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()); + } + } +}