From 10d717f0b48c4d174429bf5ac3ceac8c5303f66f Mon Sep 17 00:00:00 2001 From: Markus Fleischhacker Date: Mon, 10 Mar 2025 17:46:03 +0100 Subject: [PATCH] Export parts in yolo and csv formats --- .../model/io/CSVSaveStrategy.java | 35 +++- .../model/io/YOLOSaveStrategy.java | 25 ++- .../controller/ControllerTests.java | 155 ++++++++++++++++++ .../csv/reference/annotations_with_parts.csv | 9 + .../austin-neill-685084-unsplash.txt | 9 + .../yolo/reference_with_parts/object.data | 3 + 6 files changed, 233 insertions(+), 3 deletions(-) create mode 100644 src/test/resources/testannotations/csv/reference/annotations_with_parts.csv create mode 100644 src/test/resources/testannotations/yolo/reference_with_parts/austin-neill-685084-unsplash.txt create mode 100644 src/test/resources/testannotations/yolo/reference_with_parts/object.data 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 d3d2b40..de57356 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 @@ -20,6 +20,7 @@ import com.fasterxml.jackson.dataformat.csv.CsvMapper; import com.github.mfl28.boundingboxeditor.model.data.BoundingBoxData; +import com.github.mfl28.boundingboxeditor.model.data.BoundingShapeData; 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; @@ -31,9 +32,12 @@ import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; +import java.util.ArrayDeque; import java.util.ArrayList; +import java.util.Deque; import java.util.List; import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Stream; /** * Saving-strategy to export annotations to a CSV file. @@ -61,8 +65,8 @@ public ImageAnnotationExportResult save(ImageAnnotationData annotations, Path de progress.set(1.0 * nrProcessedAnnotations.getAndIncrement() / totalNrAnnotations); return imageAnnotation.getBoundingShapeData().stream() - .filter(BoundingBoxData.class::isInstance) - .map(boundingShapeData -> Pair.of(imageAnnotation, (BoundingBoxData) boundingShapeData)); + .flatMap(this::extractBoundingBoxDataElements) + .map(boundingBoxData -> Pair.of(imageAnnotation, boundingBoxData)); }) .map(pair -> CSVRow.fromData(pair.getLeft(), pair.getRight())) .toList() @@ -78,4 +82,31 @@ public ImageAnnotationExportResult save(ImageAnnotationData annotations, Path de ); } + private Stream extractBoundingBoxDataElements(BoundingShapeData boundingShapeData) { + if(boundingShapeData.getParts().isEmpty()) { + if(boundingShapeData instanceof BoundingBoxData boundingBoxData) { + return Stream.of(boundingBoxData); + } + + return Stream.empty(); + } + + final Deque stack = new ArrayDeque<>(); + final List result = new ArrayList<>(); + + stack.push(boundingShapeData); + + while(!stack.isEmpty()) { + var currentBoundingShape = stack.pop(); + + if(currentBoundingShape instanceof BoundingBoxData boundingBoxData) { + result.add(boundingBoxData); + } + + stack.addAll(currentBoundingShape.getParts()); + } + + return result.stream(); + } + } diff --git a/src/main/java/com/github/mfl28/boundingboxeditor/model/io/YOLOSaveStrategy.java b/src/main/java/com/github/mfl28/boundingboxeditor/model/io/YOLOSaveStrategy.java index 281ecfe..d5e2028 100644 --- a/src/main/java/com/github/mfl28/boundingboxeditor/model/io/YOLOSaveStrategy.java +++ b/src/main/java/com/github/mfl28/boundingboxeditor/model/io/YOLOSaveStrategy.java @@ -34,6 +34,7 @@ import java.util.*; import java.util.concurrent.atomic.AtomicInteger; import java.util.stream.Collectors; +import java.util.stream.Stream; /** * Saves bounding-box and bounding-polygon annotations (with at least 3 nodes) @@ -106,7 +107,9 @@ private void createAnnotationFile(ImageAnnotation annotation) throws IOException try (BufferedWriter fileWriter = Files.newBufferedWriter( saveFolderPath.resolve(imageFileNameWithoutExtension + YOLO_ANNOTATION_FILE_EXTENSION))) { - List boundingShapeDataList = annotation.getBoundingShapeData(); + List boundingShapeDataList = annotation.getBoundingShapeData().stream() + .flatMap(this::extractBoundingShapeDataElements) + .toList(); for (int i = 0; i < boundingShapeDataList.size(); ++i) { BoundingShapeData boundingShapeData = boundingShapeDataList.get(i); @@ -153,4 +156,24 @@ private String createBoundingPolygonDataEntry(BoundingPolygonData boundingPolygo return StringUtils.join(List.of(categoryIndex, relativePointsEntry), " "); } + + private Stream extractBoundingShapeDataElements(BoundingShapeData boundingShapeData) { + if(boundingShapeData.getParts().isEmpty()) { + return Stream.of(boundingShapeData); + } + + final Deque stack = new ArrayDeque<>(); + final List result = new ArrayList<>(); + + stack.push(boundingShapeData); + + while(!stack.isEmpty()) { + var currentBoundingBox = stack.pop(); + + result.add(currentBoundingBox); + stack.addAll(currentBoundingBox.getParts()); + } + + return result.stream(); + } } diff --git a/src/test/java/com/github/mfl28/boundingboxeditor/controller/ControllerTests.java b/src/test/java/com/github/mfl28/boundingboxeditor/controller/ControllerTests.java index 8254ebd..1a1f964 100644 --- a/src/test/java/com/github/mfl28/boundingboxeditor/controller/ControllerTests.java +++ b/src/test/java/com/github/mfl28/boundingboxeditor/controller/ControllerTests.java @@ -1274,6 +1274,118 @@ void onExportAnnotation_CSV_WhenPreviouslyImportedAnnotation_ShouldProduceEquiva TIMEOUT_DURATION_IN_SEC + " sec.")); } + @Test + void onExportAnnotation_CSV_WhenPreviouslyImportedAnnotationWithParts_OutputShouldIncludeNestedBoxes(FxRobot robot, + TestInfo testinfo, + @TempDir Path tempDirectory) + throws IOException { + loadJson("/testannotations/json/reference/annotations.json", testinfo); + + final String referenceAnnotationFilePath = "/testannotations/csv/reference/annotations_with_parts.csv"; + final File referenceAnnotationFile = + new File(Objects.requireNonNull(getClass().getResource(referenceAnnotationFilePath)).getFile()); + + // Create temporary folder to save annotations to. + Path actualFile = tempDirectory.resolve("actual.csv"); + + // Save the annotations to the temporary folder. + Platform.runLater( + () -> controller.initiateAnnotationExport(actualFile.toFile(), ImageAnnotationSaveStrategy.Type.CSV)); + WaitForAsyncUtils.waitForFxEvents(); + + timeOutAssertServiceSucceeded(controller.getAnnotationExportService(), testinfo); + + // Wait until the output-file actually exists. If the file was not created in + // the specified time-frame, a TimeoutException is thrown and the test fails. + Assertions.assertDoesNotThrow(() -> WaitForAsyncUtils.waitFor(TIMEOUT_DURATION_IN_SEC, TimeUnit.SECONDS, + () -> Files.exists(actualFile)), + () -> saveScreenshotAndReturnMessage(testinfo, + "Output-file was not created within " + + TIMEOUT_DURATION_IN_SEC + " sec.")); + + final byte[] referenceFileBytes = Files.readAllBytes(referenceAnnotationFile.toPath()); + + // Wait until the annotations were written to the output file and the file is equivalent to the reference file + // or throw a TimeoutException if this did not happen within the specified time-frame. + Assertions.assertDoesNotThrow(() -> WaitForAsyncUtils.waitFor(TIMEOUT_DURATION_IN_SEC, TimeUnit.SECONDS, + () -> Arrays.equals(referenceFileBytes, + Files.readAllBytes(actualFile))), + () -> saveScreenshotAndReturnMessage(testinfo, + "Expected annotation output-file " + + "content was not created within " + + TIMEOUT_DURATION_IN_SEC + " sec.")); + } + @Test + void onExportAnnotation_YOLO_WhenPreviouslyImportedAnnotationWithParts_OutputShouldIncludeNestedBoxes(FxRobot robot, + TestInfo testinfo, + @TempDir Path tempDirectory) + throws IOException { + loadPvoc("/testannotations/pvoc/reference/austin-neill-685084-unsplash_jpg_A.xml", testinfo); + + final String referenceAnnotationDirectoryPath = "/testannotations/yolo/reference_with_parts"; + final String expectedAnnotationFileName = "austin-neill-685084-unsplash.txt"; + + final File referenceAnnotationFolder = + new File(Objects.requireNonNull(getClass().getResource(referenceAnnotationDirectoryPath)).getFile()); + + // Create temporary folder to save annotations to. + Path actualDir = Files.createDirectory(tempDirectory.resolve("actual")); + + Assertions.assertTrue(Files.isDirectory(actualDir), + () -> saveScreenshotAndReturnMessage(testinfo, "Actual files " + + "directory does not exist.")); + + + // Save the annotations to the temporary folder. + Platform.runLater( + () -> controller.initiateAnnotationExport(actualDir.toFile(), ImageAnnotationSaveStrategy.Type.YOLO)); + WaitForAsyncUtils.waitForFxEvents(); + + timeOutAssertServiceSucceeded(controller.getAnnotationExportService(), testinfo); + + Path actualFilePath = actualDir.resolve(expectedAnnotationFileName); + Path actualObjectDataFilePath = actualDir.resolve("object.data"); + + // Wait until the output-file actually exists. If the file was not created in + // the specified time-frame, a TimeoutException is thrown and the test fails. + Assertions.assertDoesNotThrow(() -> WaitForAsyncUtils.waitFor(TIMEOUT_DURATION_IN_SEC, TimeUnit.SECONDS, + () -> Files.exists(actualFilePath) && + Files.exists(actualObjectDataFilePath)), + () -> saveScreenshotAndReturnMessage(testinfo, + "Output-files were not created within " + + TIMEOUT_DURATION_IN_SEC + " sec.")); + + final File objectDataFile = referenceAnnotationFolder.toPath().resolve("object.data").toFile(); + final byte[] objectDataFileArray = Files.readAllBytes(objectDataFile.toPath()); + + // Wait until the annotations were written to the output file and the file is equivalent to the reference file + // or throw a TimeoutException if this did not happen within the specified time-frame. + Assertions.assertDoesNotThrow(() -> WaitForAsyncUtils.waitFor(TIMEOUT_DURATION_IN_SEC, TimeUnit.SECONDS, + () -> Arrays.equals(objectDataFileArray, + Files.readAllBytes( + actualObjectDataFilePath))), + () -> saveScreenshotAndReturnMessage(testinfo, + "Expected object-data output-file " + + "content was not created within " + + TIMEOUT_DURATION_IN_SEC + " sec.")); + + + // The output file should be exactly the same as the reference file. + final File referenceFile = referenceAnnotationFolder.toPath().resolve(expectedAnnotationFileName).toFile(); + final byte[] referenceArray = Files.readAllBytes(referenceFile.toPath()); + + // Wait until the annotations were written to the output file and the file is equivalent to the reference file + // or throw a TimeoutException if this did not happen within the specified time-frame. + Assertions.assertDoesNotThrow(() -> WaitForAsyncUtils.waitFor(TIMEOUT_DURATION_IN_SEC, TimeUnit.SECONDS, + () -> Arrays.equals(referenceArray, + Files.readAllBytes( + actualFilePath))), + () -> saveScreenshotAndReturnMessage(testinfo, + "Expected annotation output-file " + + "content was not created within " + + TIMEOUT_DURATION_IN_SEC + " sec.")); + } + private void userChoosesNoOnAnnotationImportDialogSubtest(FxRobot robot, File annotationFile, TestInfo testinfo) { userChoosesToSaveExistingAnnotationsOnAnnotationImport(robot, annotationFile, testinfo); userChoosesNotToSaveExistingAnnotationsOnAnnotationImport(robot, annotationFile, testinfo); @@ -1443,4 +1555,47 @@ private void importAnnotationAndClickDialogOption(FxRobot robot, File annotation WaitForAsyncUtils.waitForFxEvents(); } + + private void loadJson(String resourceName, TestInfo testinfo) { + waitUntilCurrentImageIsLoaded(testinfo); + WaitForAsyncUtils.waitForFxEvents(); + timeOutAssertServiceSucceeded(controller.getImageMetaDataLoadingService(), testinfo); + + verifyThat(mainView.getStatusBar().getCurrentEventMessage(), + Matchers.startsWith("Successfully loaded 4 image-files from folder "), saveScreenshot(testinfo)); + final File inputAnnotationFile = + new File(Objects.requireNonNull(getClass().getResource(resourceName)).getFile()); + + // Load bounding-boxes defined in the reference annotation-file. + Platform.runLater(() -> controller + .initiateAnnotationImport(inputAnnotationFile, ImageAnnotationLoadStrategy.Type.JSON)); + WaitForAsyncUtils.waitForFxEvents(); + + timeOutAssertServiceSucceeded(controller.getAnnotationImportService(), testinfo); + WaitForAsyncUtils.waitForFxEvents(); + + verifyThat(mainView.getStatusBar().getCurrentEventMessage(), + Matchers.startsWith("Successfully imported annotations for 1 image in"), saveScreenshot(testinfo)); + } + + private void loadPvoc(String resourceName, TestInfo testinfo) { + waitUntilCurrentImageIsLoaded(testinfo); + WaitForAsyncUtils.waitForFxEvents(); + + timeOutAssertServiceSucceeded(controller.getImageMetaDataLoadingService(), testinfo); + verifyThat(mainView.getStatusBar().getCurrentEventMessage(), + Matchers.startsWith("Successfully loaded 4 image-files from folder "), saveScreenshot(testinfo)); + verifyThat(model.isSaved(), Matchers.is(true), saveScreenshot(testinfo)); + + final File referenceAnnotationFile = new File(Objects.requireNonNull(getClass().getResource(resourceName)).getFile()); + + // Load bounding-boxes defined in the reference annotation-file. + Platform.runLater(() -> controller + .initiateAnnotationImport(referenceAnnotationFile, ImageAnnotationLoadStrategy.Type.PASCAL_VOC)); + WaitForAsyncUtils.waitForFxEvents(); + + timeOutAssertServiceSucceeded(controller.getAnnotationImportService(), testinfo); + + verifyThat(model.isSaved(), Matchers.is(true), saveScreenshot(testinfo)); + } } diff --git a/src/test/resources/testannotations/csv/reference/annotations_with_parts.csv b/src/test/resources/testannotations/csv/reference/annotations_with_parts.csv new file mode 100644 index 0000000..a5ef6c4 --- /dev/null +++ b/src/test/resources/testannotations/csv/reference/annotations_with_parts.csv @@ -0,0 +1,9 @@ +filename,width,height,class,xmin,ymin,xmax,ymax +"austin-neill-685084-unsplash.jpg",3854,2033,Boat,1815,1572,2008,1660 +"austin-neill-685084-unsplash.jpg",3854,2033,Sail,1761,84,1904,583 +"austin-neill-685084-unsplash.jpg",3854,2033,Sail,1798,236,1901,553 +"austin-neill-685084-unsplash.jpg",3854,2033,Sail,1562,453,2113,1046 +"austin-neill-685084-unsplash.jpg",3854,2033,Sail,1632,442,2169,993 +"austin-neill-685084-unsplash.jpg",3854,2033,Sail,1769,811,1915,1337 +"austin-neill-685084-unsplash.jpg",3854,2033,Sail,1719,1470,1870,1710 +"austin-neill-685084-unsplash.jpg",3854,2033,Flag,1565,1748,1690,1786 diff --git a/src/test/resources/testannotations/yolo/reference_with_parts/austin-neill-685084-unsplash.txt b/src/test/resources/testannotations/yolo/reference_with_parts/austin-neill-685084-unsplash.txt new file mode 100644 index 0000000..b097f28 --- /dev/null +++ b/src/test/resources/testannotations/yolo/reference_with_parts/austin-neill-685084-unsplash.txt @@ -0,0 +1,9 @@ +0 0.45055 0.402755 0.449634 0.46699 0.45055 0.598928 0.460623 0.699616 0.4739 0.76819 0.494964 0.782946 0.512364 0.776872 0.524268 0.756906 0.537545 0.637118 0.541209 0.485219 0.54075 0.374983 0.5261 0.28818 0.496796 0.253458 0.474359 0.269085 0.45467 0.328977 +0 0.496083 0.794786 0.050039 0.04301 +2 0.475549 0.164092 0.037211 0.245519 +2 0.479923 0.194161 0.026894 0.156173 +2 0.476855 0.36865 0.142958 0.2918 +2 0.493108 0.352932 0.139434 0.270762 +2 0.477964 0.528286 0.037859 0.258637 +2 0.465627 0.782098 0.039037 0.118303 +1 0.422241 0.869144 0.03241 0.018741 \ No newline at end of file diff --git a/src/test/resources/testannotations/yolo/reference_with_parts/object.data b/src/test/resources/testannotations/yolo/reference_with_parts/object.data new file mode 100644 index 0000000..4806953 --- /dev/null +++ b/src/test/resources/testannotations/yolo/reference_with_parts/object.data @@ -0,0 +1,3 @@ +Boat +Flag +Sail \ No newline at end of file