Skip to content

Commit 6ee4e62

Browse files
authored
Add CSV import strategy (#147)
* Add CSV import strategy * Update test * Fix line-feeds * Extend tests
1 parent b8d0c1c commit 6ee4e62

File tree

20 files changed

+457
-273
lines changed

20 files changed

+457
-273
lines changed

.gitattributes

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
1-
# Force json test annotation files to always have LF line endings.
2-
/src/test/resources/testannotations/**/*.json text eol=lf
1+
# Force test annotation files to always have LF line endings.
2+
/src/test/resources/testannotations/**/*.json text eol=lf
3+
/src/test/resources/testannotations/**/*.csv text eol=lf

build.gradle

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ plugins {
2929
id 'com.github.hierynomus.license' version '0.16.1'
3030
id 'io.miret.etienne.sass' version '1.5.1'
3131
id "com.ryandens.javaagent-test" version "0.7.0"
32+
id "io.freefair.lombok" version "8.11"
3233
}
3334

3435
group 'com.github.mfl28'
@@ -112,7 +113,8 @@ dependencies {
112113
// https://mvnrepository.com/artifact/com.drewnoakes/metadata-extractor
113114
implementation 'com.drewnoakes:metadata-extractor:2.19.0'
114115

115-
implementation 'com.opencsv:opencsv:5.9'
116+
// https://mvnrepository.com/artifact/com.fasterxml.jackson.dataformat/jackson-dataformat-csv
117+
implementation 'com.fasterxml.jackson.dataformat:jackson-dataformat-csv:2.18.3'
116118
}
117119

118120
javafx {

src/main/java/com/github/mfl28/boundingboxeditor/controller/Controller.java

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1216,21 +1216,22 @@ private File getAnnotationSavingDestination(ImageAnnotationSaveStrategy.Type sav
12161216
}
12171217

12181218
private File getAnnotationLoadingSource(ImageAnnotationLoadStrategy.Type loadFormat) {
1219-
File source;
1220-
1221-
if(loadFormat.equals(ImageAnnotationLoadStrategy.Type.JSON)) {
1222-
source = MainView.displayFileChooserAndGetChoice(LOAD_IMAGE_ANNOTATIONS_FILE_CHOOSER_TITLE, stage,
1219+
return switch (loadFormat) {
1220+
case JSON -> MainView.displayFileChooserAndGetChoice(LOAD_IMAGE_ANNOTATIONS_FILE_CHOOSER_TITLE, stage,
12231221
ioMetaData.getDefaultAnnotationLoadingDirectory(),
12241222
DEFAULT_JSON_EXPORT_FILENAME,
12251223
new FileChooser.ExtensionFilter("JSON files", "*.json",
12261224
"*.JSON"),
12271225
MainView.FileChooserType.OPEN);
1228-
} else {
1229-
source = MainView.displayDirectoryChooserAndGetChoice(LOAD_IMAGE_ANNOTATIONS_DIRECTORY_CHOOSER_TITLE, stage,
1226+
case CSV -> MainView.displayFileChooserAndGetChoice(LOAD_IMAGE_ANNOTATIONS_FILE_CHOOSER_TITLE, stage,
1227+
ioMetaData.getDefaultAnnotationLoadingDirectory(),
1228+
DEFAULT_CSV_EXPORT_FILENAME,
1229+
new FileChooser.ExtensionFilter("CSV files", "*.csv",
1230+
"*.CSV"),
1231+
MainView.FileChooserType.OPEN);
1232+
default -> MainView.displayDirectoryChooserAndGetChoice(LOAD_IMAGE_ANNOTATIONS_DIRECTORY_CHOOSER_TITLE, stage,
12301233
ioMetaData.getDefaultAnnotationLoadingDirectory());
1231-
}
1232-
1233-
return source;
1234+
};
12341235
}
12351236

12361237
private void interruptDirectoryWatcher() {
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
/*
2+
* Copyright (C) 2025 Markus Fleischhacker <markus.fleischhacker28@gmail.com>
3+
*
4+
* This file is part of Bounding Box Editor
5+
*
6+
* Bounding Box Editor is free software: you can redistribute it and/or modify
7+
* it under the terms of the GNU General Public License as published by
8+
* the Free Software Foundation, either version 3 of the License, or
9+
* (at your option) any later version.
10+
*
11+
* Bounding Box Editor is distributed in the hope that it will be useful,
12+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
13+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14+
* GNU General Public License for more details.
15+
*
16+
* You should have received a copy of the GNU General Public License
17+
* along with Bounding Box Editor. If not, see <http://www.gnu.org/licenses/>.
18+
*/
19+
package com.github.mfl28.boundingboxeditor.model.io;
20+
21+
import com.fasterxml.jackson.databind.MappingIterator;
22+
import com.fasterxml.jackson.databind.RuntimeJsonMappingException;
23+
import com.fasterxml.jackson.dataformat.csv.CsvMapper;
24+
import com.fasterxml.jackson.dataformat.csv.CsvParser;
25+
import com.fasterxml.jackson.dataformat.csv.CsvReadException;
26+
import com.github.mfl28.boundingboxeditor.model.data.*;
27+
import com.github.mfl28.boundingboxeditor.model.io.data.CSVRow;
28+
import com.github.mfl28.boundingboxeditor.model.io.results.IOErrorInfoEntry;
29+
import com.github.mfl28.boundingboxeditor.model.io.results.ImageAnnotationImportResult;
30+
import com.github.mfl28.boundingboxeditor.utils.ColorUtils;
31+
import javafx.beans.property.DoubleProperty;
32+
33+
import java.io.IOException;
34+
import java.nio.file.Path;
35+
import java.util.*;
36+
37+
public class CSVLoadStrategy implements ImageAnnotationLoadStrategy {
38+
39+
private static boolean filterRow(Set<String> filesToLoad, CSVRow csvRow, List<IOErrorInfoEntry> errorInfoEntries) {
40+
if (filesToLoad.contains(csvRow.getFilename())) {
41+
return true;
42+
}
43+
44+
errorInfoEntries.add(new IOErrorInfoEntry(csvRow.getFilename(),
45+
"Image " + csvRow.getFilename() +
46+
" does not belong to currently loaded image files."));
47+
48+
return false;
49+
}
50+
51+
private static void updateAnnotations(
52+
CSVRow csvRow, Map<String, ImageAnnotation> filenameAnnotationMap,
53+
Map<String, ObjectCategory> categoryNameToCategoryMap,
54+
Map<String, Integer> categoryNameToShapeCountMap) {
55+
var filename = csvRow.getFilename();
56+
57+
var imageAnnotation = filenameAnnotationMap.computeIfAbsent(
58+
filename, key -> new ImageAnnotation(new ImageMetaData(key)));
59+
60+
var boundingBoxData = createBoundingBox(csvRow, categoryNameToCategoryMap);
61+
62+
imageAnnotation.getBoundingShapeData().add(boundingBoxData);
63+
categoryNameToShapeCountMap.merge(boundingBoxData.getCategoryName(), 1, Integer::sum);
64+
}
65+
66+
private static BoundingBoxData createBoundingBox(CSVRow csvRow, Map<String, ObjectCategory> existingCategoryNameToCategoryMap) {
67+
var objectCategory = existingCategoryNameToCategoryMap.computeIfAbsent(csvRow.getCategoryName(),
68+
name -> new ObjectCategory(name, ColorUtils.createRandomColor()));
69+
70+
double xMinRelative = (double) csvRow.getXMin() / csvRow.getWidth();
71+
double yMinRelative = (double) csvRow.getYMin() / csvRow.getHeight();
72+
double xMaxRelative = (double) csvRow.getXMax() / csvRow.getWidth();
73+
double yMaxRelative = (double) csvRow.getYMax() / csvRow.getHeight();
74+
75+
return new BoundingBoxData(
76+
objectCategory, xMinRelative, yMinRelative, xMaxRelative, yMaxRelative,
77+
Collections.emptyList());
78+
}
79+
80+
@Override
81+
public ImageAnnotationImportResult load(Path path, Set<String> filesToLoad,
82+
Map<String, ObjectCategory> existingCategoryNameToCategoryMap,
83+
DoubleProperty progress) throws IOException {
84+
final Map<String, Integer> categoryNameToBoundingShapesCountMap = new HashMap<>();
85+
final List<IOErrorInfoEntry> errorInfoEntries = new ArrayList<>();
86+
final Map<String, ImageAnnotation> filenameAnnotationMap = new HashMap<>();
87+
88+
progress.set(0);
89+
90+
final var csvMapper = new CsvMapper();
91+
final var csvSchema = csvMapper.schemaFor(CSVRow.class)
92+
.withHeader()
93+
.withColumnReordering(true)
94+
.withStrictHeaders(true);
95+
96+
try (MappingIterator<CSVRow> it = csvMapper
97+
.readerFor(CSVRow.class)
98+
.with(csvSchema)
99+
.without(CsvParser.Feature.IGNORE_TRAILING_UNMAPPABLE)
100+
.without(CsvParser.Feature.ALLOW_TRAILING_COMMA)
101+
.with(CsvParser.Feature.FAIL_ON_MISSING_COLUMNS)
102+
.with(CsvParser.Feature.FAIL_ON_MISSING_HEADER_COLUMNS)
103+
.readValues(path.toFile())) {
104+
it.forEachRemaining(csvRow -> {
105+
try {
106+
if (filterRow(filesToLoad, csvRow, errorInfoEntries)) {
107+
updateAnnotations(csvRow, filenameAnnotationMap,
108+
existingCategoryNameToCategoryMap,
109+
categoryNameToBoundingShapesCountMap);
110+
}
111+
112+
} catch (RuntimeJsonMappingException exception) {
113+
errorInfoEntries.add(new IOErrorInfoEntry(path.getFileName().toString(),
114+
exception.getMessage()));
115+
}
116+
}
117+
);
118+
} catch (CsvReadException exception) {
119+
errorInfoEntries.add(new IOErrorInfoEntry(path.getFileName().toString(),
120+
exception.getMessage()));
121+
}
122+
123+
var imageAnnotationData = new ImageAnnotationData(
124+
filenameAnnotationMap.values(), categoryNameToBoundingShapesCountMap,
125+
existingCategoryNameToCategoryMap);
126+
127+
progress.set(1.0);
128+
129+
return new ImageAnnotationImportResult(
130+
imageAnnotationData.imageAnnotations().size(),
131+
errorInfoEntries,
132+
imageAnnotationData
133+
);
134+
}
135+
136+
}
137+

src/main/java/com/github/mfl28/boundingboxeditor/model/io/CSVSaveStrategy.java

Lines changed: 21 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -18,66 +18,55 @@
1818
*/
1919
package com.github.mfl28.boundingboxeditor.model.io;
2020

21+
import com.fasterxml.jackson.dataformat.csv.CsvMapper;
2122
import com.github.mfl28.boundingboxeditor.model.data.BoundingBoxData;
22-
import com.github.mfl28.boundingboxeditor.model.data.ImageAnnotation;
2323
import com.github.mfl28.boundingboxeditor.model.data.ImageAnnotationData;
24+
import com.github.mfl28.boundingboxeditor.model.io.data.CSVRow;
2425
import com.github.mfl28.boundingboxeditor.model.io.results.IOErrorInfoEntry;
2526
import com.github.mfl28.boundingboxeditor.model.io.results.ImageAnnotationExportResult;
26-
import com.opencsv.CSVWriterBuilder;
27-
import com.opencsv.ICSVWriter;
2827
import javafx.beans.property.DoubleProperty;
28+
import org.apache.commons.lang3.tuple.Pair;
2929

3030
import java.io.IOException;
3131
import java.nio.charset.StandardCharsets;
3232
import java.nio.file.Files;
3333
import java.nio.file.Path;
3434
import java.util.ArrayList;
3535
import java.util.List;
36+
import java.util.concurrent.atomic.AtomicInteger;
3637

3738
/**
3839
* Saving-strategy to export annotations to a CSV file.
3940
* <p>
4041
* The CSVSaveStrategy supports {@link BoundingBoxData} only.
4142
*/
4243
public class CSVSaveStrategy implements ImageAnnotationSaveStrategy {
43-
private static final String FILE_NAME_SERIALIZED_NAME = "filename";
44-
private static final String WIDTH_SERIALIZED_NAME = "width";
45-
private static final String HEIGHT_SERIALIZED_NAME = "height";
46-
private static final String CLASS_SERIALIZED_NAME = "class";
47-
private static final String MIN_X_SERIALIZED_NAME = "xmin";
48-
private static final String MAX_X_SERIALIZED_NAME = "xmax";
49-
private static final String MIN_Y_SERIALIZED_NAME = "ymin";
50-
private static final String MAX_Y_SERIALIZED_NAME = "ymax";
51-
5244
@Override
5345
public ImageAnnotationExportResult save(ImageAnnotationData annotations, Path destination,
5446
DoubleProperty progress) {
5547
final int totalNrAnnotations = annotations.imageAnnotations().size();
56-
int nrProcessedAnnotations = 0;
48+
final AtomicInteger nrProcessedAnnotations = new AtomicInteger();
5749

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

60-
try (ICSVWriter writer = new CSVWriterBuilder(Files.newBufferedWriter(destination, StandardCharsets.UTF_8)).build()) {
61-
String[] header = {
62-
FILE_NAME_SERIALIZED_NAME,
63-
WIDTH_SERIALIZED_NAME,
64-
HEIGHT_SERIALIZED_NAME,
65-
CLASS_SERIALIZED_NAME,
66-
MIN_X_SERIALIZED_NAME,
67-
MIN_Y_SERIALIZED_NAME,
68-
MAX_X_SERIALIZED_NAME,
69-
MAX_Y_SERIALIZED_NAME};
70-
71-
writer.writeNext(header);
52+
try (var writer = Files.newBufferedWriter(destination, StandardCharsets.UTF_8)) {
53+
var csvMapper = new CsvMapper();
54+
var csvSchema = csvMapper.schemaFor(CSVRow.class).withHeader();
7255

73-
for (var imageAnnotation : annotations.imageAnnotations()) {
74-
for (var boundingShapeData : imageAnnotation.getBoundingShapeData()) {
75-
if (boundingShapeData instanceof BoundingBoxData boundingBoxData) {
76-
writer.writeNext(buildLine(imageAnnotation, boundingBoxData));
77-
}
56+
try (var valuesWriter = csvMapper.writer(csvSchema).writeValues(writer)) {
57+
valuesWriter.writeAll(
58+
annotations.imageAnnotations().stream()
59+
.flatMap(
60+
imageAnnotation -> {
61+
progress.set(1.0 * nrProcessedAnnotations.getAndIncrement() / totalNrAnnotations);
7862

79-
progress.set(1.0 * nrProcessedAnnotations++ / totalNrAnnotations);
80-
}
63+
return imageAnnotation.getBoundingShapeData().stream()
64+
.filter(BoundingBoxData.class::isInstance)
65+
.map(boundingShapeData -> Pair.of(imageAnnotation, (BoundingBoxData) boundingShapeData));
66+
})
67+
.map(pair -> CSVRow.fromData(pair.getLeft(), pair.getRight()))
68+
.toList()
69+
);
8170
}
8271
} catch (IOException e) {
8372
errorEntries.add(new IOErrorInfoEntry(destination.getFileName().toString(), e.getMessage()));
@@ -89,21 +78,4 @@ public ImageAnnotationExportResult save(ImageAnnotationData annotations, Path de
8978
);
9079
}
9180

92-
private static String[] buildLine(ImageAnnotation imageAnnotation, BoundingBoxData boundingBoxData) {
93-
double imageWidth = imageAnnotation.getImageMetaData().getImageWidth();
94-
double imageHeight = imageAnnotation.getImageMetaData().getImageHeight();
95-
96-
var bounds = boundingBoxData.getAbsoluteBoundsInImage(imageWidth, imageHeight);
97-
98-
return new String[]{
99-
imageAnnotation.getImageFileName(),
100-
String.valueOf((int) Math.round(imageWidth)),
101-
String.valueOf((int) Math.round(imageHeight)),
102-
boundingBoxData.getCategoryName(),
103-
String.valueOf((int) Math.round(bounds.getMinX())),
104-
String.valueOf((int) Math.round(bounds.getMinY())),
105-
String.valueOf((int) Math.round(bounds.getMaxX())),
106-
String.valueOf((int) Math.round(bounds.getMaxY()))
107-
};
108-
}
10981
}

src/main/java/com/github/mfl28/boundingboxeditor/model/io/ImageAnnotationLoadStrategy.java

Lines changed: 7 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,6 @@
2525

2626
import java.io.IOException;
2727
import java.nio.file.Path;
28-
import java.security.InvalidParameterException;
2928
import java.util.Map;
3029
import java.util.Set;
3130

@@ -40,15 +39,12 @@ public interface ImageAnnotationLoadStrategy {
4039
* @return the loading-strategy with the provided type
4140
*/
4241
static ImageAnnotationLoadStrategy createStrategy(Type type) {
43-
if(type.equals(Type.PASCAL_VOC)) {
44-
return new PVOCLoadStrategy();
45-
} else if(type.equals(Type.YOLO)) {
46-
return new YOLOLoadStrategy();
47-
} else if(type.equals(Type.JSON)) {
48-
return new JSONLoadStrategy();
49-
} else {
50-
throw new InvalidParameterException();
51-
}
42+
return switch (type) {
43+
case PASCAL_VOC -> new PVOCLoadStrategy();
44+
case YOLO -> new YOLOLoadStrategy();
45+
case JSON -> new JSONLoadStrategy();
46+
case CSV -> new CSVLoadStrategy();
47+
};
5248
}
5349

5450
/**
@@ -65,7 +61,7 @@ ImageAnnotationImportResult load(Path path, Set<String> filesToLoad,
6561
Map<String, ObjectCategory> existingCategoryNameToCategoryMap,
6662
DoubleProperty progress) throws IOException;
6763

68-
enum Type {PASCAL_VOC, YOLO, JSON}
64+
enum Type {PASCAL_VOC, YOLO, JSON, CSV}
6965

7066
@SuppressWarnings("serial")
7167
class InvalidAnnotationFormatException extends RuntimeException {

src/main/java/com/github/mfl28/boundingboxeditor/model/io/ImageAnnotationSaveStrategy.java

Lines changed: 6 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@
2424
import javafx.beans.property.DoubleProperty;
2525

2626
import java.nio.file.Path;
27-
import java.security.InvalidParameterException;
2827

2928
/**
3029
* The interface of an image annotation saving-strategy.
@@ -37,17 +36,12 @@ public interface ImageAnnotationSaveStrategy {
3736
* @return the saving-strategy with the provided type
3837
*/
3938
static ImageAnnotationSaveStrategy createStrategy(Type type) {
40-
if(type.equals(Type.PASCAL_VOC)) {
41-
return new PVOCSaveStrategy();
42-
} else if(type.equals(Type.YOLO)) {
43-
return new YOLOSaveStrategy();
44-
} else if(type.equals(Type.JSON)) {
45-
return new JSONSaveStrategy();
46-
} else if(type.equals(Type.CSV)) {
47-
return new CSVSaveStrategy();
48-
} else {
49-
throw new InvalidParameterException();
50-
}
39+
return switch (type) {
40+
case PASCAL_VOC -> new PVOCSaveStrategy();
41+
case YOLO -> new YOLOSaveStrategy();
42+
case JSON -> new JSONSaveStrategy();
43+
case CSV -> new CSVSaveStrategy();
44+
};
5145
}
5246

5347
/**

0 commit comments

Comments
 (0)