From 459fab50b5f8d8b9d55d0d98b862da708bf3c3c5 Mon Sep 17 00:00:00 2001 From: Michael Doube Date: Wed, 15 Jun 2022 19:39:08 +0800 Subject: [PATCH] Improve headless calling of intertrabecular angles (#318) * Initialise the pixel spacing class variable * ITA: improve handling of calibrated images and test them explicitly Only isotropic images are tested so far, anisotropic pixel spacing causes the tests to cancel. * Update README.md (#302) * ITA: add tests for calibrated isotropic pixel spacing * ITA: add tests that run in headless mode Includes a new base class AbstractWrapperHeadlessTest that other tests of headless functionality can extend. This is useful for checking that wrapper plugins behave as expected when called from macros and Python scripts, or from other Java code. * Remove redundant copyright statement --- .../IntertrabecularAngleWrapper.java | 43 ++---- .../IntertrabecularAngleWrapperTest.java | 96 +++++++++++-- .../headless/AbstractWrapperHeadlessTest.java | 83 +++++++++++ ...ertrabecularAnglesWrapperHeadlessTest.java | 134 ++++++++++++++++++ 4 files changed, 318 insertions(+), 38 deletions(-) create mode 100644 Modern/wrapperPlugins/src/test/java/org/bonej/wrapperPlugins/headless/AbstractWrapperHeadlessTest.java create mode 100644 Modern/wrapperPlugins/src/test/java/org/bonej/wrapperPlugins/headless/IntertrabecularAnglesWrapperHeadlessTest.java diff --git a/Modern/wrapperPlugins/src/main/java/org/bonej/wrapperPlugins/IntertrabecularAngleWrapper.java b/Modern/wrapperPlugins/src/main/java/org/bonej/wrapperPlugins/IntertrabecularAngleWrapper.java index d2393902..9f9fa1aa 100644 --- a/Modern/wrapperPlugins/src/main/java/org/bonej/wrapperPlugins/IntertrabecularAngleWrapper.java +++ b/Modern/wrapperPlugins/src/main/java/org/bonej/wrapperPlugins/IntertrabecularAngleWrapper.java @@ -56,7 +56,6 @@ import org.bonej.utilities.ImagePlusUtil; import org.bonej.utilities.SharedTable; import org.bonej.wrapperPlugins.wrapperUtils.Common; -import org.bonej.wrapperPlugins.wrapperUtils.ResultUtils; import org.joml.Vector3d; import org.scijava.ItemIO; import org.scijava.app.StatusService; @@ -117,18 +116,16 @@ public class IntertrabecularAngleWrapper extends BoneJCommand { style = NumberWidget.SLIDER_STYLE, persistKey = "ITA_max_valence", callback = "enforceValidRange") private int maximumValence = 3; - @Parameter(label = "Minimum trabecular length (px)", min = "0", - stepSize = "1", - description = "Minimum length for a trabecula to be kept from being fused into a node", - style = NumberWidget.SPINNER_STYLE, callback = "calculateRealLength", - persist = false, initializer = "initRealLength") - private int minimumTrabecularLength; - @Parameter(label = "Margin (px)", min = "0", stepSize = "1", - description = "Nodes with centroids closer than this value to any image boundary will not be included in results", + @Parameter(label = "Minimum trabecular length (real units)", min = "0.00", + stepSize = "0.1", + description = "Minimum length in calibrated units for a trabecula to be kept from being fused into a node", + style = NumberWidget.SPINNER_STYLE, + persist = false) + private double minimumTrabecularLength; + @Parameter(label = "Margin (pixels)", min = "0", stepSize = "1", + description = "Nodes with centroids closer than this value in pixels to any image boundary will not be included in results", style = NumberWidget.SPINNER_STYLE) private int marginCutOff; - @SuppressWarnings("unused") - private String realLength = ""; @Parameter(label = "Iterate pruning", description = "If true, iterate pruning as long as short edges remain, or stop after a single pass", required = false, persistKey = "ITA_iterate") @@ -169,12 +166,13 @@ public class IntertrabecularAngleWrapper extends BoneJCommand { @Parameter private LogService logService; - private double[] coefficients; + private double[] pixelSpacing; private boolean anisotropyWarned; @Override public void run() { statusService.showStatus("Intertrabecular angles: Initialising..."); + initRealLength(); statusService.showStatus("Intertrabecular angles: skeletonising"); final ImagePlus skeleton = skeletonise(); if (showSkeleton) @@ -193,7 +191,7 @@ public void run() { statusService.showProgress(1, PROGRESS_STEPS); final ValuePair pruningResult = GraphPruning .pruneShortEdges(largestGraph, minimumTrabecularLength, iteratePruning, - useClusters, coefficients); + useClusters, pixelSpacing); final Graph cleanGraph = pruningResult.a; statusService.showStatus( "Intertrabecular angles: valence sorting trabeculae"); @@ -229,15 +227,6 @@ private Graph[] analyzeSkeleton(final ImagePlus skeleton) { return analyser.getGraphs(); } - @SuppressWarnings("unused") - private void calculateRealLength() { - final double calibratedMinimumLength = minimumTrabecularLength * - coefficients[0]; - final String unit = ResultUtils.getUnitHeader(inputImage); - realLength = String.join(" ", String.format("%.2g", - calibratedMinimumLength), unit); - } - private Map createRadianMap( final Map> valenceMap) { @@ -295,21 +284,17 @@ private void imageValidater() { } } - @SuppressWarnings("unused") private void initRealLength() { if (inputImage == null || inputImage.getCalibration() == null) { - coefficients = new double[] { 1.0, 1.0, 1.0 }; - realLength = String.join(" ", String.format("%.2g", - (double) minimumTrabecularLength)); + pixelSpacing = new double[] { 1.0, 1.0, 1.0 }; } else { final Calibration calibration = inputImage.getCalibration(); - coefficients = new double[] { calibration.pixelWidth, + pixelSpacing = new double[] { calibration.pixelWidth, calibration.pixelHeight, calibration.pixelDepth }; - calculateRealLength(); } } - + private boolean isCloseToBoundary(final Vertex v) { final Vector3d centroid = PointUtils.centroid(v.getPoints()); final int width = inputImage.getWidth(); diff --git a/Modern/wrapperPlugins/src/test/java/org/bonej/wrapperPlugins/IntertrabecularAngleWrapperTest.java b/Modern/wrapperPlugins/src/test/java/org/bonej/wrapperPlugins/IntertrabecularAngleWrapperTest.java index d956a0bb..5cf4c3df 100644 --- a/Modern/wrapperPlugins/src/test/java/org/bonej/wrapperPlugins/IntertrabecularAngleWrapperTest.java +++ b/Modern/wrapperPlugins/src/test/java/org/bonej/wrapperPlugins/IntertrabecularAngleWrapperTest.java @@ -39,19 +39,14 @@ import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.ArgumentMatchers.startsWith; -import static org.mockito.Mockito.after; import static org.mockito.Mockito.doNothing; -import static org.mockito.Mockito.mock; import static org.mockito.Mockito.timeout; import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; -import static org.scijava.ui.DialogPrompt.MessageType.WARNING_MESSAGE; import ij.IJ; import ij.ImagePlus; import ij.gui.NewImage; +import ij.measure.Calibration; import java.net.URL; import java.util.Iterator; @@ -67,7 +62,6 @@ import org.junit.experimental.categories.Category; import org.scijava.command.CommandModule; import org.scijava.table.DefaultColumn; -import org.scijava.ui.swing.sdi.SwingDialogPrompt; /** * Tests for {@link IntertrabecularAngleWrapper} @@ -80,14 +74,14 @@ public class IntertrabecularAngleWrapperTest extends AbstractWrapperTest { @Test - public void testAngleResults() throws Exception { + public void testAngleResultsUncalibratedPixels() throws Exception { // SETUP final Predicate nonEmpty = Objects::nonNull; final URL resource = getClass().getClassLoader().getResource( "test-skelly.zip"); assert resource != null; final ImagePlus skelly = IJ.openImage(resource.getFile()); - + // EXECUTE final CommandModule module = command().run( IntertrabecularAngleWrapper.class, true, "inputImage", skelly, @@ -115,7 +109,91 @@ public void testAngleResults() throws Exception { assertEquals(2, fiveColumn.stream().filter(nonEmpty) .filter(d -> d == Math.PI / 2).count()); } + + @Test + public void testAngleResultsIsotropicBigPixels() throws Exception { + // SETUP + final Predicate nonEmpty = Objects::nonNull; + final URL resource = getClass().getClassLoader().getResource( + "test-skelly.zip"); + assert resource != null; + final ImagePlus skelly = IJ.openImage(resource.getFile()); + Calibration cal = new Calibration(); + cal.pixelDepth = 2; + cal.pixelHeight = 2; + cal.pixelWidth = 2; + skelly.setCalibration(cal); + // EXECUTE + final CommandModule module = command().run( + IntertrabecularAngleWrapper.class, true, "inputImage", skelly, + "minimumValence", 3, "maximumValence", 50, "minimumTrabecularLength", 4, + "marginCutOff", 0, "useClusters", true, "iteratePruning", false).get(); + + // VERIFY + @SuppressWarnings("unchecked") + final List> table = + (List>) module.getOutput("resultsTable"); + assertNotNull(table); + assertEquals(2, table.size()); + final DefaultColumn threeColumn = table.get(0); + assertEquals("3", threeColumn.getHeader()); + assertEquals(10, threeColumn.size()); + assertEquals(3, threeColumn.stream().filter(nonEmpty).count()); + assertEquals(2, threeColumn.stream().filter(nonEmpty).distinct().count()); + final DefaultColumn fiveColumn = table.get(1); + assertEquals("5", fiveColumn.getHeader()); + assertEquals(10, fiveColumn.size()); + assertEquals(10, fiveColumn.stream().filter(nonEmpty).count()); + assertEquals(6, fiveColumn.stream().filter(nonEmpty).distinct().count()); + assertEquals(1, fiveColumn.stream().filter(nonEmpty) + .filter(d -> d == Math.PI).count()); + assertEquals(2, fiveColumn.stream().filter(nonEmpty) + .filter(d -> d == Math.PI / 2).count()); + } + + @Test + public void testAngleResultsIsotropicSmallPixels() throws Exception { + // SETUP + final Predicate nonEmpty = Objects::nonNull; + final URL resource = getClass().getClassLoader().getResource( + "test-skelly.zip"); + assert resource != null; + final ImagePlus skelly = IJ.openImage(resource.getFile()); + Calibration cal = new Calibration(); + cal.pixelDepth = 0.1; + cal.pixelHeight = 0.1; + cal.pixelWidth = 0.1; + skelly.setCalibration(cal); + + // EXECUTE + final CommandModule module = command().run( + IntertrabecularAngleWrapper.class, true, "inputImage", skelly, + "minimumValence", 3, "maximumValence", 50, "minimumTrabecularLength", 0.2, + "marginCutOff", 0, "useClusters", true, "iteratePruning", false).get(); + + // VERIFY + @SuppressWarnings("unchecked") + final List> table = + (List>) module.getOutput("resultsTable"); + assertNotNull(table); + assertEquals(2, table.size()); + final DefaultColumn threeColumn = table.get(0); + assertEquals("3", threeColumn.getHeader()); + assertEquals(10, threeColumn.size()); + assertEquals(3, threeColumn.stream().filter(nonEmpty).count()); + assertEquals(2, threeColumn.stream().filter(nonEmpty).distinct().count()); + final DefaultColumn fiveColumn = table.get(1); + assertEquals("5", fiveColumn.getHeader()); + assertEquals(10, fiveColumn.size()); + assertEquals(10, fiveColumn.stream().filter(nonEmpty).count()); + assertEquals(6, fiveColumn.stream().filter(nonEmpty).distinct().count()); + assertEquals(1, fiveColumn.stream().filter(nonEmpty) + .filter(d -> d == Math.PI).count()); + assertEquals(2, fiveColumn.stream().filter(nonEmpty) + .filter(d -> d == Math.PI / 2).count()); + } + @Test public void testAnisotropicImageShowsWarningDialog() { CommonWrapperTests.testAnisotropyWarning(imageJ(), diff --git a/Modern/wrapperPlugins/src/test/java/org/bonej/wrapperPlugins/headless/AbstractWrapperHeadlessTest.java b/Modern/wrapperPlugins/src/test/java/org/bonej/wrapperPlugins/headless/AbstractWrapperHeadlessTest.java new file mode 100644 index 00000000..9529e906 --- /dev/null +++ b/Modern/wrapperPlugins/src/test/java/org/bonej/wrapperPlugins/headless/AbstractWrapperHeadlessTest.java @@ -0,0 +1,83 @@ +/*- + * #%L + * High-level BoneJ2 commands. + * %% + * Copyright (C) 2015 - 2020 Michael Doube, BoneJ developers + * %% + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + * #L% + */ + +package org.bonej.wrapperPlugins.headless; + +import net.imagej.ImageJ; +import org.bonej.utilities.SharedTable; +import org.junit.After; +import org.junit.AfterClass; +import org.junit.Before; +import org.junit.BeforeClass; +import org.scijava.command.CommandService; + +/** + * An abstract base test class that handles basic setup and tear down + * for testing wrapper plugins in headless mode. + *

+ * NB Remember to call the methods in this class if you override/hide the methods! + *

+ * @author Richard Domander + * @author Michael Doube + */ +public abstract class AbstractWrapperHeadlessTest { + private static ImageJ imageJ; + private static CommandService commandService; + + protected static CommandService command() { + return commandService; + } + + protected static ImageJ imageJ() { + return imageJ; + } + + @BeforeClass + public static void basicOneTimeSetup() { + imageJ = new ImageJ(); + commandService = imageJ.command(); + } + + @Before + public void setup() { + imageJ.ui().setHeadless(true); + } + + @After + public void tearDown() { + SharedTable.reset(); + } + + @AfterClass + public static void basicOneTimeTearDown() { + imageJ.context().dispose(); + imageJ = null; + commandService = null; + } +} diff --git a/Modern/wrapperPlugins/src/test/java/org/bonej/wrapperPlugins/headless/IntertrabecularAnglesWrapperHeadlessTest.java b/Modern/wrapperPlugins/src/test/java/org/bonej/wrapperPlugins/headless/IntertrabecularAnglesWrapperHeadlessTest.java new file mode 100644 index 00000000..e021dd5a --- /dev/null +++ b/Modern/wrapperPlugins/src/test/java/org/bonej/wrapperPlugins/headless/IntertrabecularAnglesWrapperHeadlessTest.java @@ -0,0 +1,134 @@ +package org.bonej.wrapperPlugins.headless; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +import java.net.URL; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ExecutionException; +import java.util.function.Predicate; + +import net.imagej.table.DefaultResultsTable; + +import org.bonej.wrapperPlugins.IntertrabecularAngleWrapper; +import org.junit.Test; +import org.scijava.command.CommandModule; +import org.scijava.table.DefaultColumn; +import org.scijava.table.DefaultGenericTable; +import org.scijava.table.DoubleColumn; + +import ij.IJ; +import ij.ImagePlus; +import ij.measure.Calibration; + +/** + * Tests for BoneJ Wrapper Plugins that do not rely on a GUI. + * + * Its intention is to validate output when plugin wrappers are called + * from scripts and macros. + * + * @author Michael Doube + * + */ +public class IntertrabecularAnglesWrapperHeadlessTest extends AbstractWrapperHeadlessTest { + + @Test + public void testInterTrabecularAngles() throws InterruptedException, ExecutionException { + //SETUP + assertTrue(imageJ().ui().isHeadless()); + final Predicate nonEmpty = Objects::nonNull; + + final URL resource = getClass().getClassLoader().getResource( + "test-skelly.zip"); + assert resource != null; + final ImagePlus skelly = IJ.openImage(resource.getFile()); + Calibration cal = new Calibration(); + cal.pixelDepth = 0.1; + cal.pixelHeight = 0.1; + cal.pixelWidth = 0.1; + skelly.setCalibration(cal); + + //EXECUTE + final CommandModule module = command().run( + IntertrabecularAngleWrapper.class, false, "inputImage", skelly, + "minimumValence", 3, "maximumValence", 50, "minimumTrabecularLength", 0.2, + "marginCutOff", 0, "useClusters", true, "printCentroids", true, + "iteratePruning", false, "showSkeleton", true).get(); + + Map outputs = module.getOutputs(); + + //VERIFY + assertNotNull(outputs); + logOutputNameAndClass(module); + + //check the output image exists and is an ImagePlus + assertNotNull(module.getOutput("skeletonImage")); + assertTrue(outputs.get("skeletonImage") instanceof ImagePlus); + + //check that the angle table exists and is a DefaultGenericTable + assertNotNull(module.getOutput("resultsTable")); + assertTrue(module.getOutput("resultsTable") instanceof DefaultGenericTable); + + //Check the angle table contains the expected values. + final DefaultGenericTable table = (DefaultGenericTable) module.getOutput("resultsTable"); + assertEquals(2, table.size()); + @SuppressWarnings("unchecked") + final DefaultColumn threeColumn = (DefaultColumn) table.get(0); + assertEquals("3", threeColumn.getHeader()); + assertEquals(10, threeColumn.size()); + assertEquals(3, threeColumn.stream().filter(nonEmpty).count()); + assertEquals(2, threeColumn.stream().filter(nonEmpty).distinct().count()); + @SuppressWarnings("unchecked") + final DefaultColumn fiveColumn = (DefaultColumn) table.get(1); + assertEquals("5", fiveColumn.getHeader()); + assertEquals(10, fiveColumn.size()); + assertEquals(10, fiveColumn.stream().filter(nonEmpty).count()); + assertEquals(6, fiveColumn.stream().filter(nonEmpty).distinct().count()); + assertEquals(1, fiveColumn.stream().filter(nonEmpty) + .filter(d -> d == Math.PI).count()); + assertEquals(2, fiveColumn.stream().filter(nonEmpty) + .filter(d -> d == Math.PI / 2).count()); + assertEquals(2, fiveColumn.stream().filter(nonEmpty) + .filter(d -> d == 0.8621700546672264).count()); + assertEquals(2, fiveColumn.stream().filter(nonEmpty) + .filter(d -> d == 2.279422598922567).count()); + assertEquals(2, fiveColumn.stream().filter(nonEmpty) + .filter(d -> d == 2.4329663814621227).count()); + + //check that the centroid table exists and is a DefaulResultsTable + assertNotNull(module.getOutput("centroidTable")); + assertTrue(module.getOutput("centroidTable") instanceof DefaultResultsTable); + + //check that the centroid table contains the expected values + final DefaultResultsTable centroidTable = (DefaultResultsTable) module.getOutput("centroidTable"); + assertEquals(6, centroidTable.size()); + assertEquals(7, centroidTable.getRowCount()); + final DoubleColumn v1x = centroidTable.get(0); + assertEquals(2, v1x.stream().filter(d -> d == 2).count()); + assertEquals(1, v1x.stream().filter(d -> d == 9).count()); + assertEquals(2, v1x.stream().filter(d -> d == 17).count()); + assertEquals(2, v1x.stream().filter(d -> d == 24).count()); + } + + /** + * Print the names and object types in the output list to the console + * + * @param module + */ + private void logOutputNameAndClass(CommandModule module) { + + Map outputs = module.getOutputs(); + + System.out.println("Output list for " + module.getInfo().getClassName()); + + for (Map.Entry entry : outputs.entrySet()) { + if (Objects.isNull(entry)) + continue; + if (Objects.isNull(entry.getKey()) || Objects.isNull(entry.getValue())) + continue; + System.out.println(entry.getKey() + " " + entry.getValue().getClass().getName()); + } + } +}