Skip to content
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
5 changes: 3 additions & 2 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
# Force json test annotation files to always have LF line endings.
/src/test/resources/testannotations/**/*.json text eol=lf
# Force test annotation files to always have LF line endings.
/src/test/resources/testannotations/**/*.json text eol=lf
/src/test/resources/testannotations/**/*.csv text eol=lf
4 changes: 3 additions & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ plugins {
id 'com.github.hierynomus.license' version '0.16.1'
id 'io.miret.etienne.sass' version '1.5.1'
id "com.ryandens.javaagent-test" version "0.7.0"
id "io.freefair.lombok" version "8.11"
}

group 'com.github.mfl28'
Expand Down Expand Up @@ -112,7 +113,8 @@ dependencies {
// https://mvnrepository.com/artifact/com.drewnoakes/metadata-extractor
implementation 'com.drewnoakes:metadata-extractor:2.19.0'

implementation 'com.opencsv:opencsv:5.9'
// https://mvnrepository.com/artifact/com.fasterxml.jackson.dataformat/jackson-dataformat-csv
implementation 'com.fasterxml.jackson.dataformat:jackson-dataformat-csv:2.18.3'
}

javafx {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1216,21 +1216,22 @@ private File getAnnotationSavingDestination(ImageAnnotationSaveStrategy.Type sav
}

private File getAnnotationLoadingSource(ImageAnnotationLoadStrategy.Type loadFormat) {
File source;

if(loadFormat.equals(ImageAnnotationLoadStrategy.Type.JSON)) {
source = MainView.displayFileChooserAndGetChoice(LOAD_IMAGE_ANNOTATIONS_FILE_CHOOSER_TITLE, stage,
return switch (loadFormat) {
case JSON -> MainView.displayFileChooserAndGetChoice(LOAD_IMAGE_ANNOTATIONS_FILE_CHOOSER_TITLE, stage,
ioMetaData.getDefaultAnnotationLoadingDirectory(),
DEFAULT_JSON_EXPORT_FILENAME,
new FileChooser.ExtensionFilter("JSON files", "*.json",
"*.JSON"),
MainView.FileChooserType.OPEN);
} else {
source = MainView.displayDirectoryChooserAndGetChoice(LOAD_IMAGE_ANNOTATIONS_DIRECTORY_CHOOSER_TITLE, stage,
case CSV -> MainView.displayFileChooserAndGetChoice(LOAD_IMAGE_ANNOTATIONS_FILE_CHOOSER_TITLE, stage,
ioMetaData.getDefaultAnnotationLoadingDirectory(),
DEFAULT_CSV_EXPORT_FILENAME,
new FileChooser.ExtensionFilter("CSV files", "*.csv",
"*.CSV"),
MainView.FileChooserType.OPEN);
default -> MainView.displayDirectoryChooserAndGetChoice(LOAD_IMAGE_ANNOTATIONS_DIRECTORY_CHOOSER_TITLE, stage,
ioMetaData.getDefaultAnnotationLoadingDirectory());
}

return source;
};
}

private void interruptDirectoryWatcher() {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
/*
* Copyright (C) 2025 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.fasterxml.jackson.databind.MappingIterator;
import com.fasterxml.jackson.databind.RuntimeJsonMappingException;
import com.fasterxml.jackson.dataformat.csv.CsvMapper;
import com.fasterxml.jackson.dataformat.csv.CsvParser;
import com.fasterxml.jackson.dataformat.csv.CsvReadException;
import com.github.mfl28.boundingboxeditor.model.data.*;
import com.github.mfl28.boundingboxeditor.model.io.data.CSVRow;
import com.github.mfl28.boundingboxeditor.model.io.results.IOErrorInfoEntry;
import com.github.mfl28.boundingboxeditor.model.io.results.ImageAnnotationImportResult;
import com.github.mfl28.boundingboxeditor.utils.ColorUtils;
import javafx.beans.property.DoubleProperty;

import java.io.IOException;
import java.nio.file.Path;
import java.util.*;

public class CSVLoadStrategy implements ImageAnnotationLoadStrategy {

private static boolean filterRow(Set<String> filesToLoad, CSVRow csvRow, List<IOErrorInfoEntry> errorInfoEntries) {
if (filesToLoad.contains(csvRow.getFilename())) {
return true;
}

errorInfoEntries.add(new IOErrorInfoEntry(csvRow.getFilename(),
"Image " + csvRow.getFilename() +

Check warning on line 45 in src/main/java/com/github/mfl28/boundingboxeditor/model/io/CSVLoadStrategy.java

View check run for this annotation

Codecov / codecov/patch

src/main/java/com/github/mfl28/boundingboxeditor/model/io/CSVLoadStrategy.java#L44-L45

Added lines #L44 - L45 were not covered by tests
" does not belong to currently loaded image files."));

return false;

Check warning on line 48 in src/main/java/com/github/mfl28/boundingboxeditor/model/io/CSVLoadStrategy.java

View check run for this annotation

Codecov / codecov/patch

src/main/java/com/github/mfl28/boundingboxeditor/model/io/CSVLoadStrategy.java#L48

Added line #L48 was not covered by tests
}

private static void updateAnnotations(
CSVRow csvRow, Map<String, ImageAnnotation> filenameAnnotationMap,
Map<String, ObjectCategory> categoryNameToCategoryMap,
Map<String, Integer> categoryNameToShapeCountMap) {
var filename = csvRow.getFilename();

var imageAnnotation = filenameAnnotationMap.computeIfAbsent(
filename, key -> new ImageAnnotation(new ImageMetaData(key)));

var boundingBoxData = createBoundingBox(csvRow, categoryNameToCategoryMap);

imageAnnotation.getBoundingShapeData().add(boundingBoxData);
categoryNameToShapeCountMap.merge(boundingBoxData.getCategoryName(), 1, Integer::sum);
}

private static BoundingBoxData createBoundingBox(CSVRow csvRow, Map<String, ObjectCategory> existingCategoryNameToCategoryMap) {
var objectCategory = existingCategoryNameToCategoryMap.computeIfAbsent(csvRow.getCategoryName(),
name -> new ObjectCategory(name, ColorUtils.createRandomColor()));

double xMinRelative = (double) csvRow.getXMin() / csvRow.getWidth();
double yMinRelative = (double) csvRow.getYMin() / csvRow.getHeight();
double xMaxRelative = (double) csvRow.getXMax() / csvRow.getWidth();
double yMaxRelative = (double) csvRow.getYMax() / csvRow.getHeight();

return new BoundingBoxData(
objectCategory, xMinRelative, yMinRelative, xMaxRelative, yMaxRelative,
Collections.emptyList());
}

@Override
public ImageAnnotationImportResult load(Path path, Set<String> filesToLoad,
Map<String, ObjectCategory> existingCategoryNameToCategoryMap,
DoubleProperty progress) throws IOException {
final Map<String, Integer> categoryNameToBoundingShapesCountMap = new HashMap<>();
final List<IOErrorInfoEntry> errorInfoEntries = new ArrayList<>();
final Map<String, ImageAnnotation> filenameAnnotationMap = new HashMap<>();

progress.set(0);

final var csvMapper = new CsvMapper();
final var csvSchema = csvMapper.schemaFor(CSVRow.class)
.withHeader()
.withColumnReordering(true)
.withStrictHeaders(true);

try (MappingIterator<CSVRow> it = csvMapper
.readerFor(CSVRow.class)
.with(csvSchema)
.without(CsvParser.Feature.IGNORE_TRAILING_UNMAPPABLE)
.without(CsvParser.Feature.ALLOW_TRAILING_COMMA)
.with(CsvParser.Feature.FAIL_ON_MISSING_COLUMNS)
.with(CsvParser.Feature.FAIL_ON_MISSING_HEADER_COLUMNS)
.readValues(path.toFile())) {
it.forEachRemaining(csvRow -> {
try {
if (filterRow(filesToLoad, csvRow, errorInfoEntries)) {
updateAnnotations(csvRow, filenameAnnotationMap,
existingCategoryNameToCategoryMap,
categoryNameToBoundingShapesCountMap);
}

} catch (RuntimeJsonMappingException exception) {
errorInfoEntries.add(new IOErrorInfoEntry(path.getFileName().toString(),
exception.getMessage()));

Check warning on line 114 in src/main/java/com/github/mfl28/boundingboxeditor/model/io/CSVLoadStrategy.java

View check run for this annotation

Codecov / codecov/patch

src/main/java/com/github/mfl28/boundingboxeditor/model/io/CSVLoadStrategy.java#L112-L114

Added lines #L112 - L114 were not covered by tests
}
}
);
} catch (CsvReadException exception) {
errorInfoEntries.add(new IOErrorInfoEntry(path.getFileName().toString(),
exception.getMessage()));

Check warning on line 120 in src/main/java/com/github/mfl28/boundingboxeditor/model/io/CSVLoadStrategy.java

View check run for this annotation

Codecov / codecov/patch

src/main/java/com/github/mfl28/boundingboxeditor/model/io/CSVLoadStrategy.java#L118-L120

Added lines #L118 - L120 were not covered by tests
}

var imageAnnotationData = new ImageAnnotationData(
filenameAnnotationMap.values(), categoryNameToBoundingShapesCountMap,
existingCategoryNameToCategoryMap);

progress.set(1.0);

return new ImageAnnotationImportResult(
imageAnnotationData.imageAnnotations().size(),
errorInfoEntries,
imageAnnotationData
);
}

}

Original file line number Diff line number Diff line change
Expand Up @@ -18,66 +18,55 @@
*/
package com.github.mfl28.boundingboxeditor.model.io;

import com.fasterxml.jackson.dataformat.csv.CsvMapper;
import com.github.mfl28.boundingboxeditor.model.data.BoundingBoxData;
import com.github.mfl28.boundingboxeditor.model.data.ImageAnnotation;
import com.github.mfl28.boundingboxeditor.model.data.ImageAnnotationData;
import com.github.mfl28.boundingboxeditor.model.io.data.CSVRow;
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 org.apache.commons.lang3.tuple.Pair;

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;
import java.util.concurrent.atomic.AtomicInteger;

/**
* Saving-strategy to export annotations to a CSV file.
* <p>
* The CSVSaveStrategy supports {@link BoundingBoxData} only.
*/
public class CSVSaveStrategy implements ImageAnnotationSaveStrategy {
private static final String FILE_NAME_SERIALIZED_NAME = "filename";
private static final String WIDTH_SERIALIZED_NAME = "width";
private static final String HEIGHT_SERIALIZED_NAME = "height";
private static final String CLASS_SERIALIZED_NAME = "class";
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 AtomicInteger nrProcessedAnnotations = new AtomicInteger();

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

try (ICSVWriter writer = new CSVWriterBuilder(Files.newBufferedWriter(destination, StandardCharsets.UTF_8)).build()) {
String[] header = {
FILE_NAME_SERIALIZED_NAME,
WIDTH_SERIALIZED_NAME,
HEIGHT_SERIALIZED_NAME,
CLASS_SERIALIZED_NAME,
MIN_X_SERIALIZED_NAME,
MIN_Y_SERIALIZED_NAME,
MAX_X_SERIALIZED_NAME,
MAX_Y_SERIALIZED_NAME};

writer.writeNext(header);
try (var writer = Files.newBufferedWriter(destination, StandardCharsets.UTF_8)) {
var csvMapper = new CsvMapper();
var csvSchema = csvMapper.schemaFor(CSVRow.class).withHeader();

for (var imageAnnotation : annotations.imageAnnotations()) {
for (var boundingShapeData : imageAnnotation.getBoundingShapeData()) {
if (boundingShapeData instanceof BoundingBoxData boundingBoxData) {
writer.writeNext(buildLine(imageAnnotation, boundingBoxData));
}
try (var valuesWriter = csvMapper.writer(csvSchema).writeValues(writer)) {
valuesWriter.writeAll(
annotations.imageAnnotations().stream()
.flatMap(
imageAnnotation -> {
progress.set(1.0 * nrProcessedAnnotations.getAndIncrement() / totalNrAnnotations);

progress.set(1.0 * nrProcessedAnnotations++ / totalNrAnnotations);
}
return imageAnnotation.getBoundingShapeData().stream()
.filter(BoundingBoxData.class::isInstance)
.map(boundingShapeData -> Pair.of(imageAnnotation, (BoundingBoxData) boundingShapeData));
})
.map(pair -> CSVRow.fromData(pair.getLeft(), pair.getRight()))
.toList()
);
}
} catch (IOException e) {
errorEntries.add(new IOErrorInfoEntry(destination.getFileName().toString(), e.getMessage()));
Expand All @@ -89,21 +78,4 @@ public ImageAnnotationExportResult save(ImageAnnotationData annotations, Path de
);
}

private static String[] buildLine(ImageAnnotation imageAnnotation, BoundingBoxData boundingBoxData) {
double imageWidth = imageAnnotation.getImageMetaData().getImageWidth();
double imageHeight = imageAnnotation.getImageMetaData().getImageHeight();

var bounds = boundingBoxData.getAbsoluteBoundsInImage(imageWidth, imageHeight);

return new String[]{
imageAnnotation.getImageFileName(),
String.valueOf((int) Math.round(imageWidth)),
String.valueOf((int) Math.round(imageHeight)),
boundingBoxData.getCategoryName(),
String.valueOf((int) Math.round(bounds.getMinX())),
String.valueOf((int) Math.round(bounds.getMinY())),
String.valueOf((int) Math.round(bounds.getMaxX())),
String.valueOf((int) Math.round(bounds.getMaxY()))
};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@

import java.io.IOException;
import java.nio.file.Path;
import java.security.InvalidParameterException;
import java.util.Map;
import java.util.Set;

Expand All @@ -40,15 +39,12 @@ public interface ImageAnnotationLoadStrategy {
* @return the loading-strategy with the provided type
*/
static ImageAnnotationLoadStrategy createStrategy(Type type) {
if(type.equals(Type.PASCAL_VOC)) {
return new PVOCLoadStrategy();
} else if(type.equals(Type.YOLO)) {
return new YOLOLoadStrategy();
} else if(type.equals(Type.JSON)) {
return new JSONLoadStrategy();
} else {
throw new InvalidParameterException();
}
return switch (type) {
case PASCAL_VOC -> new PVOCLoadStrategy();
case YOLO -> new YOLOLoadStrategy();
case JSON -> new JSONLoadStrategy();
case CSV -> new CSVLoadStrategy();
};
}

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

enum Type {PASCAL_VOC, YOLO, JSON}
enum Type {PASCAL_VOC, YOLO, JSON, CSV}

@SuppressWarnings("serial")
class InvalidAnnotationFormatException extends RuntimeException {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@
import javafx.beans.property.DoubleProperty;

import java.nio.file.Path;
import java.security.InvalidParameterException;

/**
* The interface of an image annotation saving-strategy.
Expand All @@ -37,17 +36,12 @@ public interface ImageAnnotationSaveStrategy {
* @return the saving-strategy with the provided type
*/
static ImageAnnotationSaveStrategy createStrategy(Type type) {
if(type.equals(Type.PASCAL_VOC)) {
return new PVOCSaveStrategy();
} else if(type.equals(Type.YOLO)) {
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();
}
return switch (type) {
case PASCAL_VOC -> new PVOCSaveStrategy();
case YOLO -> new YOLOSaveStrategy();
case JSON -> new JSONSaveStrategy();
case CSV -> new CSVSaveStrategy();
};
}

/**
Expand Down
Loading