Skip to content

Commit

Permalink
Improve headless calling of intertrabecular angles (#318)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
mdoube authored Jun 15, 2022
1 parent 6a7fb7e commit 459fab5
Show file tree
Hide file tree
Showing 4 changed files with 318 additions and 38 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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)
Expand All @@ -193,7 +191,7 @@ public void run() {
statusService.showProgress(1, PROGRESS_STEPS);
final ValuePair<Graph, double[]> pruningResult = GraphPruning
.pruneShortEdges(largestGraph, minimumTrabecularLength, iteratePruning,
useClusters, coefficients);
useClusters, pixelSpacing);
final Graph cleanGraph = pruningResult.a;
statusService.showStatus(
"Intertrabecular angles: valence sorting trabeculae");
Expand Down Expand Up @@ -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<Integer, DoubleStream> createRadianMap(
final Map<Integer, List<Vertex>> valenceMap)
{
Expand Down Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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}
Expand All @@ -80,14 +74,14 @@
public class IntertrabecularAngleWrapperTest extends AbstractWrapperTest {

@Test
public void testAngleResults() throws Exception {
public void testAngleResultsUncalibratedPixels() throws Exception {
// SETUP
final Predicate<Double> 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,
Expand Down Expand Up @@ -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<Double> 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<DefaultColumn<Double>> table =
(List<DefaultColumn<Double>>) module.getOutput("resultsTable");
assertNotNull(table);
assertEquals(2, table.size());
final DefaultColumn<Double> 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<Double> 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<Double> 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<DefaultColumn<Double>> table =
(List<DefaultColumn<Double>>) module.getOutput("resultsTable");
assertNotNull(table);
assertEquals(2, table.size());
final DefaultColumn<Double> 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<Double> 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(),
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
* <p>
* NB Remember to call the methods in this class if you override/hide the methods!
* </p>
* @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;
}
}
Loading

0 comments on commit 459fab5

Please sign in to comment.