diff --git a/docs/ops/doc/FijiPluginEquivalents.md b/docs/ops/doc/FijiPluginEquivalents.md new file mode 100644 index 000000000..35717e0bd --- /dev/null +++ b/docs/ops/doc/FijiPluginEquivalents.md @@ -0,0 +1,55 @@ +# Fiji Plugin Equivalents + +Many classic ImageJ and Fiji plugins have equivalent Ops. This page maps each plugin to its Ops; +the javadoc of each Op details its differences from the plugin. Ops marked *GPL* or *LGPL* live in the +`scijava-ops-image-gpl` or `scijava-ops-image-lgpl` components, since they derive from code under +those licenses; all other Ops are in `scijava-ops-image`. + +"Same results" means the Op reproduces the plugin's output exactly on our test images; most Ops also +generalize the plugin to n-dimensional images and calibrated pixel sizes. + +| Plugin | Ops | Notes | +|---|---|---| +| Auto Threshold | `threshold.` | Same results, including Huang and Huang2. | +| Auto Local Threshold | `threshold.local` | Same results. | +| 3D ImageJ Suite: Canny edge 3D | `filter.derichePartialDerivative`, `filter.dericheGradientMagnitude`, `segment.cannyDericheEdges` | Normalized, separable Deriche filter; magnitudes differ by a constant factor. | +| 3D ImageJ Suite: 3D Spot Segmentation | `segment.spotsFromSeeds` (*GPL*) | Same results; no watershed option. | +| Anisotropic Diffusion 2D | — | Not ported; see `filter.peronaMalik` and `filter.coherenceEnhancingDiffusion`. | +| Bleach Correction | `image.bleachCorrect`, `image.bleachFit`, `image.histogramMatch` | | +| BioVoxxel Toolbox | `filter.pseudoFlatField`, `filter.flatFieldCorrect`, `filter.convolvedBackgroundSubtraction`, `filter.gaussianWeightedMedian`, `labeling.edgeCorrectedCount`, `labeling.clarkEvans`; `threshold.limitedMean`, `labeling.countContained` (*GPL*) | | +| CircleSkinner | `transform.houghCircle`, `segment.detectHoughCircles` | Votes on full rings rather than midpoint circles. | +| Coloc 2 | `coloc.pearsons`, `coloc.manders`, `coloc.costesThreshold`, `coloc.spearman`, `coloc.pValue` | Costes thresholds count a pixel equal to the threshold as below it. | +| Colour Deconvolution | `color.deconvolveStains` | Within one grey level. | +| Curvatures, Shape Index Map | `filter.hessianEigenvalues`, `filter.shapeIndex` | | +| Directionality | `features.directionalityHistogram` (*GPL*) | Same results for the Fourier method; the gradient method's half-bin bias is fixed. | +| Gaussian Stack Focuser | `transform.focusStack`, `transform.focusStackIndex` | | +| Graph Cut | `segment.graphCut` | Independent implementation of Boykov–Kolmogorov max-flow. | +| Helmholtz Analysis | `filter.helmholtzWavelength`, `filter.helmholtzOrientation` | Spatial instead of FFT band-pass; results differ slightly. | +| Interactive H-Watershed | `segment.hWatershed` | Same results on integer images. | +| Iterative Deconvolve 3D | `deconvolve.wiener`, `deconvolve.tikhonov`, `deconvolve.landweber` | Independent implementations. | +| Kalman Stack Filter | `filter.kalman` | Independent implementation of the documented algorithm. | +| Kuwahara Filter | `filter.kuwahara` | Same results away from the image border. | +| Linear Kuwahara | `filter.kuwaharaLinear` | Independent implementation. | +| KymographBuilder | `image.kymograph` | Samples the first row and column, which the plugin skips. | +| Level Sets | `segment.levelSet`, `segment.fastMarching` | Independent implementations (Chan–Vese, geodesic active contours). | +| Local Thickness | `morphology.localThickness` | Same results to 1e-5. | +| MorphoLibJ: morphological reconstruction, extrema, h-transforms, kill borders, fill holes | `morphology.reconstructBy*`, `morphology.regional*`, `morphology.extended*`, `morphology.h*`, `morphology.impose*`, `morphology.killBorders`, `morphology.fillHoles` | Same results. | +| MorphoLibJ: attribute filtering | `morphology.areaOpening`, `morphology.boxDiagonalOpening`, `morphology.attributeOpening` and closings | Same results. MorphoLibJ's "volume opening" is `morphology.areaOpening` on a 3D image. | +| MorphoLibJ: morphological segmentation | `segment.extendedMinimaWatershed`, `segment.compactWatershed` | Same results with watershed lines. | +| MorphoLibJ: distance transform watershed | `segment.distanceTransformWatershed` | Uses the exact Euclidean distance transform instead of chamfer. | +| MorphoLibJ: label utilities and label morphology | `labeling.*` (e.g. `removeBorderLabels`, `filterBySize`, `dilate`, `distanceMap`, `adjacency`) | Exact Euclidean distances instead of chamfer. | +| MorphoLibJ: geodesic distance, region analysis, label overlap | `morphology.geodesicDistanceMap`, `labeling.geodesicDiameter`, `labeling.inscribedBall`, `labeling.equivalentEllipsoid`, `labeling.maximumFeret`, `labeling.overlap.*` | | +| MorphoLibJ: intrinsic volumes (Analyze Regions) | `features.intrinsicVolumes.*` (*LGPL*) | Same results, except that interface area honors the pixel calibration. | +| OrientationJ | `filter.structureTensor`, `filter.orientation`, `filter.coherency` | | +| PHANTAST | `segment.phantast`, `filter.localCoefficientOfVariation` | Same results; two halo-correction bugs fixed. | +| PIV analyser | `image.piv` | | +| RadiomicsJ texture features | `features.glrlm.*`, `features.glszm.*`, `features.gldzm.*`, `features.ngtdm.*`, `features.ngldm.*` | Match the IBSI reference values. | +| RATS | `threshold.rats` | Independent implementation; results differ in detail. | +| Retinex | `filter.retinex` | Each scale is applied to the original image. | +| Ridge Detection | `segment.detectLines` (*GPL*) | Same results. | +| ROF Denoise | `filter.tvDenoise` | Chambolle's algorithm; borders differ. | +| SIOX Segmentation | `segment.siox` | Same results. | +| Skeletonize3D, AnalyzeSkeleton | `morphology.skeletonize`, `morphology.tagSkeleton`, `topology.skeletonGraph`, `topology.skeletonStatistics` (*GPL*) | Same results; fixes pruning after the root vertex is removed. | +| Statistical Region Merging | `segment.statisticalRegionMerging` | Same results on full-range 8-bit images. | +| Stitching (pairwise phase correlation) | `filter.phaseCorrelate` | Translation only. | +| Tubeness, Frangi Vesselness | `filter.tubeness`, `filter.frangiVesselness`, `filter.sheetness` | `filter.tubeness` differentiates in calibrated units, unlike the plugin. | diff --git a/docs/ops/doc/index.rst b/docs/ops/doc/index.rst index a2dd7cca2..cd61ea56c 100644 --- a/docs/ops/doc/index.rst +++ b/docs/ops/doc/index.rst @@ -25,6 +25,7 @@ The combination of these libraries allows declarative image analysis workflows, SearchingForOps CallingOps IntrospectingOps + FijiPluginEquivalents .. toctree:: :maxdepth: 2 diff --git a/pom.xml b/pom.xml index c86ef69cc..43d9a9183 100644 --- a/pom.xml +++ b/pom.xml @@ -69,6 +69,8 @@ scijava-ops-engine scijava-ops-flim scijava-ops-image + scijava-ops-image-gpl + scijava-ops-image-lgpl scijava-ops-opencv scijava-ops-ext-parser scijava-ops-spi diff --git a/scijava-ops-engine/CHANGELOG.md b/scijava-ops-engine/CHANGELOG.md new file mode 100644 index 000000000..814e8dcfd --- /dev/null +++ b/scijava-ops-engine/CHANGELOG.md @@ -0,0 +1,11 @@ +# Changelog + +All notable changes to SciJava Ops Engine. + +## [Unreleased] + +### Fixed + +- Ops with multi-dimensional array parameters (e.g. `double[][]`) could not + be called with their optional parameters omitted, or with converted + arguments. diff --git a/scijava-ops-engine/src/main/java/org/scijava/ops/engine/matcher/convert/Conversions.java b/scijava-ops-engine/src/main/java/org/scijava/ops/engine/matcher/convert/Conversions.java index 9eeb24402..a011d0975 100644 --- a/scijava-ops-engine/src/main/java/org/scijava/ops/engine/matcher/convert/Conversions.java +++ b/scijava-ops-engine/src/main/java/org/scijava/ops/engine/matcher/convert/Conversions.java @@ -479,7 +479,7 @@ static String getClassName(Type t) { var className = clazz.getSimpleName(); if (className.chars().allMatch(Character::isJavaIdentifierPart)) return className; - if (clazz.isArray()) return clazz.getComponentType().getSimpleName() + + if (clazz.isArray()) return getClassName(clazz.getComponentType()) + "_Arr"; return className; } diff --git a/scijava-ops-engine/src/main/java/org/scijava/ops/engine/matcher/reduce/ReductionUtils.java b/scijava-ops-engine/src/main/java/org/scijava/ops/engine/matcher/reduce/ReductionUtils.java index 6566073c8..4eecf1f50 100644 --- a/scijava-ops-engine/src/main/java/org/scijava/ops/engine/matcher/reduce/ReductionUtils.java +++ b/scijava-ops-engine/src/main/java/org/scijava/ops/engine/matcher/reduce/ReductionUtils.java @@ -176,7 +176,7 @@ private static String getClassName(Class clazz) { var className = clazz.getSimpleName(); if (className.chars().allMatch(c -> Character.isJavaIdentifierPart(c))) return className; - if (clazz.isArray()) return clazz.getComponentType().getSimpleName() + + if (clazz.isArray()) return getClassName(clazz.getComponentType()) + "_Arr"; return className; } diff --git a/scijava-ops-engine/src/test/java/org/scijava/ops/engine/matcher/convert/ConversionTest.java b/scijava-ops-engine/src/test/java/org/scijava/ops/engine/matcher/convert/ConversionTest.java index b759bca8b..ae41e0ce4 100644 --- a/scijava-ops-engine/src/test/java/org/scijava/ops/engine/matcher/convert/ConversionTest.java +++ b/scijava-ops-engine/src/test/java/org/scijava/ops/engine/matcher/convert/ConversionTest.java @@ -259,4 +259,13 @@ public void testConversionDescription() { Assertions.assertTrue(info.toString().contains(TEST_DESC)); } + /** Generated names of nested array types must be valid identifiers. */ + @Test + public void testClassNameOfNestedArray() { + assertEquals("double", Conversions.getClassName(double.class)); + assertEquals("double_Arr", Conversions.getClassName(double[].class)); + assertEquals("double_Arr_Arr", Conversions.getClassName(double[][].class)); + assertEquals("Integer_Arr_Arr_Arr", Conversions.getClassName( + Integer[][][].class)); + } } diff --git a/scijava-ops-engine/src/test/java/org/scijava/ops/engine/reduce/NestedArrayParameterTest.java b/scijava-ops-engine/src/test/java/org/scijava/ops/engine/reduce/NestedArrayParameterTest.java new file mode 100644 index 000000000..ac980dff6 --- /dev/null +++ b/scijava-ops-engine/src/test/java/org/scijava/ops/engine/reduce/NestedArrayParameterTest.java @@ -0,0 +1,129 @@ +/*- + * #%L + * Java implementation of the SciJava Ops matching engine. + * %% + * Copyright (C) 2016 - 2025 SciJava 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.scijava.ops.engine.reduce; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.function.BiFunction; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.scijava.ops.engine.AbstractTestEnvironment; +import org.scijava.ops.engine.conversionLoss.impl.IdentityLossReporter; +import org.scijava.ops.engine.conversionLoss.impl.PrimitiveLossReporters; +import org.scijava.ops.engine.copy.CopyOpCollection; +import org.scijava.ops.engine.matcher.convert.IdentityCollection; +import org.scijava.ops.engine.matcher.convert.PrimitiveConverters; +import org.scijava.ops.engine.matcher.convert.UtilityConverters; +import org.scijava.ops.engine.matcher.impl.LossReporterWrapper; +import org.scijava.ops.spi.Nullable; +import org.scijava.ops.spi.OpCollection; +import org.scijava.ops.spi.OpMethod; + +/** + * Tests ops with a multi-dimensional array parameter, whose reduced and + * converted wrappers need a generated class name derived from the parameter + * types ({@code double[][]} must give a valid identifier such as + * {@code double_Arr_Arr}). + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +public class NestedArrayParameterTest extends AbstractTestEnvironment + implements OpCollection +{ + + @BeforeAll + public static void addNeededOps() { + ops.register( // + new CopyOpCollection<>(), // + new IdentityCollection<>(), // + new IdentityLossReporter<>(), // + new LossReporterWrapper<>(), // + new PrimitiveConverters<>(), // + new PrimitiveLossReporters(), // + new UtilityConverters(), // + new NestedArrayParameterTest() // + ); + } + + @OpMethod(names = "test.nestedArraySum", type = BiFunction.class) + public static Double nestedArraySum(final double[][] values, + @Nullable final Double scale) + { + double sum = 0; + for (final double[] row : values) + for (final double v : row) + sum += v; + return sum * (scale == null ? 1 : scale); + } + + @OpMethod(names = "test.nestedArrayCount", type = BiFunction.class) + public static Double nestedArrayCount(final double[][][] values, + @Nullable final Double scale) + { + long count = 0; + for (final double[][] plane : values) + for (final double[] row : plane) + count += row.length; + return count * (scale == null ? 1 : scale); + } + + private static final double[][] VALUES = { { 1, 2 }, { 3, 4 } }; + + @Test + public void testAllArguments() { + final Double sum = ops.op("test.nestedArraySum").input(VALUES, 2.0) + .outType(Double.class).apply(); + assertEquals(20, sum, 0); + } + + /** Omitting the optional parameter goes through a reduced wrapper. */ + @Test + public void testOptionalOmitted() { + final Double sum = ops.op("test.nestedArraySum").input(VALUES).outType( + Double.class).apply(); + assertEquals(10, sum, 0); + final Double count = ops.op("test.nestedArrayCount").input( + new double[2][3][4]).outType(Double.class).apply(); + assertEquals(24, count, 0); + } + + /** An Integer scale goes through a converted wrapper. */ + @Test + public void testConverted() { + final Double sum = ops.op("test.nestedArraySum").input(VALUES, 3) + .outType(Double.class).apply(); + assertEquals(30, sum, 0); + final Integer rounded = ops.op("test.nestedArraySum").input(VALUES) + .outType(Integer.class).apply(); + assertEquals(10, rounded); + } +} diff --git a/scijava-ops-image-gpl/LICENSE.txt b/scijava-ops-image-gpl/LICENSE.txt new file mode 100644 index 000000000..f288702d2 --- /dev/null +++ b/scijava-ops-image-gpl/LICENSE.txt @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program 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. + + This program 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 this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/scijava-ops-image-gpl/README.md b/scijava-ops-image-gpl/README.md new file mode 100644 index 000000000..5ed40c1df --- /dev/null +++ b/scijava-ops-image-gpl/README.md @@ -0,0 +1,24 @@ +# SciJava Ops Image GPL + +Image processing ops for [SciJava Ops](https://github.com/scijava/scijava) +translated from GPL-licensed ImageJ and Fiji plugins. + +[SciJava Ops Image](../scijava-ops-image) is BSD-2 licensed, so algorithms +that are only available as GPL code, and that cannot practically be +reimplemented from their published descriptions, live here instead. Each op +credits the original authors, project and license in its javadoc. + +## Contents + +| Ops | Derived from | +|---|---| +| `morphology.skeletonize`, `morphology.tagSkeleton`, `topology.skeletonGraph`, `topology.skeletonStatistics` | Fiji's Skeletonize3D and AnalyzeSkeleton | +| `features.directionalityHistogram` | Fiji's Directionality | +| `segment.detectLines`, `segment.estimateLineParameters` | Ridge Detection (Steger's curvilinear structure detector) | +| `segment.spotsFromSeeds` | the 3D ImageJ Suite (mcib3d), Segment3DSpots | +| `threshold.limitedMean`, `labeling.countContained`, `labeling.filterByContainedCount` | BioVoxxel Toolbox (Limited Mean, Speckle Inspector) | + +## License + +This component is licensed under the GNU General Public License v3 or later; +see [LICENSE.txt](LICENSE.txt). diff --git a/scijava-ops-image-gpl/pom.xml b/scijava-ops-image-gpl/pom.xml new file mode 100644 index 000000000..95729f311 --- /dev/null +++ b/scijava-ops-image-gpl/pom.xml @@ -0,0 +1,308 @@ + + + 4.0.0 + + + org.scijava + pom-scijava + 44.0.0 + + + + scijava-ops-image-gpl + 1.0.0-SNAPSHOT + + SciJava Ops Image GPL + GPL-licensed image processing operations for SciJava Ops, translated from copyleft ImageJ/Fiji plugins. + https://github.com/scijava/scijava + 2026 + + SciJava + https://scijava.org/ + + + + GNU General Public License v3+ + https://www.gnu.org/licenses/gpl-3.0.html + repo + + + + + + ctrueden + Curtis Rueden + + founder + debugger + reviewer + maintainer + + + + gselzer + Gabriel Selzer + + founder + debugger + reviewer + support + + + + + + + None + + + + + + Image.sc Forum + https://forum.image.sc/tag/scijava + + + + + scm:git:https://github.com/scijava/scijava + scm:git:git@github.com:scijava/scijava + HEAD + https://github.com/scijava/scijava + + + GitHub Issues + https://github.com/scijava/scijava/issues + + + GitHub Actions + https://github.com/scijava/scijava/actions + + + + org.scijava.ops.image.gpl + + gpl_v3 + SciJava developers. + + + 1.0.1-SNAPSHOT + 1.0.1-SNAPSHOT + 1.0.1-SNAPSHOT + 1.0.1-SNAPSHOT + 2.0.0-SNAPSHOT + 1.0.1-SNAPSHOT + 1.0.1-SNAPSHOT + 1.0.1-SNAPSHOT + 1.0.1-SNAPSHOT + + true + + + + + + org.scijava + scijava-function + ${scijava-function.version} + + + org.scijava + scijava-ops-api + ${scijava-ops-api.version} + + + org.scijava + scijava-ops-image + ${scijava-ops-image.version} + + + org.scijava + scijava-ops-spi + ${scijava-ops-spi.version} + + + org.scijava + scijava-types + ${scijava-types.version} + + + + + net.imglib2 + imglib2 + + + net.imglib2 + imglib2-algorithm + + + net.imglib2 + imglib2-roi + + + + + org.junit.jupiter + junit-jupiter-api + test + + + org.scijava + scijava-ops-engine + ${scijava-ops-engine.version} + test + + + + + + scijava.public + https://maven.scijava.org/content/groups/public + + + + + + + maven-compiler-plugin + + + + org.scijava + scijava-ops-indexer + ${scijava-ops-indexer.version} + + + true + + -Ascijava.ops.parse="${scijava.ops.parse}" + -Ascijava.ops.opVersion="${project.version}" + + + + + maven-javadoc-plugin + + + https://javadoc.scijava.org/Java11/ + https://javadoc.scijava.org/ImgLib2/ + https://javadoc.scijava.org/SCIFIO/ + https://javadoc.scijava.org/SciJava/ + + + + org.scijava + scijava-taglets + ${scijava-taglets.version} + + + + + implNote + a + Implementation Note: + + + + + + maven-enforcer-plugin + + + enforce-package-rules + + enforce + + test + + + + + + + + + + + maven-antrun-plugin + + + union-op-yaml + process-test-classes + + run + + + + + + + + + + + + + + + + only-eclipse-scijava + + + m2e.version + + + + + + + + org.eclipse.m2e + lifecycle-mapping + + 1.0.0 + + + + + + + maven-antrun-plugin + + run + + + + + true + + true + + + + + + + + + + + + + diff --git a/scijava-ops-image-gpl/src/main/java/org/scijava/ops/image/gpl/biovoxxel/ContainedCount.java b/scijava-ops-image-gpl/src/main/java/org/scijava/ops/image/gpl/biovoxxel/ContainedCount.java new file mode 100644 index 000000000..5167909e2 --- /dev/null +++ b/scijava-ops-image-gpl/src/main/java/org/scijava/ops/image/gpl/biovoxxel/ContainedCount.java @@ -0,0 +1,267 @@ +/*- + * #%L + * GPL-licensed image processing operations for SciJava Ops, translated from copyleft ImageJ/Fiji plugins. + * %% + * Copyright (C) 2026 SciJava developers. + * %% + * This program 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. + * + * This program 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 this program. If not, see + * . + * #L% + */ + +package org.scijava.ops.image.gpl.biovoxxel; + +import java.util.ArrayDeque; +import java.util.LinkedHashMap; +import java.util.Map; + +import net.imglib2.Cursor; +import net.imglib2.RandomAccess; +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.roi.labeling.ImgLabeling; +import net.imglib2.roi.labeling.LabelRegion; +import net.imglib2.roi.labeling.LabelRegions; +import net.imglib2.type.BooleanType; +import net.imglib2.type.numeric.IntegerType; +import net.imglib2.util.Intervals; + +import org.scijava.ops.image.labeling.utils.LabelFiltering; +import org.scijava.ops.spi.Nullable; + +/** + * Ops counting the secondary objects (such as speckles, spots or nuclei) + * contained in each primary object (such as cells), and selecting the primary + * objects by that count. + *

+ * The secondary objects of a primary object are the connected components of + * the secondary mask restricted to the primary object, with full connectivity + * (8-connected in 2D, 26-connected in 3D). A secondary object crossing the + * border of a primary object is thus clipped to it, and counted for each + * primary object it overlaps, with its clipped size. Each primary label is + * handled on its own, so labelings with overlapping labels are supported. + *

+ *

+ * This is a translation of the {@code Speckle_Inspector} plugin of the + * BioVoxxel + * Toolbox by Jan Brocher, generalized to n-D. The toolbox is licensed + * under the BSD 3-Clause license, but the {@code Speckle_Inspector.java} file + * also carries a GPL-3 (or later) header, so this port lives in the GPL + * module. The plugin + * filters the primary objects by size and circularity and excludes edge + * objects; with these ops, build and filter the primary labeling beforehand + * (e.g. with {@code labeling.cca}, {@code labeling.filterBySize}, + * {@code labeling.filterByValue} and {@code labeling.removeBorderLabels}). + * The plugin delimits each primary object by its outer outline, so secondary + * objects in its holes are counted too; to do the same, fill the holes of the + * primary labeling first ({@code labeling.fillHoles}). The circularity limits + * of the secondary objects are not supported. + *

+ *

+ * The BSD-3 license notice of the BioVoxxel Toolbox, which the original file + * also carries, follows. + *

+ * + *
+ * Copyright (c) 2015, BioVoxxel / Jan Brocher
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ *
+ * * Redistributions of source code must retain the above copyright notice, this
+ *   list of conditions and the following disclaimer.
+ *
+ * * 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.
+ *
+ * * Neither the name of BioVoxxel nor the names of its
+ *   contributors may be used to endorse or promote products derived from
+ *   this software without specific prior written permission.
+ *
+ * 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 HOLDER 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.
+ * 
+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @author Jan Brocher + */ +public final class ContainedCount { + + private ContainedCount() { + // Prevent instantiation of static utility class. + } + + /** + * Counts the secondary objects contained in each primary object. + * + * @param primary the primary objects + * @param secondary the secondary objects, as a mask with the same interval + * as the primary labeling + * @param minSize the minimum pixel count (inclusive) of a counted secondary + * object, after clipping to the primary object (default: no + * minimum) + * @param maxSize the maximum pixel count (inclusive) of a counted secondary + * object, after clipping to the primary object (default: no + * maximum) + * @return the number of secondary objects in each primary label that has + * pixels + * @param label type + * @param index type of the labeling + * @param type of the secondary mask + * @implNote op names='labeling.countContained', type=Function + */ + public static , B extends BooleanType> + Map countContained(final ImgLabeling primary, + final RandomAccessibleInterval secondary, @Nullable final Long minSize, + @Nullable final Long maxSize) + { + if (!Intervals.equals(primary, secondary)) { + throw new IllegalArgumentException( + "The primary labeling and the secondary mask must have the same interval"); + } + final long lo = minSize == null ? 0 : minSize; + final long hi = maxSize == null ? Long.MAX_VALUE : maxSize; + final int n = primary.numDimensions(); + final LabelRegions regions = new LabelRegions<>(primary); + final RandomAccess sa = secondary.randomAccess(); + final Map counts = new LinkedHashMap<>(); + for (final LabelRegion region : regions) { + // Mark the secondary pixels of the region in a local grid over its + // bounding box. + final long[] min = region.minAsLongArray(); + final int[] dims = new int[n]; + final int[] strides = new int[n]; + long total = 1; + for (int d = 0; d < n; d++) { + dims[d] = (int) region.dimension(d); + strides[d] = (int) total; + total *= dims[d]; + } + if (total > Integer.MAX_VALUE) { + throw new IllegalArgumentException("Label region too large: " + + region.getLabel()); + } + final boolean[] mask = new boolean[(int) total]; + final Cursor c = region.inside().localizingCursor(); + while (c.hasNext()) { + c.fwd(); + sa.setPosition(c); + if (!sa.get().get()) continue; + int index = 0; + for (int d = 0; d < n; d++) + index += (int) (c.getLongPosition(d) - min[d]) * strides[d]; + mask[index] = true; + } + counts.put(region.getLabel(), countComponents(mask, dims, strides, lo, + hi)); + } + return counts; + } + + /** + * Keeps the primary objects whose number of contained secondary objects + * lies within a range, like the objects marked in magenta by the plugin. + * + * @param primary the primary objects + * @param secondary the secondary objects, as a mask with the same interval + * as the primary labeling + * @param minCount the minimum number (inclusive) of secondary objects of a + * kept primary object (default 0) + * @param maxCount the maximum number (inclusive) of secondary objects of a + * kept primary object (default: no maximum) + * @param minSize the minimum pixel count (inclusive) of a counted secondary + * object, after clipping to the primary object (default: no + * minimum) + * @param maxSize the maximum pixel count (inclusive) of a counted secondary + * object, after clipping to the primary object (default: no + * maximum) + * @return a new labeling with only the primary labels in the count range + * @param label type + * @param index type of the labeling + * @param type of the secondary mask + * @implNote op names='labeling.filterByContainedCount', type=Function + */ + public static , B extends BooleanType> + ImgLabeling filterByContainedCount(final ImgLabeling primary, + final RandomAccessibleInterval secondary, + @Nullable final Integer minCount, @Nullable final Integer maxCount, + @Nullable final Long minSize, @Nullable final Long maxSize) + { + final Map counts = countContained(primary, secondary, minSize, + maxSize); + return LabelFiltering.filterByValue(primary, counts, minCount == null + ? null : minCount.doubleValue(), maxCount == null ? null : maxCount + .doubleValue()); + } + + /** + * Counts the fully connected components of a mask whose size is in range. + * The mask is cleared. + */ + private static int countComponents(final boolean[] mask, final int[] dims, + final int[] strides, final long minSize, final long maxSize) + { + final int n = dims.length; + final int[] pos = new int[n]; + final int[] q = new int[n]; + final int nOffsets = (int) Math.pow(3, n); + final ArrayDeque queue = new ArrayDeque<>(); + int count = 0; + for (int seed = 0; seed < mask.length; seed++) { + if (!mask[seed]) continue; + mask[seed] = false; + queue.add(seed); + long size = 0; + while (!queue.isEmpty()) { + final int index = queue.poll(); + size++; + int rest = index; + for (int d = n - 1; d >= 0; d--) { + pos[d] = rest / strides[d]; + rest %= strides[d]; + } + for (int o = 0; o < nOffsets; o++) { + int code = o; + boolean inside = true; + int neighbor = 0; + for (int d = 0; d < n; d++) { + q[d] = pos[d] + code % 3 - 1; + code /= 3; + if (q[d] < 0 || q[d] >= dims[d]) { + inside = false; + break; + } + neighbor += q[d] * strides[d]; + } + if (inside && mask[neighbor]) { + mask[neighbor] = false; + queue.add(neighbor); + } + } + } + if (size >= minSize && size <= maxSize) count++; + } + return count; + } +} diff --git a/scijava-ops-image-gpl/src/main/java/org/scijava/ops/image/gpl/biovoxxel/LimitedMeanThreshold.java b/scijava-ops-image-gpl/src/main/java/org/scijava/ops/image/gpl/biovoxxel/LimitedMeanThreshold.java new file mode 100644 index 000000000..9de1585b5 --- /dev/null +++ b/scijava-ops-image-gpl/src/main/java/org/scijava/ops/image/gpl/biovoxxel/LimitedMeanThreshold.java @@ -0,0 +1,277 @@ +/*- + * #%L + * GPL-licensed image processing operations for SciJava Ops, translated from copyleft ImageJ/Fiji plugins. + * %% + * Copyright (C) 2026 SciJava developers. + * %% + * This program 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. + * + * This program 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 this program. If not, see + * . + * #L% + */ + +package org.scijava.ops.image.gpl.biovoxxel; + +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.loops.LoopBuilder; +import net.imglib2.type.logic.BitType; +import net.imglib2.type.numeric.RealType; +import net.imglib2.type.numeric.integer.UnsignedByteType; +import net.imglib2.util.Intervals; +import net.imglib2.view.Views; + +import org.scijava.function.Computers; +import org.scijava.ops.spi.Nullable; + +/** + * Binarizes an image with the histogram-limiting methods MoLiM (mode limited + * mean) and DiLiM (differential limited mean): the histogram is first limited + * by removing the bins up to its mode (MoLiM), or, with differential + * limitation, up to its median when the median is closer to the mode than to + * the mean (DiLiM); the threshold is then the mean of the remaining + * histogram. + *

+ * The mode is taken as the background: when it is above the mean (bright + * background), the histogram is reversed first, so the foreground is then the + * pixels darker than the threshold. With {@code force}, the opposite partition + * is taken as the foreground (the smaller partition in the plugin dialog); + * this option is ignored when the mode is in the first or last bin, as in the + * plugin. When both the mode and the median are in the first bin, the + * differential method removes the first bin, then all bins up to the mean of + * the rest. + *

+ *

+ * Images of {@link UnsignedByteType} use one bin per gray level, like the + * plugin (which only handles 8-bit images); other types use 256 bins between + * the minimum and maximum of the image. The median is computed as in the + * plugin: one past the last bin whose cumulative count is at most half the + * pixel count, except that it is 0 when that bin is the first one. NaN + * pixels are left out of the histogram and are always background. + *

+ *

+ * This is a translation of the {@code Limited_Mean} plugin of the + * BioVoxxel + * Toolbox by Jan Brocher. The toolbox is licensed under the BSD 3-Clause + * license, but the {@code Limited_Mean.java} file also carries a GPL-3 (or + * later) header, so this port lives in the GPL module. + *

+ *

+ * Reference: J. Brocher, "Qualitative and Quantitative Evaluation of Two New + * Histogram Limiting Binarization Algorithms", International Journal of Image + * Processing 8(2):30-48, 2014. + *

+ *

+ * The BSD-3 license notice of the BioVoxxel Toolbox, which the original file + * also carries, follows. + *

+ * + *
+ * Copyright (c) 2015, BioVoxxel / Jan Brocher
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ *
+ * * Redistributions of source code must retain the above copyright notice, this
+ *   list of conditions and the following disclaimer.
+ *
+ * * 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.
+ *
+ * * Neither the name of BioVoxxel nor the names of its
+ *   contributors may be used to endorse or promote products derived from
+ *   this software without specific prior written permission.
+ *
+ * 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 HOLDER 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.
+ * 
+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @author Jan Brocher + * @param type of the input image + * @implNote op names='threshold.limitedMean' + */ +public class LimitedMeanThreshold> implements + Computers.Arity3, Boolean, Boolean, RandomAccessibleInterval> +{ + + private static final int BINS = 256; + + /** + * Binarizes the image. + * + * @param input the image to binarize + * @param differential whether to use the differential limitation (DiLiM) + * instead of the mode limitation (MoLiM); default true, as in the + * plugin dialog + * @param force whether to take the other partition as the foreground + * (default false) + * @param output the binary result, true for the foreground + */ + @Override + public void compute( // + final RandomAccessibleInterval input, // + @Nullable final Boolean differential, // + @Nullable final Boolean force, // + final RandomAccessibleInterval output // + ) { + if (!Intervals.equalDimensions(input, output)) { + throw new IllegalArgumentException( + "Input and output must have the same dimensions"); + } + // Map each value to its bin. + final double min, scale; + if (input.getType() instanceof UnsignedByteType) { + min = 0; + scale = 1; + } + else { + double lo = Double.POSITIVE_INFINITY, hi = Double.NEGATIVE_INFINITY; + for (final T t : Views.iterable(input)) { + final double v = t.getRealDouble(); + if (v < lo) lo = v; + if (v > hi) hi = v; + } + min = lo; + scale = hi > lo ? BINS / (hi - lo) : 0; + } + final long[] hist = new long[BINS]; + for (final T t : Views.iterable(input)) { + final double v = t.getRealDouble(); + if (!Double.isNaN(v)) hist[bin(v, min, scale)]++; + } + + final int[] result = threshold(hist, differential == null || differential, + Boolean.TRUE.equals(force)); + final int t = result[0]; + final boolean inverted = result[1] != 0; + LoopBuilder.setImages(input, output).forEachPixel((in, out) -> { + final double v = in.getRealDouble(); + if (Double.isNaN(v)) { + out.set(false); + return; + } + final int b = bin(v, min, scale); + out.set((inverted ? BINS - 1 - b : b) > t); + }); + } + + private static int bin(final double v, final double min, + final double scale) + { + final int b = (int) ((v - min) * scale); + return Math.max(0, Math.min(BINS - 1, b)); + } + + /** + * Computes the limited mean threshold of a histogram. + * + * @return the threshold bin, and 1 if the threshold applies to the reversed + * histogram (0 otherwise); bins above the threshold are foreground + */ + static int[] threshold(final long[] histogram, final boolean differential, + final boolean force) + { + final int n = histogram.length; + long[] hist = histogram.clone(); + double modal = mode(hist); + double mean = mean(hist); + // Note: The plugin does not offer the force option when the mode is in + // the first or last bin. + final boolean forced = force && modal != 0 && modal != n - 1; + final boolean inverted = (modal > mean) != forced; + if (inverted) { + final long[] reversed = new long[n]; + for (int i = 0; i < n; i++) + reversed[i] = hist[n - 1 - i]; + hist = reversed; + modal = mode(hist); + mean = mean(hist); + } + final double median = median(hist); + + if (modal == 0 && median == 0 && differential) { + hist[0] = 0; + final double intermediateMean = mean(hist); + for (int b = 0; b <= intermediateMean && b < n; b++) + hist[b] = 0; + } + else if (modal == 0 && median > 0 && differential) { + clear(hist, median); + } + else if (Math.abs(median - modal) < Math.abs(mean - median) && + differential) + { + clear(hist, median); + } + else { + clear(hist, modal); + } + final double newMean = mean(hist); + // Note: A NaN mean (empty histogram) gives 0, as the int cast does. + return new int[] { (int) newMean, inverted ? 1 : 0 }; + } + + /** Clears the bins up to {@code limit}, inclusive. */ + private static void clear(final long[] hist, final double limit) { + for (int b = 0; b <= limit && b < hist.length; b++) + hist[b] = 0; + } + + /** Returns the first bin with the highest count. */ + private static int mode(final long[] hist) { + int mode = 0; + long max = 0; + for (int i = 0; i < hist.length; i++) { + if (hist[i] > max) { + max = hist[i]; + mode = i; + } + } + return mode; + } + + private static double mean(final long[] hist) { + double sum = 0, count = 0; + for (int i = 0; i < hist.length; i++) { + sum += (double) hist[i] * i; + count += hist[i]; + } + return sum / count; + } + + /** The median as computed by the plugin; see the class description. */ + private static int median(final long[] hist) { + long total = 0; + for (final long h : hist) + total += h; + final long half = total / 2; + long cumulative = 0; + int last = 0; + for (int i = 0; i < hist.length; i++) { + cumulative += hist[i]; + if (cumulative <= half) last = i; + } + return last == 0 ? 0 : last + 1; + } +} diff --git a/scijava-ops-image-gpl/src/main/java/org/scijava/ops/image/gpl/biovoxxel/package-info.java b/scijava-ops-image-gpl/src/main/java/org/scijava/ops/image/gpl/biovoxxel/package-info.java new file mode 100644 index 000000000..21c1c3cb2 --- /dev/null +++ b/scijava-ops-image-gpl/src/main/java/org/scijava/ops/image/gpl/biovoxxel/package-info.java @@ -0,0 +1,28 @@ +/*- + * #%L + * GPL-licensed image processing operations for SciJava Ops, translated from copyleft ImageJ/Fiji plugins. + * %% + * Copyright (C) 2026 SciJava developers. + * %% + * This program 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. + * + * This program 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 this program. If not, see + * . + * #L% + */ + +/** + * Ops translated from plugins of the BioVoxxel Toolbox by Jan Brocher whose + * source files carry a GPL-3 (or later) header in addition to the BSD 3-Clause + * license of the toolbox. + */ +package org.scijava.ops.image.gpl.biovoxxel; diff --git a/scijava-ops-image-gpl/src/main/java/org/scijava/ops/image/gpl/directionality/DefaultDirectionalityHistogram.java b/scijava-ops-image-gpl/src/main/java/org/scijava/ops/image/gpl/directionality/DefaultDirectionalityHistogram.java new file mode 100644 index 000000000..0da786766 --- /dev/null +++ b/scijava-ops-image-gpl/src/main/java/org/scijava/ops/image/gpl/directionality/DefaultDirectionalityHistogram.java @@ -0,0 +1,465 @@ +/*- + * #%L + * GPL-licensed image processing operations for SciJava Ops, translated from copyleft ImageJ/Fiji plugins. + * %% + * Copyright (C) 2026 SciJava developers. + * %% + * This program 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. + * + * This program 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 this program. If not, see + * . + * #L% + */ + +package org.scijava.ops.image.gpl.directionality; + +import java.util.Arrays; + +import net.imglib2.Cursor; +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.type.numeric.RealType; +import net.imglib2.view.Views; + +import org.scijava.function.Functions; +import org.scijava.ops.spi.Nullable; + +/** + * Measures the preferred orientation of the structures of a 2D image: a + * histogram of the amount of structures in each direction, and a Gaussian fit + * of its highest peak giving the dominant direction, its dispersion, the + * amount of structures in that direction and the goodness of the fit. Images + * with isotropic content give a flat histogram, while images with a preferred + * orientation give a peak at that orientation. + *

+ * Two methods are available. With + * {@link DirectionalityMethod#FOURIER_COMPONENTS} (the default), the image is + * cut into square blocks, each windowed by a Blackman window and padded to a + * power of two; the power spectrum of each block is then measured in angular + * wedges using the spatial filters of Liu (1991). Structures with a preferred + * orientation give a periodic pattern at +90 degrees in the Fourier spectrum. + * With {@link DirectionalityMethod#LOCAL_GRADIENT_ORIENTATION}, the gradient + * is computed with 5x5 Sobel kernels, and each pixel adds its squared gradient + * magnitude to the bin of its orientation (rotated by 90 degrees, so that it + * is the orientation of the structure and not of its normal). + *

+ *

+ * The highest peak of the histogram is fitted by + * {@code a + (b - a) exp(-(x - c)^2 / (2 d^2))}, as the plugin does with the + * ImageJ {@code CurveFitter}; the fit does not wrap around the ends of the + * histogram, so a peak near the ends of the analyzed range is fitted poorly. + *

+ *

+ * This is a translation of the Fiji + * Directionality plugin + * by Jean-Yves Tinevez (GPL v3+), without its display parts and without the + * orientation map. Deviations from the plugin: the analysis is of one 2D + * plane; the fit is computed with a Nelder-Mead search instead of the ImageJ + * {@code Minimizer}, which reaches the same least-squares optimum; and the + * amount takes the 180 degree periodicity of the histogram into account, as + * documented by the plugin (whose implementation compares radians with + * degrees, so it never wraps). With + * {@link DirectionalityMethod#LOCAL_GRADIENT_ORIENTATION}, each pixel goes to + * the bin whose center is nearest to its orientation; the plugin truncates + * instead, so its bins collect the orientations in + * {@code [center, center + width)} and its histogram and fitted direction are + * offset by half a bin (about 1 degree with the default 90 bins). The first + * and last bins thus cover only half a bin width each. Quirks kept for parity + * with the plugin: the bin centers include both ends of the analyzed range, + * and the Blackman window has a constant third term (the plugin omits the + * position from its cosine). + *

+ *

+ * Note: this is unrelated to the Tamura texture feature + * {@code features.tamura.directionality}, which summarizes directionality as + * a single number. + *

+ *

+ * Reference: Z.-Q. Liu, "Scale space approach to directional analysis of + * images", Applied Optics 30(11):1369-1373, 1991. + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @author Jean-Yves Tinevez + * @param type of the input image + * @implNote op names='features.directionalityHistogram' + */ +public class DefaultDirectionalityHistogram> implements + Functions.Arity5, DirectionalityMethod, Integer, Double, Double, Directionality> +{ + + /** Spectrum pixels closer than this to the zero frequency are ignored. */ + private static final double FREQ_THRESHOLD = 5.0; + + /** Number of standard deviations around the center summed for the amount. */ + private static final double SIGMA_NUMBER = 2; + + /** + * Measures the directionality of a 2D image. + * + * @param input the 2D image to analyze + * @param method the analysis method (defaults to + * {@link DirectionalityMethod#FOURIER_COMPONENTS}) + * @param binCount the number of bins of the histogram (default 90, at least + * 2) + * @param binStart the direction of the first bin, in degrees (default -90) + * @param binEnd the direction of the last bin, in degrees (defaults to 180 + * degrees after the start, which is also its maximum) + * @return the directionality histogram and its Gaussian fit + */ + @Override + public Directionality apply( // + final RandomAccessibleInterval input, // + @Nullable final DirectionalityMethod method, // + @Nullable final Integer binCount, // + @Nullable final Double binStart, // + @Nullable final Double binEnd // + ) { + if (input.numDimensions() != 2) { + throw new IllegalArgumentException( + "Directionality requires a 2D image, not " + input.numDimensions() + + "D"); + } + final int nBins = binCount == null ? 90 : binCount; + if (nBins < 2) { + throw new IllegalArgumentException("At least 2 bins are needed: " + + nBins); + } + final double start = binStart == null ? -90 : binStart; + final double end = binEnd == null ? start + 180 : Math.min(binEnd, start + + 180); + if (!(end > start)) { + throw new IllegalArgumentException("The bin end (" + end + + ") must be greater than the bin start (" + start + ")"); + } + final int width = (int) input.dimension(0); + final int height = (int) input.dimension(1); + final float[] pixels = new float[width * height]; + final Cursor c = Views.flatIterable(input).cursor(); + for (int i = 0; i < pixels.length; i++) + pixels[i] = c.next().getRealFloat(); + + // The bin centers, in radians. + final double[] bins = new double[nBins]; + for (int i = 0; i < nBins; i++) + bins[i] = (start + i * (end - start) / (nBins - 1)) / 180. * Math.PI; + + final double[] dir = method == DirectionalityMethod.LOCAL_GRADIENT_ORIENTATION + ? gradientHistogram(pixels, width, height, bins, start, end) + : fourierHistogram(pixels, width, height, bins); + double sum = 0; + for (final double v : dir) + sum += v; + for (int j = 0; j < dir.length; j++) + dir[j] /= sum; + + final GaussianFit fit = new GaussianFit(bins, dir); + final double center = fit.params[2]; + final double std = fit.params[3]; + double amount = 0; + for (int j = 0; j < dir.length; j++) { + double xn = bins[j]; + if (Math.abs(xn - center) > Math.PI / 2) xn += xn > center ? -Math.PI + : Math.PI; + if (xn >= center - SIGMA_NUMBER * std && xn <= center + SIGMA_NUMBER * + std) amount += dir[j]; + } + + final double[] degrees = new double[nBins]; + for (int j = 0; j < nBins; j++) + degrees[j] = 180 * bins[j] / Math.PI; + return new Directionality(degrees, dir, fit.params[0], fit.params[1], Math + .toDegrees(center), Math.toDegrees(std), amount, fit.goodness); + } + + // -- Local gradient orientation -- + + private static double[] gradientHistogram(final float[] pixels, + final int width, final int height, final double[] bins, + final double binStart, final double binEnd) + { + final int nBins = bins.length; + final double[] normDir = new double[nBins]; + // Note: The kernels are swapped and transposed relative to the usual + // Sobel naming, to get the orientation of the structures (90 degrees from + // the gradient), as in the plugin. + final float[] kernelY = { // + -5f, -4f, 0f, 4f, 5f, // + -8f, -10f, 0f, 10f, 8f, // + -10f, -20f, 0f, 20f, 10f, // + -8f, -10f, 0f, 10f, 8f, // + -5f, -4f, 0f, 4f, 5f }; + final float[] kernelX = { // + 5f, 8f, 10f, 8f, 5f, // + 4f, 10f, 20f, 10f, 4f, // + 0f, 0f, 0f, 0f, 0f, // + -4f, -10f, -20f, -10f, -4f, // + -5f, -8f, -10f, -8f, -5f }; + final float[] gradX = correlate5x5(pixels, width, height, kernelX); + final float[] gradY = correlate5x5(pixels, width, height, kernelY); + + // The range of allowed angles, wrapped within [-90, 90] degrees. + final double wrappedStart = ((binStart + 90) % 180 + 180) % 180 - 90; + final double wrappedEnd = ((binEnd + 90) % 180 + 180) % 180 - 90; + final double range1Min, range1Max, range2Min, range2Max; + if (wrappedEnd <= wrappedStart) { + range1Min = -90; + range1Max = wrappedEnd; + range2Min = wrappedStart; + range2Max = 90; + } + else { + range1Min = wrappedStart; + range1Max = wrappedEnd; + range2Min = wrappedStart; + range2Max = wrappedEnd; + } + final double binWidth = (bins[1] - bins[0]) * 180.0 / Math.PI; + for (int i = 0; i < gradX.length; i++) { + // Note: Float arithmetic, as in the plugin. + final float dx = gradX[i]; + final float dy = -gradY[i]; // upright orientation + final double norm = dx * dx + dy * dy; + final double angle = Math.atan(dy / dx) * 180.0 / Math.PI; + if ((angle >= range1Min && angle <= range1Max) || (angle >= range2Min && + angle <= range2Max)) + { + double angleRef = angle; + while (angleRef > binEnd) + angleRef -= 180; + while (angleRef < binStart) + angleRef += 180; + // Note: The plugin truncates here, which fills each bin with the + // angles in [center, center + width) and so biases the histogram by + // half a bin. Rounding assigns each angle to its nearest bin center. + final int index = (int) Math.round((angleRef - binStart) / binWidth); + normDir[Math.min(index, nBins - 1)] += norm; + } + } + return normDir; + } + + /** + * Correlates the image with a 5x5 kernel, extending the image by its edge + * pixels, like the ImageJ {@code Convolver} with a zero-sum kernel. + */ + private static float[] correlate5x5(final float[] pixels, final int width, + final int height, final float[] kernel) + { + final float[] out = new float[pixels.length]; + for (int y = 0; y < height; y++) { + for (int x = 0; x < width; x++) { + double sum = 0; + int i = 0; + for (int v = -2; v <= 2; v++) { + final int yy = Math.min(Math.max(y + v, 0), height - 1); + for (int u = -2; u <= 2; u++) { + final int xx = Math.min(Math.max(x + u, 0), width - 1); + sum += pixels[xx + yy * width] * kernel[i++]; + } + } + out[x + y * width] = (float) sum; + } + } + return out; + } + + // -- Fourier components -- + + private static double[] fourierHistogram(final float[] pixels, + final int width, final int height, final double[] bins) + { + final int nBins = bins.length; + // Cut the image into overlapping square blocks covering it. + final int smallSide = Math.min(width, height); + final int nPadX, nPadY, step; + if (width == height) { + nPadX = 1; + nPadY = 1; + step = 0; + } + else { + final int longSide = Math.max(width, height); + final int nPad = longSide / smallSide + 1; + nPadX = width == longSide ? nPad : 1; + nPadY = width == longSide ? 1 : nPad; + step = (longSide - smallSide) / (nPad - 1); + } + int padSize = 2; + while (padSize < smallSide) + padSize *= 2; + final int offset = (padSize - smallSide) / 2; + + final float[] window = blackmanWindow(smallSide); + final float[][] filters = makeFilters(padSize, bins); + + final double[] dir = new double[nBins]; + final double[] re = new double[padSize * padSize]; + final double[] im = new double[padSize * padSize]; + final float[] spectrum = new float[padSize * padSize]; + for (int ix = 0; ix < nPadX; ix++) { + for (int iy = 0; iy < nPadY; iy++) { + // Window the block and pad it to a power of two. + Arrays.fill(re, 0); + Arrays.fill(im, 0); + for (int y = 0; y < smallSide; y++) { + for (int x = 0; x < smallSide; x++) { + final float v = pixels[(ix * step + x) + (iy * step + y) * width] * + window[x + y * smallSide]; + re[(x + offset) + (y + offset) * padSize] = v; + } + } + fft2D(re, im, padSize); + // Power spectrum, with the zero frequency at the center. + final int half = padSize / 2; + for (int r = 0; r < padSize; r++) { + final int sr = (r + half) % padSize; + for (int col = 0; col < padSize; col++) { + final int k = (col + half) % padSize + sr * padSize; + spectrum[col + r * padSize] = (float) (re[k] * re[k] + im[k] * + im[k]); + } + } + for (int bin = 0; bin < nBins; bin++) { + final float[] f = filters[bin]; + for (int i = 0; i < spectrum.length; i++) + dir[bin] += spectrum[i] * f[i]; + } + } + } + return dir; + } + + /** + * Separable 2D Blackman window. Note: The cosine of the third term does not + * depend on the position (it should be {@code cos(4 pi i / n)}); this is + * kept from the plugin to give identical results. + */ + private static float[] blackmanWindow(final int n) { + final double[] w = new double[n]; + for (int i = 0; i < n; i++) + w[i] = 0.42 - 0.5 * Math.cos(2 * Math.PI * i / n) + 0.08 * Math.cos(4 * + Math.PI / n); + final float[] window = new float[n * n]; + for (int i = 0; i < window.length; i++) + window[i] = (float) (w[i % n] * w[i / n]); + return window; + } + + /** The angular filters of Liu (1991), one per bin, centered spectra. */ + private static float[][] makeFilters(final int padSize, final double[] bins) { + final int nBins = bins.length; + final int center = padSize / 2; + final float[] r = new float[padSize * padSize]; + final float[] theta = new float[padSize * padSize]; + for (int i = 0; i < r.length; i++) { + final int dy = i / padSize - center; + final int dx = i % padSize - center; + r[i] = (float) Math.sqrt(dx * dx + dy * dy); + theta[i] = (float) Math.atan2(-dy, dx); // upright orientation + } + final double thetaBw = bins[1] - bins[0]; + final double rc = padSize / 4; + final double rBw = rc / 2; + final float[][] filters = new float[nBins][]; + for (int b = 0; b < nBins; b++) { + final float[] f = new float[padSize * padSize]; + double thetaC = bins[b]; + while (thetaC >= Math.PI) + thetaC -= Math.PI; + while (thetaC < 0) + thetaC += Math.PI; + thetaC -= Math.PI / 2; + for (int i = 0; i < f.length; i++) { + final double cr = r[i]; + if (cr < FREQ_THRESHOLD || cr > padSize / 2) continue; + final double radial = Math.exp(-(cr - rc) * (cr - rc) / (rBw * rBw)); + final double ct = theta[i]; + final double delta; + if (Math.abs(ct - thetaC) < thetaBw) delta = ct - thetaC; + else if (Math.abs(ct - (thetaC - Math.PI)) < thetaBw) delta = ct - + (thetaC - Math.PI); + else if (Math.abs(ct - (thetaC + Math.PI)) < thetaBw) delta = ct - + (thetaC + Math.PI); + else continue; + final double angular = Math.cos(delta / thetaBw * Math.PI / 2.0); + f[i] = (float) (angular * angular * radial); + } + filters[b] = f; + } + return filters; + } + + /** In-place 2D FFT of a square power-of-two array, rows then columns. */ + private static void fft2D(final double[] re, final double[] im, + final int n) + { + final double[] rowRe = new double[n]; + final double[] rowIm = new double[n]; + for (int y = 0; y < n; y++) { + System.arraycopy(re, y * n, rowRe, 0, n); + System.arraycopy(im, y * n, rowIm, 0, n); + fft1D(rowRe, rowIm); + System.arraycopy(rowRe, 0, re, y * n, n); + System.arraycopy(rowIm, 0, im, y * n, n); + } + for (int x = 0; x < n; x++) { + for (int y = 0; y < n; y++) { + rowRe[y] = re[x + y * n]; + rowIm[y] = im[x + y * n]; + } + fft1D(rowRe, rowIm); + for (int y = 0; y < n; y++) { + re[x + y * n] = rowRe[y]; + im[x + y * n] = rowIm[y]; + } + } + } + + /** In-place iterative radix-2 FFT. */ + private static void fft1D(final double[] re, final double[] im) { + final int n = re.length; + for (int i = 1, j = 0; i < n; i++) { + int bit = n >> 1; + for (; (j & bit) != 0; bit >>= 1) + j ^= bit; + j ^= bit; + if (i < j) { + double t = re[i]; + re[i] = re[j]; + re[j] = t; + t = im[i]; + im[i] = im[j]; + im[j] = t; + } + } + for (int len = 2; len <= n; len <<= 1) { + final double angle = -2 * Math.PI / len; + final double wRe = Math.cos(angle), wIm = Math.sin(angle); + for (int i = 0; i < n; i += len) { + double cRe = 1, cIm = 0; + for (int k = 0; k < len / 2; k++) { + final int a = i + k, b = a + len / 2; + final double tRe = re[b] * cRe - im[b] * cIm; + final double tIm = re[b] * cIm + im[b] * cRe; + re[b] = re[a] - tRe; + im[b] = im[a] - tIm; + re[a] += tRe; + im[a] += tIm; + final double nRe = cRe * wRe - cIm * wIm; + cIm = cRe * wIm + cIm * wRe; + cRe = nRe; + } + } + } + } +} diff --git a/scijava-ops-image-gpl/src/main/java/org/scijava/ops/image/gpl/directionality/Directionality.java b/scijava-ops-image-gpl/src/main/java/org/scijava/ops/image/gpl/directionality/Directionality.java new file mode 100644 index 000000000..eab7f4735 --- /dev/null +++ b/scijava-ops-image-gpl/src/main/java/org/scijava/ops/image/gpl/directionality/Directionality.java @@ -0,0 +1,133 @@ +/*- + * #%L + * GPL-licensed image processing operations for SciJava Ops, translated from copyleft ImageJ/Fiji plugins. + * %% + * Copyright (C) 2026 SciJava developers. + * %% + * This program 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. + * + * This program 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 this program. If not, see + * . + * #L% + */ + +package org.scijava.ops.image.gpl.directionality; + +/** + * Immutable result of a directionality analysis: the histogram of the amount + * of structures in each direction, and the Gaussian fit of its highest peak, + * as reported by the Fiji Directionality plugin. + *

+ * Angles are in degrees, in their usual mathematical sense: 0 is the East + * direction (along increasing X) and angles increase counterclockwise, that is + * towards decreasing Y. + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @author Jean-Yves Tinevez + */ +public final class Directionality { + + private final double[] binCenters; + private final double[] histogram; + private final double offset; + private final double peak; + private final double direction; + private final double dispersion; + private final double amount; + private final double goodness; + + Directionality(final double[] binCenters, final double[] histogram, + final double offset, final double peak, final double direction, + final double dispersion, final double amount, final double goodness) + { + this.binCenters = binCenters.clone(); + this.histogram = histogram.clone(); + this.offset = offset; + this.peak = peak; + this.direction = direction; + this.dispersion = dispersion; + this.amount = amount; + this.goodness = goodness; + } + + /** @return the number of bins of the histogram */ + public int binCount() { + return histogram.length; + } + + /** + * @return the direction of each bin, in degrees (a copy). As in the plugin, + * the first and last bins are at the start and end of the analyzed + * range, so with the default range they are -90 and 90 degrees. + */ + public double[] binCenters() { + return binCenters.clone(); + } + + /** + * @return the amount of structures in each bin (a copy), normalized so that + * the bins sum to 1 + */ + public double[] histogram() { + return histogram.clone(); + } + + /** + * @return the baseline of the fitted Gaussian ({@code a} in + * {@code a + (b - a) exp(-(x - c)^2 / (2 d^2))}) + */ + public double offset() { + return offset; + } + + /** @return the peak value of the fitted Gaussian ({@code b}) */ + public double peak() { + return peak; + } + + /** + * @return the dominant direction: the center of the fitted Gaussian + * ({@code c}), in degrees + */ + public double direction() { + return direction; + } + + /** + * @return the dispersion: the standard deviation of the fitted Gaussian + * ({@code d}), in degrees + */ + public double dispersion() { + return dispersion; + } + + /** + * @return the amount of structures in the dominant direction: the sum of + * the histogram bins within two standard deviations of the center + * of the fitted Gaussian, taking the 180 degree periodicity into + * account + */ + public double amount() { + return amount; + } + + /** + * @return the goodness of the Gaussian fit, from 1 (good) down to 0 (bad) + * or less: one minus the residual sum of squares relative to the + * total sum of squares, corrected for the degrees of freedom + */ + public double goodness() { + return goodness; + } +} diff --git a/scijava-ops-image-gpl/src/main/java/org/scijava/ops/image/gpl/directionality/DirectionalityMethod.java b/scijava-ops-image-gpl/src/main/java/org/scijava/ops/image/gpl/directionality/DirectionalityMethod.java new file mode 100644 index 000000000..9f5a697f0 --- /dev/null +++ b/scijava-ops-image-gpl/src/main/java/org/scijava/ops/image/gpl/directionality/DirectionalityMethod.java @@ -0,0 +1,47 @@ +/*- + * #%L + * GPL-licensed image processing operations for SciJava Ops, translated from copyleft ImageJ/Fiji plugins. + * %% + * Copyright (C) 2026 SciJava developers. + * %% + * This program 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. + * + * This program 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 this program. If not, see + * . + * #L% + */ + +package org.scijava.ops.image.gpl.directionality; + +/** + * Methods for measuring the local orientation of structures, as in the Fiji + * Directionality plugin. + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @author Jean-Yves Tinevez + */ +public enum DirectionalityMethod { + + /** + * Fourier components analysis: the image is cut into square blocks whose + * power spectra are measured in angular wedges, using the filters of Liu + * (Appl. Opt. 30(11), 1991). + */ + FOURIER_COMPONENTS, + + /** + * Local gradient orientation: the orientation of the 5x5 Sobel gradient + * at each pixel, weighted by the squared gradient magnitude. + */ + LOCAL_GRADIENT_ORIENTATION +} diff --git a/scijava-ops-image-gpl/src/main/java/org/scijava/ops/image/gpl/directionality/GaussianFit.java b/scijava-ops-image-gpl/src/main/java/org/scijava/ops/image/gpl/directionality/GaussianFit.java new file mode 100644 index 000000000..54c6eac5f --- /dev/null +++ b/scijava-ops-image-gpl/src/main/java/org/scijava/ops/image/gpl/directionality/GaussianFit.java @@ -0,0 +1,95 @@ +/*- + * #%L + * GPL-licensed image processing operations for SciJava Ops, translated from copyleft ImageJ/Fiji plugins. + * %% + * Copyright (C) 2026 SciJava developers. + * %% + * This program 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. + * + * This program 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 this program. If not, see + * . + * #L% + */ + +package org.scijava.ops.image.gpl.directionality; + +import org.scijava.ops.image.gpl.util.GaussianCurveFit; + +/** + * Gaussian fit of a directionality histogram, as done by the {@code GAUSSIAN} + * fit of the ImageJ {@code CurveFitter}: a {@link GaussianCurveFit} started + * from the same initial values, restarted at most twice. + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +final class GaussianFit { + + private static final int MAX_ITERATIONS = 4000; + private static final int RESTARTS = 2; + private static final double TOLERANCE = 1e-12; + + private static final GaussianCurveFit.Convergence CONVERGED = (best, worst, + spread, scale) -> Math.abs(worst - best) <= TOLERANCE * (Math.abs(best) + + Math.abs(worst)) + 1e-300 && spread <= 1e-12 * scale + 1e-300; + + /** The fitted parameters {a, b, c, d}, with d >= 0. */ + final double[] params; + + /** The goodness of fit, as reported by CurveFitter. */ + final double goodness; + + GaussianFit(final double[] x, final double[] y) { + final GaussianCurveFit fit = new GaussianCurveFit(x, y); + final int n = x.length; + double xMin = Double.POSITIVE_INFINITY, xMax = Double.NEGATIVE_INFINITY; + double yMin = Double.POSITIVE_INFINITY, yMax = Double.NEGATIVE_INFINITY; + double xOfMax = x[0], yMean = 0; + for (int i = 0; i < n; i++) { + xMin = Math.min(xMin, x[i]); + xMax = Math.max(xMax, x[i]); + if (y[i] > yMax) { + yMax = y[i]; + xOfMax = x[i]; + } + yMin = Math.min(yMin, y[i]); + yMean += y[i]; + } + yMean /= n; + final double d0 = 0.39894 * (xMax - xMin) * (yMean - yMin) / (yMax - yMin + + 1e-100); + + double[] best = fit.simplex(new double[] { xOfMax, d0 }, new double[] { + 0.2 * d0, 0.1 * d0 }, MAX_ITERATIONS, CONVERGED); + double bestValue = fit.sse(best, null); + for (int r = 0; r < RESTARTS; r++) { + final double step = 0.1 * Math.abs(best[1]) + 1e-10; + final double[] p = fit.simplex(best, new double[] { step, step }, + MAX_ITERATIONS, CONVERGED); + final double value = fit.sse(p, null); + if (!(value < bestValue)) break; + best = p; + bestValue = value; + } + params = fit.params(best); + final double sse = fit.sse(best, null); + + double sumY = 0, sumY2 = 0; + for (final double v : y) { + sumY += v; + sumY2 += v * v; + } + final double ssd = sumY2 - sumY * sumY / n; + final int dof = n - 4; + goodness = ssd > 0 && dof > 0 ? 1 - sse / ssd * n / dof : 0; + } +} diff --git a/scijava-ops-image-gpl/src/main/java/org/scijava/ops/image/gpl/directionality/package-info.java b/scijava-ops-image-gpl/src/main/java/org/scijava/ops/image/gpl/directionality/package-info.java new file mode 100644 index 000000000..5ef2a5a16 --- /dev/null +++ b/scijava-ops-image-gpl/src/main/java/org/scijava/ops/image/gpl/directionality/package-info.java @@ -0,0 +1,27 @@ +/*- + * #%L + * GPL-licensed image processing operations for SciJava Ops, translated from copyleft ImageJ/Fiji plugins. + * %% + * Copyright (C) 2026 SciJava developers. + * %% + * This program 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. + * + * This program 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 this program. If not, see + * . + * #L% + */ + +/** + * Directionality analysis ops, translated from the Fiji Directionality plugin + * by Jean-Yves Tinevez. + */ +package org.scijava.ops.image.gpl.directionality; diff --git a/scijava-ops-image-gpl/src/main/java/org/scijava/ops/image/gpl/mcib3d/ProfileGaussianFit.java b/scijava-ops-image-gpl/src/main/java/org/scijava/ops/image/gpl/mcib3d/ProfileGaussianFit.java new file mode 100644 index 000000000..d9b4e47a5 --- /dev/null +++ b/scijava-ops-image-gpl/src/main/java/org/scijava/ops/image/gpl/mcib3d/ProfileGaussianFit.java @@ -0,0 +1,88 @@ +/*- + * #%L + * GPL-licensed image processing operations for SciJava Ops, translated from copyleft ImageJ/Fiji plugins. + * %% + * Copyright (C) 2026 SciJava developers. + * %% + * This program 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. + * + * This program 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 this program. If not, see + * . + * #L% + */ + +package org.scijava.ops.image.gpl.mcib3d; + +import org.scijava.ops.image.gpl.util.GaussianCurveFit; + +/** + * Gaussian fit of a radial profile, as done in the 3D ImageJ Suite with the + * {@code GAUSSIAN} fit of the ImageJ {@code CurveFitter}: a + * {@link GaussianCurveFit} restarted until it stops improving. + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +final class ProfileGaussianFit { + + private static final int MAX_ITERATIONS = 10000; + private static final int MAX_RESTARTS = 20; + + private static final GaussianCurveFit.Convergence CONVERGED = (best, worst, + spread, scale) -> Math.abs(worst - best) <= 1e-14 * Math.abs(best) + + 1e-300 && spread <= 1e-12 * (scale + 1); + + private ProfileGaussianFit() { + // Prevent instantiation of utility class. + } + + /** + * Fits a Gaussian to a profile. + * + * @param x the abscissae + * @param y the profile values + * @param c0 the initial center + * @param d0 the initial standard deviation + * @return the fitted {a, b, c, d}, with d >= 0, or {@code null} if the fit + * failed + */ + static double[] fit(final double[] x, final double[] y, final double c0, + final double d0) + { + final GaussianCurveFit f = new GaussianCurveFit(x, y); + double[] best = { c0, d0 }; + double bestValue = f.sse(best, null); + double[] steps = { Math.max(1, Math.abs(d0)), 0.5 * Math.abs(d0) }; + for (int r = 0; r <= MAX_RESTARTS; r++) { + final double[] p = f.simplex(best, steps, MAX_ITERATIONS, CONVERGED); + final double value = f.sse(p, null); + if (r > 0 && !(value < bestValue * (1 - 1e-12))) { + if (value < bestValue) { + best = p; + bestValue = value; + } + break; + } + if (value <= bestValue) { + best = p; + bestValue = value; + } + final double s = 0.1 * Math.abs(best[1]) + 1e-6; + steps = new double[] { s, s }; + } + final double[] params = f.params(best); + for (final double p : params) { + if (!Double.isFinite(p)) return null; + } + return params; + } +} diff --git a/scijava-ops-image-gpl/src/main/java/org/scijava/ops/image/gpl/mcib3d/SpotsFromSeeds.java b/scijava-ops-image-gpl/src/main/java/org/scijava/ops/image/gpl/mcib3d/SpotsFromSeeds.java new file mode 100644 index 000000000..ae1c9ee83 --- /dev/null +++ b/scijava-ops-image-gpl/src/main/java/org/scijava/ops/image/gpl/mcib3d/SpotsFromSeeds.java @@ -0,0 +1,575 @@ +/*- + * #%L + * GPL-licensed image processing operations for SciJava Ops, translated from copyleft ImageJ/Fiji plugins. + * %% + * Copyright (C) 2026 SciJava developers. + * %% + * This program 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. + * + * This program 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 this program. If not, see + * . + * #L% + */ + +package org.scijava.ops.image.gpl.mcib3d; + +import java.util.Arrays; +import java.util.List; +import java.util.Locale; +import java.util.function.BiFunction; + +import net.imglib2.Cursor; +import net.imglib2.Dimensions; +import net.imglib2.Localizable; +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.roi.labeling.ImgLabeling; +import net.imglib2.roi.labeling.LabelingType; +import net.imglib2.type.numeric.IntegerType; +import net.imglib2.type.numeric.RealType; +import net.imglib2.type.numeric.integer.IntType; +import net.imglib2.util.Util; +import net.imglib2.view.Views; + +import org.scijava.function.Functions; +import org.scijava.ops.spi.Nullable; +import org.scijava.ops.spi.OpDependency; + +/** + * Seeded spot segmentation: grows one region (spot) from each seed, keeping + * the connected pixels above a local threshold computed for that seed, in any + * number of dimensions. + *

+ * The seeds are processed in the given order (typically the local maxima of + * the image, e.g. from {@code image.localExtrema}, in flat iteration order). + * For each seed not already part of a spot, a local threshold is computed with + * one of these methods: + *

+ *
    + *
  • {@code "constant"}: the given {@code value}.
  • + *
  • {@code "diff"}: the seed value minus the given {@code value}.
  • + *
  • {@code "mean"}: {@code w * spotMean + (1 - w) * backgroundMean}, where + * {@code w} is the given {@code value} (default 0.5), {@code spotMean} the + * mean in the ball of radius {@code radii[0]} (default 2) around the seed, and + * {@code backgroundMean} the mean in the shell of radii {@code radii[1]} (inner, + * inclusive, default 4) and {@code radii[2]} (outer, exclusive, default 6). + * If the shell lies outside the image, the spot mean is used.
  • + *
  • {@code "gaussian"}: the radial profile of the seed (the mean in the + * unit-thick shells of radii 0 to {@code radii[0]}, default 10, mirrored to + * make it symmetric) is fitted by a Gaussian {@code a + (b - a) exp(-(r - c)^2 + * / (2 sigma^2))}, and the threshold is the value of this Gaussian at + * {@code value * sigma} (default 1.285, the 90th percentile of the normal + * distribution). Seeds whose fit fails produce no spot.
  • + *
+ *

+ * Then the region grows from the seed to the pixels (fully connected: 8 in + * 2D, 26 in 3D) not yet in a spot whose value is at least the threshold, with + * one of these growth rules: + *

+ *
    + *
  • {@code "classical"}: no further condition; the spot is the connected + * component of the seed.
  • + *
  • {@code "maximum"}: a pixel joins only from a neighbor at least as + * bright, so the spot only descends from the seed.
  • + *
  • {@code "block"}: a spot pixel adds its candidate neighbors only if it has + * at least two of them and none is brighter than itself, which stops the + * growth at the valley between touching spots.
  • + *
+ *

+ * Spots whose pixel count lies outside {@code [minVolume, maxVolume]} are + * discarded, and their pixels become available to later seeds, except when a + * {@code "classical"} or {@code "block"} growth is stopped for exceeding + * {@code maxVolume}: the pixels reached until then stay excluded from later + * spots, as in the original. The seed pixel itself always belongs to its spot, + * even below the threshold. With an integer image, the computed local + * thresholds are truncated to integers and the {@code "diff"} threshold is at + * least 1, as in the original. + *

+ *

+ * This is a translation of {@code Segment3DSpots} of the + * 3D ImageJ Suite + * ({@code mcib3d-core}, GPL v3+) by Thomas Boudier, generalized to n-D; see J. + * Ollion, J. Cochennec, F. Loll, C. Escudé and T. Boudier, "TANGO: a + * generic tool for high-throughput 3D image analysis for studying nuclear + * organization", Bioinformatics 29(14), 2013. Differences from the original: + * the radii are in pixels, with no spatial calibration; the watershed option + * (which confines each spot to the zone of influence of its seed) is not + * supported; the Gaussian fit uses its own least-squares solver rather than + * the ImageJ {@code CurveFitter}, so ill-conditioned fits may differ; the + * {@code "diff"} threshold is based on the image value at the seed (the + * original uses the value of the seed image, which is the same for seeds + * found as local maxima); a seed whose Gaussian fit fails produces no spot + * (the original reuses the threshold of the previous seed); the pixels of a + * spot stopped for exceeding {@code maxVolume} are not reused as part of the + * next spot (a quirk of the original); and the computed thresholds are not + * truncated for non-integer images. + *

+ *

+ * NaN pixels are outside the domain: they never join a spot, they are left + * out of the {@code "mean"} and {@code "gaussian"} thresholds, and a seed on a + * NaN pixel produces no spot. + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @author Thomas Boudier + * @param image type + * @param

seed type + * @implNote op names='segment.spotsFromSeeds' + */ +public class SpotsFromSeeds, P extends Localizable> + implements + Functions.Arity8, List

, String, Double, double[], String, Integer, Integer, ImgLabeling> +{ + + @OpDependency(name = "create.imgLabeling") + private BiFunction> labelingCreator; + + /** + * Segments one spot per seed. + * + * @param input the image + * @param seeds the seed positions, in the coordinates of {@code input} + * @param thresholdMethod how to compute the local threshold of each seed: + * {@code "constant"} (default), {@code "diff"}, {@code "mean"} or + * {@code "gaussian"} + * @param value the threshold for {@code "constant"} (required), the + * difference from the seed value for {@code "diff"} (required), the + * spot weight for {@code "mean"} (default 0.5), or the threshold + * distance in standard deviations for {@code "gaussian"} (default + * 1.285) + * @param radii for {@code "mean"}, the radius of the spot ball and the inner + * and outer radii of the background shell (default {2, 4, 6}); for + * {@code "gaussian"}, the maximum radius of the radial profile + * (default {10}); ignored otherwise + * @param growth the growth rule: {@code "classical"} (default), + * {@code "maximum"} or {@code "block"} + * @param minVolume the minimum pixel count of a spot (default 2) + * @param maxVolume the maximum pixel count of a spot (default: no maximum) + * @return a labeling of the spots, numbered from 1 in the order of their + * seeds + */ + @Override + public ImgLabeling apply( // + final RandomAccessibleInterval input, // + final List

seeds, // + @Nullable final String thresholdMethod, // + @Nullable final Double value, // + @Nullable final double[] radii, // + @Nullable final String growth, // + @Nullable final Integer minVolume, // + @Nullable final Integer maxVolume // + ) { + final Segmenter s = new Segmenter(input, // + method(thresholdMethod, value), value, radii, // + growth(growth), // + minVolume == null ? 2 : minVolume, // + maxVolume == null ? Integer.MAX_VALUE : maxVolume); + final int[] labels = s.segment(input, seeds); + final ImgLabeling output = labelingCreator.apply(input, + new IntType()); + final Cursor> c = Views.flatIterable(output) + .cursor(); + int i = 0; + while (c.hasNext()) { + final LabelingType l = c.next(); + if (labels[i] > 0) l.add(labels[i]); + i++; + } + return output; + } + + private static final int CONSTANT = 0, DIFF = 1, MEAN = 2, GAUSSIAN = 3; + private static final int CLASSICAL = 0, MAXIMUM = 1, BLOCK = 2; + + private static int method(final String name, final Double value) { + final String m = name == null ? "constant" : name.toLowerCase(Locale.ROOT); + final int method; + switch (m) { + case "constant": + method = CONSTANT; + break; + case "diff": + method = DIFF; + break; + case "mean": + method = MEAN; + break; + case "gaussian": + method = GAUSSIAN; + break; + default: + throw new IllegalArgumentException("Unknown threshold method: " + name); + } + if (value == null && (method == CONSTANT || method == DIFF)) { + throw new IllegalArgumentException("The " + m + + " threshold method needs a value"); + } + return method; + } + + private static int growth(final String name) { + if (name == null) return CLASSICAL; + switch (name.toLowerCase(Locale.ROOT)) { + case "classical": + return CLASSICAL; + case "maximum": + return MAXIMUM; + case "block": + return BLOCK; + default: + throw new IllegalArgumentException("Unknown growth rule: " + name); + } + } + + /** Marks the pixels of a spot stopped for exceeding the maximum volume. */ + private static final int CLAIMED = -1; + + private static final class Segmenter { + + private final int n; + private final int[] dims; + private final int[] strides; + private final double[] v; + private final int[] label; + private final boolean integer; + private final int method; + private final double value; + private final double[] radii; + private final int growth; + private final int minVolume; + private final int maxVolume; + + /** Flat offsets and coordinate deltas of the 3^n neighborhood. */ + private final int[] offsets; + private final int[][] deltas; + + // Region growing state. + private final int[] lo, hi, pos; + private int sens; + private boolean change; + private int val; + private double threshold; + private int[] object = new int[16]; + private int volume; + + Segmenter(final RandomAccessibleInterval> input, + final int method, final Double value, final double[] radii, + final int growth, final int minVolume, final int maxVolume) + { + n = input.numDimensions(); + dims = new int[n]; + strides = new int[n]; + long total = 1; + for (int d = 0; d < n; d++) { + dims[d] = (int) input.dimension(d); + strides[d] = (int) total; + total *= input.dimension(d); + } + if (total > Integer.MAX_VALUE) { + throw new IllegalArgumentException("Image too large"); + } + v = new double[(int) total]; + int i = 0; + for (final RealType t : Views.flatIterable(input)) + v[i++] = t.getRealDouble(); + label = new int[v.length]; + integer = Util.getTypeFromInterval(input) instanceof IntegerType; + this.method = method; + this.value = value == null ? (method == MEAN ? 0.5 : 1.285) : value; + if (radii == null) { + this.radii = method == MEAN ? new double[] { 2, 4, 6 } : new double[] { + 10 }; + } + else { + final int needed = method == MEAN ? 3 : method == GAUSSIAN ? 1 : 0; + if (radii.length < needed) { + throw new IllegalArgumentException("Expected " + needed + " radii"); + } + this.radii = radii; + } + this.growth = growth; + this.minVolume = minVolume; + this.maxVolume = maxVolume; + + int count = 1; + for (int d = 0; d < n; d++) + count *= 3; + offsets = new int[count]; + deltas = new int[count][n]; + for (int k = 0; k < count; k++) { + int r = k; + for (int d = 0; d < n; d++) { + deltas[k][d] = r % 3 - 1; + r /= 3; + offsets[k] += deltas[k][d] * strides[d]; + } + } + lo = new int[n]; + hi = new int[n]; + pos = new int[n]; + } + + int[] segment(final RandomAccessibleInterval input, + final List seeds) + { + val = 1; + for (final Localizable seed : seeds) { + final int[] p = new int[n]; + int index = 0; + for (int d = 0; d < n; d++) { + p[d] = (int) (seed.getLongPosition(d) - input.min(d)); + if (p[d] < 0 || p[d] >= dims[d]) { + throw new IllegalArgumentException("Seed outside the image: " + + seed); + } + index += p[d] * strides[d]; + } + if (label[index] != 0 || Double.isNaN(v[index])) continue; + final double t = localThreshold(p, index); + if (Double.isNaN(t)) continue; + threshold = t; + if (!grow(p, index)) { + for (int k = 0; k < volume; k++) + label[object[k]] = CLAIMED; + } + else if (volume >= minVolume && volume <= maxVolume) { + val++; + } + else { + for (int k = 0; k < volume; k++) + label[object[k]] = 0; + } + } + for (int k = 0; k < label.length; k++) + if (label[k] == CLAIMED) label[k] = 0; + return label; + } + + // -- Local thresholds -- + + private double localThreshold(final int[] p, final int index) { + switch (method) { + case CONSTANT: + return value; + case DIFF: + return integer ? Math.max(1, (long) (v[index] - value)) : v[index] - + value; + case MEAN: + return truncate(localMean(p)); + default: + return truncate(gaussianThreshold(p)); + } + } + + private double truncate(final double t) { + return integer && !Double.isNaN(t) ? (long) t : t; + } + + private double localMean(final int[] p) { + final double r0 = radii[0]; + final double[] spot = shellSum(p, 0, r0 == 0 ? 1 : r0 * r0, true, (int) Math + .ceil(r0)); + final double[] back = shellSum(p, radii[1] * radii[1], radii[2] * + radii[2], false, (int) Math.ceil(radii[2])); + final double mSpot = spot[0] / spot[1]; + if (back[1] == 0) return mSpot; + final double mBack = back[0] / back[1]; + return mSpot * value + (1 - value) * mBack; + } + + private double gaussianThreshold(final int[] p) { + final int maxR = (int) radii[0]; + final double[] profile = new double[2 * maxR + 1]; + for (int r = 0; r <= maxR; r++) { + final double[] s = shellSum(p, r * r, (r + 1) * (r + 1), false, r + 1); + final double m = s[1] == 0 ? Double.NaN : s[0] / s[1]; + profile[maxR - r] = m; + profile[maxR + r] = m; + } + // Note: Shells without pixels (outside the image, or all NaN) are + // left out of the fit. + int len = 0; + for (final double m : profile) + if (!Double.isNaN(m)) len++; + final double[] x = new double[len]; + final double[] y = new double[len]; + for (int i = 0, j = 0; i < profile.length; i++) { + if (Double.isNaN(profile[i])) continue; + x[j] = i - maxR; + y[j++] = profile[i]; + } + final double[] g = ProfileGaussianFit.fit(x, y, 0, 3); + if (g == null) return Double.NaN; + final double dx = value * g[3] - g[2]; + return g[0] + (g[1] - g[0]) * Math.exp(-dx * dx / (2 * g[3] * g[3])); + } + + /** + * Sums the pixels at squared distance {@code d2} from {@code p} with + * {@code min <= d2 < max} (or {@code d2 <= max} if {@code inclusive}), + * within the box of the given half size. + * + * @return {sum, count} + */ + private double[] shellSum(final int[] p, final double min, + final double max, final boolean inclusive, final int half) + { + final int[] q = new int[n]; + final int[] qMin = new int[n]; + final int[] qMax = new int[n]; + for (int d = 0; d < n; d++) { + qMin[d] = Math.max(0, p[d] - half); + qMax[d] = Math.min(dims[d] - 1, p[d] + half); + q[d] = qMin[d]; + } + double sum = 0; + long count = 0; + while (true) { + double d2 = 0; + int index = 0; + for (int d = 0; d < n; d++) { + final double dd = q[d] - p[d]; + d2 += dd * dd; + index += q[d] * strides[d]; + } + if (d2 >= min && (inclusive ? d2 <= max : d2 < max) && !Double + .isNaN(v[index])) + { + sum += v[index]; + count++; + } + int d = 0; + while (d < n && ++q[d] > qMax[d]) { + q[d] = qMin[d]; + d++; + } + if (d == n) break; + } + return new double[] { sum, count }; + } + + // -- Region growing -- + + /** + * Grows the spot of the current label from a seed, alternating forward + * and backward raster scans of the growing bounding box until no pixel is + * added, like the original. + * + * @return false if the growth was stopped for exceeding the maximum + * volume + */ + private boolean grow(final int[] p, final int index) { + for (int d = 0; d < n; d++) { + lo[d] = p[d]; + hi[d] = p[d] + 1; + } + label[index] = val; + volume = 0; + add(index); + sens = 1; + change = true; + while (change) { + change = false; + if (!scan(n - 1)) return false; + sens = -sens; + } + return true; + } + + private boolean scan(final int d) { + if (sens > 0) { + for (pos[d] = lo[d]; pos[d] <= hi[d]; pos[d]++) { + if (!(d == 0 ? visit() : scan(d - 1))) return false; + } + } + else { + for (pos[d] = hi[d]; pos[d] >= lo[d]; pos[d]--) { + if (!(d == 0 ? visit() : scan(d - 1))) return false; + } + } + return true; + } + + private boolean visit() { + int index = 0; + for (int d = 0; d < n; d++) { + if (pos[d] < 0 || pos[d] >= dims[d]) return true; + index += pos[d] * strides[d]; + } + if (label[index] != val) return true; + final double center = v[index]; + if (growth == BLOCK) return visitBlock(index, center); + for (int k = 0; k < offsets.length; k++) { + if (!inside(deltas[k])) continue; + final int q = index + offsets[k]; + // Note: The negated comparisons keep NaN pixels out of the spot. + if (label[q] != 0 || !(v[q] >= threshold)) continue; + if (growth == MAXIMUM && !(v[q] <= center)) continue; + label[q] = val; + add(q); + if (growth == CLASSICAL && volume > maxVolume) return false; + extend(deltas[k]); + change = true; + } + return true; + } + + private boolean visitBlock(final int index, final double center) { + int count = 0; + boolean ok = true; + final int[] neigh = new int[offsets.length]; + for (int k = 0; k < offsets.length; k++) { + if (!inside(deltas[k])) continue; + final int q = index + offsets[k]; + if (label[q] != 0 || !(v[q] >= threshold)) continue; + neigh[count++] = k; + if (!(v[q] <= center)) ok = false; + } + if (count < 2 || !ok) return true; + change = true; + if (count > maxVolume) return false; + for (int i = 0; i < count; i++) { + final int k = neigh[i]; + final int q = index + offsets[k]; + label[q] = val; + add(q); + if (volume > maxVolume) return false; + extend(deltas[k]); + } + return true; + } + + private boolean inside(final int[] delta) { + for (int d = 0; d < n; d++) { + final int q = pos[d] + delta[d]; + if (q < 0 || q >= dims[d]) return false; + } + return true; + } + + /** Extends the bounding box to the neighbor at the given delta. */ + private void extend(final int[] delta) { + for (int d = 0; d < n; d++) { + final int q = pos[d] + delta[d]; + if (q < lo[d]) lo[d]--; + if (q > hi[d]) hi[d]++; + } + } + + private void add(final int index) { + if (volume == object.length) { + object = Arrays.copyOf(object, 2 * volume); + } + object[volume++] = index; + } + } +} diff --git a/scijava-ops-image-gpl/src/main/java/org/scijava/ops/image/gpl/mcib3d/package-info.java b/scijava-ops-image-gpl/src/main/java/org/scijava/ops/image/gpl/mcib3d/package-info.java new file mode 100644 index 000000000..b1de2ba72 --- /dev/null +++ b/scijava-ops-image-gpl/src/main/java/org/scijava/ops/image/gpl/mcib3d/package-info.java @@ -0,0 +1,28 @@ +/*- + * #%L + * GPL-licensed image processing operations for SciJava Ops, translated from copyleft ImageJ/Fiji plugins. + * %% + * Copyright (C) 2026 SciJava developers. + * %% + * This program 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. + * + * This program 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 this program. If not, see + * . + * #L% + */ + +/** + * Ops translated from the GPL-licensed + * 3D ImageJ Suite + * library {@code mcib3d-core} by Thomas Boudier and others. + */ +package org.scijava.ops.image.gpl.mcib3d; diff --git a/scijava-ops-image-gpl/src/main/java/org/scijava/ops/image/gpl/package-info.java b/scijava-ops-image-gpl/src/main/java/org/scijava/ops/image/gpl/package-info.java new file mode 100644 index 000000000..c41adc535 --- /dev/null +++ b/scijava-ops-image-gpl/src/main/java/org/scijava/ops/image/gpl/package-info.java @@ -0,0 +1,31 @@ +/*- + * #%L + * GPL-licensed image processing operations for SciJava Ops, translated from copyleft ImageJ/Fiji plugins. + * %% + * Copyright (C) 2026 SciJava developers. + * %% + * This program 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. + * + * This program 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 this program. If not, see + * . + * #L% + */ + +/** + * Image processing ops translated from GPL-licensed ImageJ and Fiji plugins. + *

+ * Ops live in subpackages of this package, but use the same op namespaces as + * SciJava Ops Image (e.g. {@code morphology.*}, {@code segment.*}). Each op's + * javadoc credits the original authors, project and license. + *

+ */ +package org.scijava.ops.image.gpl; diff --git a/scijava-ops-image-gpl/src/main/java/org/scijava/ops/image/gpl/ridge/ContourClass.java b/scijava-ops-image-gpl/src/main/java/org/scijava/ops/image/gpl/ridge/ContourClass.java new file mode 100644 index 000000000..4c541553c --- /dev/null +++ b/scijava-ops-image-gpl/src/main/java/org/scijava/ops/image/gpl/ridge/ContourClass.java @@ -0,0 +1,49 @@ +/*- + * #%L + * GPL-licensed image processing operations for SciJava Ops, translated from copyleft ImageJ/Fiji plugins. + * %% + * Copyright (C) 2026 SciJava developers. + * %% + * This program 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. + * + * This program 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 this program. If not, see + * . + * #L% + */ + +package org.scijava.ops.image.gpl.ridge; + +/** + * Classification of a detected line by the junctions at its end points. + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @author Thorsten Wagner + * @author Carsten Steger + */ +public enum ContourClass { + + /** No end point of the line is a junction. */ + NO_JUNCTION, + + /** Only the start point of the line is a junction. */ + START_JUNCTION, + + /** Only the end point of the line is a junction. */ + END_JUNCTION, + + /** Both end points of the line are junctions. */ + BOTH_JUNCTIONS, + + /** The line is closed. */ + CLOSED +} diff --git a/scijava-ops-image-gpl/src/main/java/org/scijava/ops/image/gpl/ridge/Convol.java b/scijava-ops-image-gpl/src/main/java/org/scijava/ops/image/gpl/ridge/Convol.java new file mode 100644 index 000000000..76dff9ac9 --- /dev/null +++ b/scijava-ops-image-gpl/src/main/java/org/scijava/ops/image/gpl/ridge/Convol.java @@ -0,0 +1,268 @@ +/*- + * #%L + * GPL-licensed image processing operations for SciJava Ops, translated from copyleft ImageJ/Fiji plugins. + * %% + * Copyright (C) 2026 SciJava developers. + * %% + * This program 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. + * + * This program 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 this program. If not, see + * . + * #L% + */ + +package org.scijava.ops.image.gpl.ridge; + +import org.scijava.ops.image.gpl.ridge.LinesUtil.MutableInt; + +/** + * Convolution of an image with the derivatives of a Gaussian kernel, using + * masks of integrated Gaussian functions and mirrored borders, from the + * ij-ridgedetection {@code Convol} class (a translation of the {@code convol.c} + * of Carsten Steger). + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @author Thorsten Wagner + * @author Carsten Steger + */ +class Convol { + + /* 1/sqrt(2*PI) */ + private final double SQRT_2_PI_INV = 0.398942280401432677939946059935; + + /* + * Functions to compute the integral, and the 0th and 1st derivative of the + * Gaussian function 1/(sqrt(2*PI)*sigma)*exp(-0.5*x^2/sigma^2) + */ + + /* Integral of the Gaussian function */ + private double phi0(double x, double sigma) { + return Normal.getNormal(x / sigma); + } + + /* The Gaussian function */ + private double phi1(double x, double sigma) { + double t; + t = x / sigma; + return SQRT_2_PI_INV / sigma * Math.exp(-0.5 * t * t); + } + + /* First derivative of the Gaussian function */ + double phi2(double x, double sigma) { + double t; + t = x / sigma; + return -x * SQRT_2_PI_INV / Math.pow(sigma, 3.0) * Math.exp(-0.5 * t * t); + } + + /* + * Functions to compute the one-dimensional convolution masks of the 0th, 1st, + * and 2nd derivative of the Gaussian kernel for a certain smoothing level + * given by sigma. The mask is allocated by the function and given as the + * return value. The caller must ensure that this memory is freed. The output + * is intended to be used as an array with range [-num:num]. Therefore, the + * caller should add num to the return value. Examples for the calling + * sequence can be found in convolveGauss. Examples for the usage of the masks + * are given in convolveRowsGauss and convolveColsGauss. + */ + + /* Gaussian smoothing mask */ + + double[] computeGaussMask0(MutableInt num, double sigma) { + + int i, n; + double limit; + double[] h; + + limit = LinesUtil.maskSize(LinesUtil.MAX_SIZE_MASK_0, + sigma); /* Error < 0.001 on each side */ + n = (int) limit; + h = new double[2 * n + 1]; + for (i = -n + 1; i <= n - 1; i++) + h[n + i] = phi0(-i + 0.5, sigma) - phi0(-i - 0.5, sigma); + h[0] = 1.0 - phi0(n - 0.5, sigma); + h[2 * n] = phi0(-n + 0.5, sigma); + num.setValue(n); + return h; + } + + /* First derivative of Gaussian smoothing mask */ + double[] computeGaussMask1(MutableInt num, double sigma) { + int i, n; + double limit; + double[] h; + + limit = LinesUtil.maskSize(LinesUtil.MAX_SIZE_MASK_1, + sigma); /* Error < 0.001 on each side */ + n = (int) limit; + h = new double[2 * n + 1]; + + for (i = -n + 1; i <= n - 1; i++) + h[n + i] = phi1(-i + 0.5, sigma) - phi1(-i - 0.5, sigma); + h[0] = -phi1(n - 0.5, sigma); + h[2 * n] = phi1(-n + 0.5, sigma); + num.setValue(n); + return h; + } + + /* Second derivative of Gaussian smoothing mask */ + double[] computeGaussMask2(MutableInt num, double sigma) { + int i, n; + double limit; + double[] h; + + limit = LinesUtil.maskSize(LinesUtil.MAX_SIZE_MASK_2, + sigma); /* Error < 0.001 on each side */ + n = (int) limit; + h = new double[2 * n + 1]; + + for (i = -n + 1; i <= n - 1; i++) + h[n + i] = phi2(-i + 0.5, sigma) - phi2(-i - 0.5, sigma); + h[0] = -phi2(n - 0.5, sigma); + h[2 * n] = phi2(-n + 0.5, sigma); + num.setValue(n); + return h; + } + + /* + * Convolve an image with the derivatives of a Gaussian smoothing kernel. + * Since all of the masks are separable, this is done in two steps in the + * function convolveGauss. Firstly, the rows of the image are convolved by an + * appropriate one-dimensional mask in convolveRowsGauss, yielding an + * intermediate float-image h. Then the columns of this image are convolved by + * another appropriate mask in convolveColsGauss to yield the final result k. + * At the border of the image the gray values are mirrored. + */ + + /* Convolve the rows of an image with the derivatives of a Gaussian. */ + private void convolveRowsGauss(float[] image, double[] mask, int n, float[] h, + int width, int height) + { + int j, r, c, l; + double sum; + + /* Inner region */ + for (r = n; r < height - n; r++) { + for (c = 0; c < width; c++) { + l = LinesUtil.linCoor(r, c, width); + sum = 0.0; + for (j = -n; j <= n; j++) + sum += (double) (image[(l + j * width)]) * mask[(j + n)]; + h[l] = (float) sum; + } + } + /* Border regions */ + for (r = 0; r < n; r++) { + for (c = 0; c < width; c++) { + l = LinesUtil.linCoor(r, c, width); + sum = 0.0; + for (j = -n; j <= n; j++) + sum += (double) (image[LinesUtil.linCoor(LinesUtil.mirrorRow(r + j, + height), c, width)]) * mask[(j + n)]; + h[l] = (float) sum; + } + } + for (r = height - n; r < height; r++) { + for (c = 0; c < width; c++) { + l = LinesUtil.linCoor(r, c, width); + sum = 0.0; + for (j = -n; j <= n; j++) + sum += (double) (image[LinesUtil.linCoor(LinesUtil.mirrorRow(r + j, + height), c, width)]) * mask[(j + n)]; + h[l] = (float) sum; + } + } + } + + /* Convolve the columns of an image with the derivatives of a Gaussian. */ + private void convolveColsGauss(float[] h, double[] mask, int n, float[] k, + int width, int height) + { + int j, r, c, l; + double sum; + + /* Inner region */ + for (r = 0; r < height; r++) { + for (c = n; c < width - n; c++) { + l = LinesUtil.linCoor(r, c, width); + sum = 0.0; + for (j = -n; j <= n; j++) + sum += h[(l + j)] * mask[(j + n)]; + k[l] = (float) sum; + } + } + /* Border regions */ + for (r = 0; r < height; r++) { + for (c = 0; c < n; c++) { + l = LinesUtil.linCoor(r, c, width); + sum = 0.0; + for (j = -n; j <= n; j++) + sum += h[LinesUtil.linCoor(r, LinesUtil.mirrorCol(c + j, width), + width)] * mask[(j + n)]; + k[l] = (float) sum; + } + } + for (r = 0; r < height; r++) { + for (c = width - n; c < width; c++) { + l = LinesUtil.linCoor(r, c, width); + sum = 0.0; + for (j = -n; j <= n; j++) + sum += h[LinesUtil.linCoor(r, LinesUtil.mirrorCol(c + j, width), + width)] * mask[(j + n)]; + k[l] = (float) sum; + } + } + } + + /* Convolve an image with a derivative of the Gaussian. */ + void convolveGauss(float[] image, float[] k, int width, int height, + double sigma, int derivType) + { + double[] hr = null, hc = null; + double[] maskr, maskc; + MutableInt nr = new MutableInt(), nc = new MutableInt(); + float[] h; + + h = new float[(width * height)]; + + switch (derivType) { + case LinesUtil.DERIV_R: + hr = computeGaussMask1(nr, sigma); + hc = computeGaussMask0(nc, sigma); + break; + case LinesUtil.DERIV_C: + hr = computeGaussMask0(nr, sigma); + hc = computeGaussMask1(nc, sigma); + break; + case LinesUtil.DERIV_RR: + hr = computeGaussMask2(nr, sigma); + hc = computeGaussMask0(nc, sigma); + break; + case LinesUtil.DERIV_RC: + hr = computeGaussMask1(nr, sigma); + hc = computeGaussMask1(nc, sigma); + break; + case LinesUtil.DERIV_CC: + hr = computeGaussMask0(nr, sigma); + hc = computeGaussMask2(nc, sigma); + break; + } + + maskr = hr; + maskc = hc; + + convolveRowsGauss(image, maskr, nr.intValue(), h, width, height); + convolveColsGauss(h, maskc, nc.intValue(), k, width, height); + + } + +} diff --git a/scijava-ops-image-gpl/src/main/java/org/scijava/ops/image/gpl/ridge/Correct.java b/scijava-ops-image-gpl/src/main/java/org/scijava/ops/image/gpl/ridge/Correct.java new file mode 100644 index 000000000..bda2cf192 --- /dev/null +++ b/scijava-ops-image-gpl/src/main/java/org/scijava/ops/image/gpl/ridge/Correct.java @@ -0,0 +1,1019 @@ +/*- + * #%L + * GPL-licensed image processing operations for SciJava Ops, translated from copyleft ImageJ/Fiji plugins. + * %% + * Copyright (C) 2026 SciJava developers. + * %% + * This program 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. + * + * This program 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 this program. If not, see + * . + * #L% + */ + +package org.scijava.ops.image.gpl.ridge; + +import org.scijava.ops.image.gpl.ridge.LinesUtil.MutableDouble; + +/** + * Correction of the bias of the extracted line width and position caused by + * asymmetric line profiles, by bilinear interpolation in a table of the true + * width, asymmetry and position correction as a function of the extracted width + * and gradient ratio, from the ij-ridgedetection {@code Correct} class (a + * translation of the {@code correct.c} of Carsten Steger). See C. Steger, "An + * unbiased detector of curvilinear structures", IEEE Transactions on Pattern + * Analysis and Machine Intelligence 20(2):113-125, 1998. + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @author Thorsten Wagner + * @author Carsten Steger + */ +final class Correct { + + private Correct() { + // Prevent instantiation of utility class. + } + + /** + * The correction table, indexed by the gradient ratio (21 steps of 0.05) and + * the extracted width relative to sigma (41 steps of 0.1 from 2). Each row + * holds the extracted width and gradient ratio, the true width and asymmetry, + * the position correction, the true widths on the strong and weak sides, and + * 1 if the entry is valid. + */ + // @formatter:off + private static final double[][][] CORRECTION_TABLE = { + { + {2.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 0}, + {2.1, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 0}, + {2.2, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 0}, + {2.3, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 0}, + {2.4, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 0}, + {2.5, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 0}, + {2.6, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 0}, + {2.7, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 0}, + {2.8, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 0}, + {2.9, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 0}, + {3.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 0}, + {3.1, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 0}, + {3.2, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 0}, + {3.3, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 0}, + {3.4, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 0}, + {3.5, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 0}, + {3.6, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 0}, + {3.7, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 0}, + {3.8, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 0}, + {3.9, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 0}, + {4.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 0}, + {4.1, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 0}, + {4.2, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 0}, + {4.3, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 0}, + {4.4, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 0}, + {4.5, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 0}, + {4.6, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 0}, + {4.7, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 0}, + {4.8, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 0}, + {4.9, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 0}, + {5.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 0}, + {5.1, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 0}, + {5.2, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 0}, + {5.3, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 0}, + {5.4, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 0}, + {5.5, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 0}, + {5.6, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 0}, + {5.7, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 0}, + {5.8, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 0}, + {5.9, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 0}, + {6.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 0} + }, + { + {2.0, 0.05, 0.0, 0.0, 0.0, 1.0, 1.0, 0}, + {2.1, 0.05, 0.0, 0.0, 0.0, 1.0, 1.0, 0}, + {2.2, 0.05, 0.0, 0.0, 0.0, 1.0, 1.0, 0}, + {2.3, 0.05, 0.0, 0.0, 0.0, 1.0, 1.0, 0}, + {2.4, 0.05, 0.0, 0.0, 0.0, 1.0, 1.0, 0}, + {2.5, 0.05, 0.39451193, 0.65711873, 1.35657624, 1.96381471, 0.53618529, 1}, + {2.6, 0.05, 0.63729269, 0.80935336, 1.30029235, 2.03985229, 0.56014771, 1}, + {2.7, 0.05, 0.81466418, 0.86836672, 1.24452224, 2.11300616, 0.58699384, 1}, + {2.8, 0.05, 0.9641277, 0.89909203, 1.18944119, 2.18266521, 0.61733479, 1}, + {2.9, 0.05, 1.09676455, 0.91717245, 1.13561041, 2.2483139, 0.6516861, 1}, + {3.0, 0.05, 1.21692489, 0.92848988, 1.08384513, 2.30961284, 0.69038716, 1}, + {3.1, 0.05, 1.32646409, 0.93580164, 1.03499886, 2.36645213, 0.73354787, 1}, + {3.2, 0.05, 1.42633569, 0.94059785, 0.98974767, 2.41895971, 0.78104029, 1}, + {3.3, 0.05, 1.51730916, 0.94376456, 0.94845804, 2.46746712, 0.83253288, 1}, + {3.4, 0.05, 1.60025122, 0.94586075, 0.91116814, 2.51245011, 0.88754989, 1}, + {3.5, 0.05, 1.67616918, 0.94725013, 0.87765423, 2.55446371, 0.94553629, 1}, + {3.6, 0.05, 1.74614708, 0.94817227, 0.84753168, 2.59408497, 1.00591503, 1}, + {3.7, 0.05, 1.81125741, 0.94878534, 0.82034984, 2.63186937, 1.06813063, 1}, + {3.8, 0.05, 1.87248943, 0.94919364, 0.79566103, 2.66832172, 1.13167828, 1}, + {3.9, 0.05, 1.93070645, 0.9494659, 0.77306078, 2.70388005, 1.19611995, 1}, + {4.0, 0.05, 1.98662965, 0.94964748, 0.75220527, 2.73890956, 1.26109044, 1}, + {4.1, 0.05, 2.04084073, 0.94976843, 0.73281358, 2.77370375, 1.32629625, 1}, + {4.2, 0.05, 2.09379566, 0.94984878, 0.71466202, 2.80849036, 1.39150964, 1}, + {4.3, 0.05, 2.14584335, 0.94990193, 0.69757485, 2.84343972, 1.45656028, 1}, + {4.4, 0.05, 2.19724527, 0.94993688, 0.68141474, 2.87867411, 1.52132589, 1}, + {4.5, 0.05, 2.24819385, 0.94995971, 0.66607397, 2.91427698, 1.58572302, 1}, + {4.6, 0.05, 2.29882832, 0.94997451, 0.65146721, 2.95030144, 1.64969856, 1}, + {4.7, 0.05, 2.3492479, 0.94998402, 0.63752588, 2.98677756, 1.71322244, 1}, + {4.8, 0.05, 2.39952228, 0.94999007, 0.62419378, 3.02371845, 1.77628155, 1}, + {4.9, 0.05, 2.44969972, 0.94999389, 0.61142395, 3.06112517, 1.83887483, 1}, + {5.0, 0.05, 2.49981321, 0.94999628, 0.59917634, 3.09899049, 1.90100951, 1}, + {5.1, 0.05, 2.54988502, 0.94999776, 0.58741617, 3.13730176, 1.96269824, 1}, + {5.2, 0.05, 2.59992995, 0.94999866, 0.57611272, 3.17604302, 2.02395698, 1}, + {5.3, 0.05, 2.64995776, 0.94999921, 0.56523852, 3.21519649, 2.08480351, 1}, + {5.4, 0.05, 2.6999748, 0.94999954, 0.55476869, 3.25474362, 2.14525638, 1}, + {5.5, 0.05, 2.74998512, 0.94999973, 0.54468056, 3.29466575, 2.20533425, 1}, + {5.6, 0.05, 2.7999913, 0.94999985, 0.5349533, 3.33494464, 2.26505536, 1}, + {5.7, 0.05, 2.84999497, 0.94999991, 0.52556769, 3.37556268, 2.32443732, 1}, + {5.8, 0.05, 2.89999712, 0.94999995, 0.51650591, 3.41650304, 2.38349696, 1}, + {5.9, 0.05, 2.94999837, 0.94999997, 0.50775142, 3.4577498, 2.4422502, 1}, + {6.0, 0.05, 2.99999908, 0.94999998, 0.49928881, 3.4992879, 2.5007121, 1} + }, + { + {2.0, 0.1, 0.0, 0.0, 0.0, 1.0, 1.0, 0}, + {2.1, 0.1, 0.0, 0.0, 0.0, 1.0, 1.0, 0}, + {2.2, 0.1, 0.0, 0.0, 0.0, 1.0, 1.0, 0}, + {2.3, 0.1, 0.21877029, 0.37919742, 1.08959528, 1.70353792, 0.59646208, 1}, + {2.4, 0.1, 0.55321986, 0.68457297, 1.04282943, 1.7763189, 0.6236811, 1}, + {2.5, 0.1, 0.74830067, 0.77538178, 0.99782962, 1.84682624, 0.65317376, 1}, + {2.6, 0.1, 0.90103267, 0.82093886, 0.95447592, 1.91457985, 0.68542015, 1}, + {2.7, 0.1, 1.03086984, 0.84772552, 0.91285559, 1.97916489, 0.72083511, 1}, + {2.8, 0.1, 1.14549804, 0.86473351, 0.87320467, 2.04028418, 0.75971582, 1}, + {2.9, 0.1, 1.24865758, 0.87597877, 0.83581862, 2.09779835, 0.80220165, 1}, + {3.0, 0.1, 1.34244262, 0.88357147, 0.80095702, 2.15174175, 0.84825825, 1}, + {3.1, 0.1, 1.42823493, 0.88874994, 0.76877227, 2.20231067, 0.89768933, 1}, + {3.2, 0.1, 1.50712643, 0.89229689, 0.73927998, 2.24982998, 0.95017002, 1}, + {3.3, 0.1, 1.5800975, 0.89472991, 0.7123693, 2.29470881, 1.00529119, 1}, + {3.4, 0.1, 1.64807137, 0.8963994, 0.68783797, 2.33739605, 1.06260395, 1}, + {3.5, 0.1, 1.7119125, 0.89754487, 0.66543423, 2.37834252, 1.12165748, 1}, + {3.6, 0.1, 1.77240775, 0.8983305, 0.64489333, 2.41797285, 1.18202715, 1}, + {3.7, 0.1, 1.83024928, 0.89886882, 0.62596303, 2.45666745, 1.24333255, 1}, + {3.8, 0.1, 1.88602625, 0.89923705, 0.60841798, 2.49475308, 1.30524692, 1}, + {3.9, 0.1, 1.94022542, 0.89948824, 0.59206537, 2.5325002, 1.3674998, 1}, + {4.0, 0.1, 1.99323862, 0.89965892, 0.57674481, 2.57012499, 1.42987501, 1}, + {4.1, 0.1, 2.04537405, 0.89977429, 0.56232516, 2.60779445, 1.49220555, 1}, + {4.2, 0.1, 2.09686919, 0.89985181, 0.5487, 2.64563288, 1.55436712, 1}, + {4.3, 0.1, 2.14790373, 0.89990351, 0.53578302, 2.68372903, 1.61627097, 1}, + {4.4, 0.1, 2.1986113, 0.89993773, 0.52350376, 2.7221429, 1.6778571, 1}, + {4.5, 0.1, 2.24908973, 0.89996017, 0.51180415, 2.76091205, 1.73908795, 1}, + {4.6, 0.1, 2.29940959, 0.89997477, 0.50063564, 2.80005699, 1.79994301, 1}, + {4.7, 0.1, 2.34962106, 0.89998416, 0.48995704, 2.83958563, 1.86041437, 1}, + {4.8, 0.1, 2.39975932, 0.89999016, 0.47973283, 2.87949693, 1.92050307, 1}, + {4.9, 0.1, 2.44984872, 0.89999394, 0.46993198, 2.9197837, 1.9802163, 1}, + {5.0, 0.1, 2.4999059, 0.89999631, 0.46052697, 2.96043474, 2.03956526, 1}, + {5.1, 0.1, 2.54994208, 0.89999777, 0.45149316, 3.00143639, 2.09856361, 1}, + {5.2, 0.1, 2.59996471, 0.89999867, 0.44280828, 3.04277369, 2.15722631, 1}, + {5.3, 0.1, 2.64997872, 0.89999921, 0.43445202, 3.08443117, 2.21556883, 1}, + {5.4, 0.1, 2.6999873, 0.89999954, 0.4264058, 3.12639335, 2.27360665, 1}, + {5.5, 0.1, 2.7499925, 0.89999973, 0.41865249, 3.16864514, 2.33135486, 1}, + {5.6, 0.1, 2.79999562, 0.89999985, 0.41117628, 3.21117198, 2.38882802, 1}, + {5.7, 0.1, 2.84999746, 0.89999991, 0.4039625, 3.25396002, 2.44603998, 1}, + {5.8, 0.1, 2.89999855, 0.89999995, 0.39699754, 3.29699612, 2.50300388, 1}, + {5.9, 0.1, 2.94999918, 0.89999997, 0.39026872, 3.34026792, 2.55973208, 1}, + {6.0, 0.1, 2.99999954, 0.89999998, 0.38376422, 3.38376376, 2.61623624, 1} + }, + { + {2.0, 0.15, 0.0, 0.0, 0.0, 1.0, 1.0, 0}, + {2.1, 0.15, 0.0, 0.0, 0.0, 1.0, 1.0, 0}, + {2.2, 0.15, 0.0, 0.0, 0.0, 1.0, 1.0, 0}, + {2.3, 0.15, 0.51495761, 0.59473282, 0.87697386, 1.62862197, 0.67137803, 1}, + {2.4, 0.15, 0.72159043, 0.70188701, 0.83862164, 1.69671098, 0.70328902, 1}, + {2.5, 0.15, 0.87668389, 0.75502109, 0.80221797, 1.76246497, 0.73753503, 1}, + {2.6, 0.15, 1.00530865, 0.78637591, 0.7676933, 1.8255396, 0.7744604, 1}, + {2.7, 0.15, 1.11703187, 0.80644909, 0.73507961, 1.88568549, 0.81431451, 1}, + {2.8, 0.15, 1.21658884, 0.8198694, 0.70445867, 1.94277809, 0.85722191, 1}, + {2.9, 0.15, 1.3066923, 0.82905423, 0.67590851, 1.99683071, 0.90316929, 1}, + {3.0, 0.15, 1.38910244, 0.83541673, 0.64946205, 2.04798808, 0.95201192, 1}, + {3.1, 0.15, 1.46510896, 0.83984926, 0.62508653, 2.0965032, 1.0034968, 1}, + {3.2, 0.15, 1.53575726, 0.84294409, 0.60268425, 2.14270447, 1.05729553, 1}, + {3.3, 0.15, 1.60194844, 0.84510584, 0.58210775, 2.18696061, 1.11303939, 1}, + {3.4, 0.15, 1.66447712, 0.84661499, 0.5631811, 2.22964917, 1.17035083, 1}, + {3.5, 0.15, 1.72404185, 0.84766724, 0.54571992, 2.27113192, 1.22886808, 1}, + {3.6, 0.15, 1.78124688, 0.8483995, 0.52954661, 2.31173802, 1.28826198, 1}, + {3.7, 0.15, 1.8366038, 0.84890765, 0.51449966, 2.35175431, 1.34824569, 1}, + {3.8, 0.15, 1.89053617, 0.84925898, 0.50043792, 2.39142152, 1.40857848, 1}, + {3.9, 0.15, 1.94338714, 0.84950072, 0.4872413, 2.43093478, 1.46906522, 1}, + {4.0, 0.15, 1.99542913, 0.8496661, 0.47480926, 2.47044699, 1.52955301, 1}, + {4.1, 0.15, 2.04687439, 0.84977849, 0.4630583, 2.51007378, 1.58992622, 1}, + {4.2, 0.15, 2.09788538, 0.8498543, 0.45191914, 2.54989923, 1.65010077, 1}, + {4.3, 0.15, 2.14858449, 0.84990501, 0.44133403, 2.58998156, 1.71001844, 1}, + {4.4, 0.15, 2.19906243, 0.84993863, 0.43125446, 2.63035849, 1.76964151, 1}, + {4.5, 0.15, 2.24938551, 0.84996073, 0.4216392, 2.6710519, 1.8289481, 1}, + {4.6, 0.15, 2.29960147, 0.84997511, 0.41245278, 2.71207185, 1.88792815, 1}, + {4.7, 0.15, 2.34974423, 0.84998437, 0.40366432, 2.75341983, 1.94658017, 1}, + {4.8, 0.15, 2.39983755, 0.84999029, 0.39524659, 2.79509131, 2.00490869, 1}, + {4.9, 0.15, 2.4498979, 0.84999402, 0.38717534, 2.83707774, 2.06292226, 1}, + {5.0, 0.15, 2.49993649, 0.84999636, 0.37942878, 2.87936807, 2.12063193, 1}, + {5.1, 0.15, 2.54996091, 0.8499978, 0.37198714, 2.92194977, 2.17805023, 1}, + {5.2, 0.15, 2.59997618, 0.84999869, 0.36483242, 2.96480966, 2.23519034, 1}, + {5.3, 0.15, 2.64998564, 0.84999922, 0.35794813, 3.0079344, 2.2920656, 1}, + {5.4, 0.15, 2.69999143, 0.84999954, 0.35131907, 3.05131088, 2.34868912, 1}, + {5.5, 0.15, 2.74999494, 0.84999974, 0.34493122, 3.09492638, 2.40507362, 1}, + {5.6, 0.15, 2.79999704, 0.84999985, 0.3387716, 3.13876878, 2.46123122, 1}, + {5.7, 0.15, 2.84999829, 0.84999991, 0.33282817, 3.18282653, 2.51717347, 1}, + {5.8, 0.15, 2.89999902, 0.84999995, 0.32708971, 3.22708877, 2.57291123, 1}, + {5.9, 0.15, 2.94999944, 0.84999997, 0.32154579, 3.27154526, 2.62845474, 1}, + {6.0, 0.15, 2.99999969, 0.84999999, 0.31618668, 3.31618638, 2.68381362, 1} + }, + { + {2.0, 0.2, 0.0, 0.0, 0.0, 1.0, 1.0, 0}, + {2.1, 0.2, 0.0, 0.0, 0.0, 1.0, 1.0, 0}, + {2.2, 0.2, 0.37590631, 0.43802047, 0.7665339, 1.50171448, 0.69828552, 1}, + {2.3, 0.2, 0.63559658, 0.60584521, 0.73239191, 1.56839938, 0.73160062, 1}, + {2.4, 0.2, 0.80878262, 0.67788209, 0.70033504, 1.63318175, 0.76681825, 1}, + {2.5, 0.2, 0.94582063, 0.71853708, 0.67018762, 1.69574617, 0.80425383, 1}, + {2.6, 0.2, 1.06169829, 0.74408983, 0.64186257, 1.75584733, 0.84415267, 1}, + {2.7, 0.2, 1.16325725, 0.76106674, 0.61532865, 1.81333658, 0.88666342, 1}, + {2.8, 0.2, 1.25425748, 0.77269612, 0.59057551, 1.86817741, 0.93182259, 1}, + {2.9, 0.2, 1.33703363, 0.78079692, 0.56758359, 1.9204464, 0.9795536, 1}, + {3.0, 0.2, 1.4131916, 0.78648913, 0.54630509, 1.97031993, 1.02968007, 1}, + {3.1, 0.2, 1.48393871, 0.79050502, 0.5266577, 2.01805066, 1.08194934, 1}, + {3.2, 0.2, 1.55024608, 0.79334205, 0.50852903, 2.06393933, 1.13606067, 1}, + {3.3, 0.2, 1.61292714, 0.79534577, 0.49178706, 2.10830716, 1.19169284, 1}, + {3.4, 0.2, 1.67267443, 0.79675906, 0.47629207, 2.1514724, 1.2485276, 1}, + {3.5, 0.2, 1.73007717, 0.7977537, 0.46190686, 2.19373281, 1.30626719, 1}, + {3.6, 0.2, 1.78563151, 0.79845154, 0.44850391, 2.23535435, 1.36464565, 1}, + {3.7, 0.2, 1.83974889, 0.79893917, 0.43596922, 2.27656528, 1.42343472, 1}, + {3.8, 0.2, 1.89276472, 0.79927822, 0.42420368, 2.31755461, 1.48244539, 1}, + {3.9, 0.2, 1.94494772, 0.79951258, 0.41312261, 2.35847367, 1.54152633, 1}, + {4.0, 0.2, 1.99650948, 0.79967348, 0.40265439, 2.39943952, 1.60056048, 1}, + {4.1, 0.2, 2.04761394, 0.79978311, 0.3927386, 2.44053948, 1.65946052, 1}, + {4.2, 0.2, 2.0983861, 0.7998572, 0.38332416, 2.481836, 1.718164, 1}, + {4.3, 0.2, 2.14891984, 0.79990684, 0.37436767, 2.52337133, 1.77662867, 1}, + {4.4, 0.2, 2.19928463, 0.79993979, 0.36583189, 2.56517188, 1.83482812, 1}, + {4.5, 0.2, 2.24953118, 0.79996146, 0.35768458, 2.60725196, 1.89274804, 1}, + {4.6, 0.2, 2.29969596, 0.79997556, 0.3498975, 2.64961691, 1.95038309, 1}, + {4.7, 0.2, 2.34980488, 0.79998466, 0.34244571, 2.69226562, 2.00773438, 1}, + {4.8, 0.2, 2.39987607, 0.79999046, 0.33530694, 2.73519256, 2.06480744, 1}, + {4.9, 0.2, 2.44992211, 0.79999413, 0.32846117, 2.77838928, 2.12161072, 1}, + {5.0, 0.2, 2.49995155, 0.79999642, 0.32189024, 2.82184552, 2.17815448, 1}, + {5.1, 0.2, 2.54997018, 0.79999784, 0.31557763, 2.86555011, 2.23444989, 1}, + {5.2, 0.2, 2.59998183, 0.79999871, 0.30950821, 2.90949144, 2.29050856, 1}, + {5.3, 0.2, 2.64998905, 0.79999924, 0.30366807, 2.95365795, 2.34634205, 1}, + {5.4, 0.2, 2.69999346, 0.79999955, 0.29804437, 2.99803833, 2.40196167, 1}, + {5.5, 0.2, 2.74999614, 0.79999974, 0.29262525, 3.04262169, 2.45737831, 1}, + {5.6, 0.2, 2.79999774, 0.79999985, 0.28739973, 3.08739764, 2.51260236, 1}, + {5.7, 0.2, 2.84999869, 0.79999992, 0.28235758, 3.13235638, 2.56764362, 1}, + {5.8, 0.2, 2.89999925, 0.79999995, 0.27748933, 3.17748864, 2.62251136, 1}, + {5.9, 0.2, 2.94999958, 0.79999997, 0.2727861, 3.22278571, 2.67721429, 1}, + {6.0, 0.2, 2.99999976, 0.79999999, 0.26823966, 3.26823944, 2.73176056, 1} + }, + { + {2.0, 0.25, 0.0, 0.0, 0.0, 1.0, 1.0, 0}, + {2.1, 0.25, 0.0, 0.0, 0.0, 1.0, 1.0, 0}, + {2.2, 0.25, 0.49051664, 0.47302665, 0.6529904, 1.45370728, 0.74629272, 1}, + {2.3, 0.25, 0.70654277, 0.58587424, 0.62387277, 1.51781779, 0.78218221, 1}, + {2.4, 0.25, 0.86254475, 0.64276424, 0.59669908, 1.5801351, 0.8198649, 1}, + {2.5, 0.25, 0.98885072, 0.67692377, 0.5713031, 1.64041031, 0.85958969, 1}, + {2.6, 0.25, 1.09671577, 0.69913021, 0.54757929, 1.69846629, 0.90153371, 1}, + {2.7, 0.25, 1.19177317, 0.71419745, 0.52545825, 1.75421551, 0.94578449, 1}, + {2.8, 0.25, 1.27730932, 0.72467006, 0.50488363, 1.80766749, 0.99233251, 1}, + {2.9, 0.25, 1.35545462, 0.73204727, 0.48579446, 1.85892429, 1.04107571, 1}, + {3.0, 0.25, 1.42771267, 0.73728047, 0.46811529, 1.9081657, 1.0918343, 1}, + {3.1, 0.25, 1.49522093, 0.74100442, 0.45175407, 1.95562773, 1.14437227, 1}, + {3.2, 0.25, 1.55888484, 0.74365637, 0.43660583, 2.00157893, 1.19842107, 1}, + {3.3, 0.25, 1.61944785, 0.74554338, 0.42255911, 2.04629816, 1.25370184, 1}, + {3.4, 0.25, 1.6775288, 0.74688343, 0.4095027, 2.09005636, 1.30994364, 1}, + {3.5, 0.25, 1.73364333, 0.74783222, 0.39733104, 2.13310339, 1.36689661, 1}, + {3.6, 0.25, 1.7882181, 0.74850134, 0.38594779, 2.17565984, 1.42434016, 1}, + {3.7, 0.25, 1.84160206, 0.74897093, 0.37526743, 2.21791321, 1.48208679, 1}, + {3.8, 0.25, 1.89407674, 0.74929858, 0.36521557, 2.26001739, 1.53998261, 1}, + {3.9, 0.25, 1.94586592, 0.74952569, 0.35572824, 2.30209454, 1.59790546, 1}, + {4.0, 0.25, 1.99714487, 0.74968193, 0.34675074, 2.34423832, 1.65576168, 1}, + {4.1, 0.25, 2.04804877, 0.74978857, 0.33823633, 2.38651791, 1.71348209, 1}, + {4.2, 0.25, 2.09868045, 0.74986072, 0.33014493, 2.42898213, 1.77101787, 1}, + {4.3, 0.25, 2.14911695, 0.7499091, 0.32244193, 2.47166346, 1.82833654, 1}, + {4.4, 0.25, 2.19941523, 0.74994124, 0.31509724, 2.51458157, 1.88541843, 1}, + {4.5, 0.25, 2.24961679, 0.74996237, 0.30808444, 2.55774645, 1.94225355, 1}, + {4.6, 0.25, 2.29975149, 0.74997614, 0.30138016, 2.60116094, 1.99883906, 1}, + {4.7, 0.25, 2.34984051, 0.74998502, 0.29496352, 2.64482282, 2.05517718, 1}, + {4.8, 0.25, 2.39989871, 0.74999069, 0.28881575, 2.68872639, 2.11127361, 1}, + {4.9, 0.25, 2.44993634, 0.74999427, 0.28291989, 2.73286372, 2.16713628, 1}, + {5.0, 0.25, 2.4999604, 0.74999651, 0.27726047, 2.77722553, 2.22277447, 1}, + {5.1, 0.25, 2.54997563, 0.74999789, 0.27182337, 2.82180186, 2.27819814, 1}, + {5.2, 0.25, 2.59998515, 0.74999874, 0.26659562, 2.86658252, 2.33341748, 1}, + {5.3, 0.25, 2.64999105, 0.74999925, 0.2615653, 2.9115574, 2.3884426, 1}, + {5.4, 0.25, 2.69999466, 0.74999956, 0.25672136, 2.95671665, 2.44328335, 1}, + {5.5, 0.25, 2.74999684, 0.74999975, 0.25205363, 3.00205084, 2.49794916, 1}, + {5.6, 0.25, 2.79999816, 0.74999985, 0.24755262, 3.047551, 2.552449, 1}, + {5.7, 0.25, 2.84999893, 0.74999992, 0.24320957, 3.09320863, 2.60679137, 1}, + {5.8, 0.25, 2.89999939, 0.74999995, 0.23901629, 3.13901575, 2.66098425, 1}, + {5.9, 0.25, 2.94999965, 0.74999997, 0.23496516, 3.18496485, 2.71503515, 1}, + {6.0, 0.25, 2.9999998, 0.74999999, 0.23104907, 3.23104889, 2.76895111, 1} + }, + { + {2.0, 0.3, 0.0, 0.0, 0.0, 1.0, 1.0, 0}, + {2.1, 0.3, 0.19884299, 0.20897708, 0.58948101, 1.34865205, 0.75134795, 1}, + {2.2, 0.3, 0.55937095, 0.46700161, 0.56245044, 1.41210868, 0.78789132, 1}, + {2.3, 0.3, 0.75341627, 0.55505276, 0.53741842, 1.47416043, 0.82583957, 1}, + {2.4, 0.3, 0.89872623, 0.60314368, 0.51416156, 1.5345595, 0.8654405, 1}, + {2.5, 0.3, 1.01788562, 0.63309675, 0.49251953, 1.59310755, 0.90689245, 1}, + {2.6, 0.3, 1.12027617, 0.65298432, 0.47237696, 1.64967455, 0.95032545, 1}, + {2.7, 0.3, 1.21086498, 0.66666488, 0.45364551, 1.70421082, 0.99578918, 1}, + {2.8, 0.3, 1.29265934, 0.67626845, 0.43624821, 1.75674988, 1.04325012, 1}, + {2.9, 0.3, 1.36765779, 0.68308727, 0.42010833, 1.80740165, 1.09259835, 1}, + {3.0, 0.3, 1.43728866, 0.68795774, 0.40514362, 1.85633799, 1.14366201, 1}, + {3.1, 0.3, 1.50263285, 0.69144536, 0.39126535, 1.90377369, 1.19622631, 1}, + {3.2, 0.3, 1.56454282, 0.69394346, 0.37838064, 1.94994645, 1.25005355, 1}, + {3.3, 0.3, 1.62370843, 0.69573049, 0.36639627, 1.99509869, 1.30490131, 1}, + {3.4, 0.3, 1.68069485, 0.69700563, 0.35522244, 2.03946299, 1.36053701, 1}, + {3.5, 0.3, 1.73596605, 0.69791224, 0.34477567, 2.08325171, 1.41674829, 1}, + {3.6, 0.3, 1.78990111, 0.69855388, 0.33498051, 2.12665089, 1.47334911, 1}, + {3.7, 0.3, 1.84280698, 0.69900549, 0.32577022, 2.16981758, 1.53018242, 1}, + {3.8, 0.3, 1.89492937, 0.69932134, 0.31708653, 2.21287989, 1.58712011, 1}, + {3.9, 0.3, 1.94646242, 0.69954066, 0.30887903, 2.25593887, 1.64406113, 1}, + {4.0, 0.3, 1.99755753, 0.69969177, 0.3011042, 2.29907151, 1.70092849, 1}, + {4.1, 0.3, 2.04833113, 0.69979501, 0.29372442, 2.34233421, 1.75766579, 1}, + {4.2, 0.3, 2.09887157, 0.69986492, 0.28670707, 2.38576641, 1.81423359, 1}, + {4.3, 0.3, 2.14924492, 0.69991182, 0.28002367, 2.42939394, 1.87060606, 1}, + {4.4, 0.3, 2.19950001, 0.69994298, 0.27364918, 2.47323205, 1.92676795, 1}, + {4.5, 0.3, 2.24967236, 0.69996349, 0.26756143, 2.51728802, 1.98271198, 1}, + {4.6, 0.3, 2.29978753, 0.69997685, 0.26174062, 2.56156329, 2.03843671, 1}, + {4.7, 0.3, 2.34986365, 0.69998546, 0.25616898, 2.60605517, 2.09394483, 1}, + {4.8, 0.3, 2.39991341, 0.69999096, 0.25083044, 2.65075816, 2.14924184, 1}, + {4.9, 0.3, 2.44994558, 0.69999444, 0.24571041, 2.69566498, 2.20433502, 1}, + {5.0, 0.3, 2.49996615, 0.69999661, 0.24079556, 2.7407673, 2.2592327, 1}, + {5.1, 0.3, 2.54997916, 0.69999795, 0.23607369, 2.78605629, 2.31394371, 1}, + {5.2, 0.3, 2.59998731, 0.69999878, 0.23153358, 2.83152298, 2.36847702, 1}, + {5.3, 0.3, 2.64999235, 0.69999928, 0.22716488, 2.87715849, 2.42284151, 1}, + {5.4, 0.3, 2.69999543, 0.69999958, 0.22295804, 2.92295423, 2.47704577, 1}, + {5.5, 0.3, 2.7499973, 0.69999975, 0.21890421, 2.96890196, 2.53109804, 1}, + {5.6, 0.3, 2.79999842, 0.69999986, 0.21499518, 3.01499386, 2.58500614, 1}, + {5.7, 0.3, 2.84999909, 0.69999992, 0.21122332, 3.06122256, 2.63877744, 1}, + {5.8, 0.3, 2.89999948, 0.69999995, 0.20758153, 3.10758109, 2.69241891, 1}, + {5.9, 0.3, 2.9499997, 0.69999997, 0.20406319, 3.15406295, 2.74593705, 1}, + {6.0, 0.3, 2.99999983, 0.69999999, 0.20066214, 3.200662, 2.799338, 1} + }, + { + {2.0, 0.35, 0.0, 0.0, 0.0, 1.0, 1.0, 0}, + {2.1, 0.35, 0.31058194, 0.27187533, 0.51078789, 1.31374879, 0.78625121, 1}, + {2.2, 0.35, 0.60585595, 0.44597273, 0.48736121, 1.37539954, 0.82460046, 1}, + {2.3, 0.35, 0.78639656, 0.51929824, 0.46573718, 1.43575638, 0.86424362, 1}, + {2.4, 0.35, 0.92441221, 0.56134639, 0.44571307, 1.49461136, 0.90538864, 1}, + {2.5, 0.35, 1.03850866, 0.58817989, 0.4271359, 1.55180474, 0.94819526, 1}, + {2.6, 0.35, 1.13696795, 0.606258, 0.40988816, 1.6072396, 0.9927604, 1}, + {2.7, 0.35, 1.2243415, 0.61881631, 0.39387454, 1.66089003, 1.03910997, 1}, + {2.8, 0.35, 1.30345305, 0.62769657, 0.379011, 1.71280133, 1.08719867, 1}, + {2.9, 0.35, 1.37620809, 0.63403951, 0.36521726, 1.76308249, 1.13691751, 1}, + {3.0, 0.35, 1.4439774, 0.6385938, 0.35241298, 1.81189275, 1.18810725, 1}, + {3.1, 0.35, 1.5077967, 0.64187057, 0.340517, 1.85942498, 1.24057502, 1}, + {3.2, 0.35, 1.5684766, 0.64422792, 0.32944865, 1.90588868, 1.29411132, 1}, + {3.3, 0.35, 1.62666592, 0.64592096, 0.31912979, 1.95149491, 1.34850509, 1}, + {3.4, 0.35, 1.6828899, 0.64713328, 0.30948693, 1.99644433, 1.40355567, 1}, + {3.5, 0.35, 1.73757494, 0.64799784, 0.30045264, 2.04091894, 1.45908106, 1}, + {3.6, 0.35, 1.79106612, 0.64861128, 0.29196638, 2.08507722, 1.51492278, 1}, + {3.7, 0.35, 1.84364065, 0.64904394, 0.28397461, 2.12905235, 1.57094765, 1}, + {3.8, 0.35, 1.89551908, 0.64934704, 0.27643042, 2.17295242, 1.62704758, 1}, + {3.9, 0.35, 1.94687488, 0.64955778, 0.26929297, 2.21686233, 1.68313767, 1}, + {4.0, 0.35, 1.99784283, 0.64970312, 0.26252672, 2.2608464, 1.7391536, 1}, + {4.1, 0.35, 2.04852632, 0.64980249, 0.25610068, 2.30495152, 1.79504848, 1}, + {4.2, 0.35, 2.09900367, 0.64986981, 0.24998771, 2.34921018, 1.85078982, 1}, + {4.3, 0.35, 2.14933338, 0.649915, 0.24416391, 2.3936434, 1.9063566, 1}, + {4.4, 0.35, 2.1995586, 0.64994503, 0.23860812, 2.43826334, 1.96173666, 1}, + {4.5, 0.35, 2.24971077, 0.6499648, 0.23330145, 2.48307546, 2.01692454, 1}, + {4.6, 0.35, 2.29981245, 0.64997768, 0.22822695, 2.52808038, 2.07191962, 1}, + {4.7, 0.35, 2.34987964, 0.64998598, 0.22336933, 2.57327526, 2.12672474, 1}, + {4.8, 0.35, 2.39992356, 0.64999128, 0.21871472, 2.61865497, 2.18134503, 1}, + {4.9, 0.35, 2.44995196, 0.64999463, 0.21425049, 2.66421293, 2.23578707, 1}, + {5.0, 0.35, 2.49997012, 0.64999673, 0.20996507, 2.70994171, 2.29005829, 1}, + {5.1, 0.35, 2.54998161, 0.64999803, 0.20584785, 2.75583348, 2.34416652, 1}, + {5.2, 0.35, 2.59998879, 0.64999882, 0.20188909, 2.80188033, 2.39811967, 1}, + {5.3, 0.35, 2.64999324, 0.6499993, 0.19807978, 2.84807449, 2.45192551, 1}, + {5.4, 0.35, 2.69999597, 0.64999959, 0.19441158, 2.89440843, 2.50559157, 1}, + {5.5, 0.35, 2.74999762, 0.64999976, 0.19087679, 2.94087493, 2.55912507, 1}, + {5.6, 0.35, 2.79999861, 0.64999986, 0.18746826, 2.98746717, 2.61253283, 1}, + {5.7, 0.35, 2.84999919, 0.64999992, 0.18417933, 3.0341787, 2.6658213, 1}, + {5.8, 0.35, 2.89999954, 0.64999996, 0.18100382, 3.08100346, 2.71899654, 1}, + {5.9, 0.35, 2.94999974, 0.64999998, 0.17793596, 3.12793575, 2.77206425, 1}, + {6.0, 0.35, 2.99999985, 0.64999999, 0.17497036, 3.17497024, 2.82502976, 1} + }, + { + {2.0, 0.4, 0.0, 0.0, 0.0, 1.0, 1.0, 0}, + {2.1, 0.4, 0.3745609, 0.28275455, 0.44363568, 1.28245932, 0.81754068, 1}, + {2.2, 0.4, 0.6391211, 0.41788353, 0.42330376, 1.34256272, 0.85743728, 1}, + {2.3, 0.4, 0.81053189, 0.4810026, 0.40458396, 1.40149073, 0.89850927, 1}, + {2.4, 0.4, 0.94330141, 0.51841224, 0.38729233, 1.45906826, 0.94093174, 1}, + {2.5, 0.4, 1.05367129, 0.54270452, 0.37128539, 1.51516543, 0.98483457, 1}, + {2.6, 0.4, 1.14921427, 0.55924766, 0.35644883, 1.56970904, 1.03029096, 1}, + {2.7, 0.4, 1.23420161, 0.5708252, 0.34268752, 1.62268813, 1.07731187, 1}, + {2.8, 0.4, 1.31132809, 0.57905816, 0.32991766, 1.67415274, 1.12584726, 1}, + {2.9, 0.4, 1.38243026, 0.58496622, 0.31806138, 1.72420637, 1.17579363, 1}, + {3.0, 0.4, 1.44883409, 0.5892257, 0.30704391, 1.77299382, 1.22700618, 1}, + {3.1, 0.4, 1.51153932, 0.59230176, 0.29679281, 1.82068675, 1.27931325, 1}, + {3.2, 0.4, 1.57132355, 0.59452222, 0.28723849, 1.86746919, 1.33253081, 1}, + {3.3, 0.4, 1.62880389, 0.59612178, 0.27831524, 1.91352489, 1.38647511, 1}, + {3.4, 0.4, 1.68447533, 0.59727021, 0.26996223, 1.95902743, 1.44097257, 1}, + {3.5, 0.4, 1.73873626, 0.59809108, 0.26212422, 2.00413341, 1.49586659, 1}, + {3.6, 0.4, 1.79190664, 0.59867462, 0.25475177, 2.04897866, 1.55102134, 1}, + {3.7, 0.4, 1.84424192, 0.59908684, 0.24780112, 2.09367686, 1.60632314, 1}, + {3.8, 0.4, 1.8959443, 0.59937596, 0.24123384, 2.13831999, 1.66168001, 1}, + {3.9, 0.4, 1.94717224, 0.59957718, 0.23501625, 2.18298001, 1.71701999, 1}, + {4.0, 0.4, 1.99804848, 0.59971605, 0.22911884, 2.22771125, 1.77228875, 1}, + {4.1, 0.4, 2.04866701, 0.59981104, 0.22351569, 2.27255308, 1.82744692, 1}, + {4.2, 0.4, 2.09909888, 0.59987543, 0.21818395, 2.31753265, 1.88246735, 1}, + {4.3, 0.4, 2.14939712, 0.59991865, 0.21310333, 2.36266734, 1.93733266, 1}, + {4.4, 0.4, 2.19960083, 0.59994739, 0.20825579, 2.40796699, 1.99203301, 1}, + {4.5, 0.4, 2.24973845, 0.59996631, 0.20362511, 2.45343583, 2.04656417, 1}, + {4.6, 0.4, 2.2998304, 0.59997863, 0.19919671, 2.49907395, 2.10092605, 1}, + {4.7, 0.4, 2.34989116, 0.59998658, 0.19495737, 2.54487857, 2.15512143, 1}, + {4.8, 0.4, 2.39993088, 0.59999166, 0.19089505, 2.59084501, 2.20915499, 1}, + {4.9, 0.4, 2.44995656, 0.59999486, 0.1869988, 2.63696735, 2.26303265, 1}, + {5.0, 0.4, 2.49997298, 0.59999687, 0.18325856, 2.683239, 2.316761, 1}, + {5.1, 0.4, 2.54998337, 0.59999811, 0.1796651, 2.72965305, 2.37034695, 1}, + {5.2, 0.4, 2.59998987, 0.59999887, 0.1762099, 2.77620256, 2.42379744, 1}, + {5.3, 0.4, 2.64999389, 0.59999933, 0.17288513, 2.8228807, 2.4771193, 1}, + {5.4, 0.4, 2.69999635, 0.59999961, 0.16968352, 2.86968088, 2.53031912, 1}, + {5.5, 0.4, 2.74999785, 0.59999977, 0.16659834, 2.91659678, 2.58340322, 1}, + {5.6, 0.4, 2.79999874, 0.59999987, 0.16362336, 2.96362245, 2.63637755, 1}, + {5.7, 0.4, 2.84999927, 0.59999993, 0.16075277, 3.01075224, 2.68924776, 1}, + {5.8, 0.4, 2.89999958, 0.59999996, 0.15798117, 3.05798086, 2.74201914, 1}, + {5.9, 0.4, 2.94999976, 0.59999998, 0.15530352, 3.10530335, 2.79469665, 1}, + {6.0, 0.4, 2.99999987, 0.59999999, 0.15271512, 3.15271503, 2.84728497, 1} + }, + { + {2.0, 0.45, 0.0, 0.0, 0.0, 1.0, 1.0, 0}, + {2.1, 0.45, 0.41769854, 0.2751024, 0.38511611, 1.25412247, 0.84587753, 1}, + {2.2, 0.45, 0.66376521, 0.38605432, 0.36748599, 1.31287743, 0.88712257, 1}, + {2.3, 0.45, 0.82865726, 0.44132724, 0.35128599, 1.37057701, 0.92942299, 1}, + {2.4, 0.45, 0.95752783, 0.47488055, 0.33635029, 1.42707342, 0.97292658, 1}, + {2.5, 0.45, 1.06508578, 0.49695695, 0.32254657, 1.48226033, 1.01773967, 1}, + {2.6, 0.45, 1.15841758, 0.51211685, 0.30976712, 1.53608183, 1.06391817, 1}, + {2.7, 0.45, 1.24159602, 0.52278857, 0.29792123, 1.58853624, 1.11146376, 1}, + {2.8, 0.45, 1.31722136, 0.53041163, 0.28692929, 1.63967412, 1.16032588, 1}, + {2.9, 0.45, 1.3870777, 0.53590256, 0.27671873, 1.68959112, 1.21040888, 1}, + {3.0, 0.45, 1.45245568, 0.53987437, 0.26722183, 1.73841714, 1.26158286, 1}, + {3.1, 0.45, 1.51432639, 0.5427512, 0.25837482, 1.7863038, 1.3136962, 1}, + {3.2, 0.45, 1.57344137, 0.54483338, 0.25011791, 1.83341213, 1.36658787, 1}, + {3.3, 0.45, 1.63039298, 0.5463369, 0.24239568, 1.87990181, 1.42009819, 1}, + {3.4, 0.45, 1.685653, 0.5474186, 0.23515743, 1.92592296, 1.47407704, 1}, + {3.5, 0.45, 1.7395985, 0.54819313, 0.22835743, 1.97161052, 1.52838948, 1}, + {3.6, 0.45, 1.79253049, 0.54874453, 0.22195484, 2.01708113, 1.58291887, 1}, + {3.7, 0.45, 1.84468807, 0.54913449, 0.21591352, 2.06243215, 1.63756785, 1}, + {3.8, 0.45, 1.89625977, 0.54940826, 0.21020157, 2.10774212, 1.69225788, 1}, + {3.9, 0.45, 1.94739281, 0.54959892, 0.20479094, 2.15307233, 1.74692767, 1}, + {4.0, 0.45, 1.99820102, 0.54973058, 0.19965688, 2.1984689, 1.8015311, 1}, + {4.1, 0.45, 2.04877136, 0.54982068, 0.19477754, 2.24396514, 1.85603486, 1}, + {4.2, 0.45, 2.0991695, 0.54988177, 0.19013352, 2.28958387, 1.91041613, 1}, + {4.3, 0.45, 2.1494444, 0.54992279, 0.18570755, 2.3353396, 1.9646604, 1}, + {4.4, 0.45, 2.19963215, 0.54995006, 0.18148415, 2.38124042, 2.01875958, 1}, + {4.5, 0.45, 2.24975898, 0.54996801, 0.17744937, 2.42728962, 2.07271038, 1}, + {4.6, 0.45, 2.29984372, 0.54997971, 0.17359063, 2.47348702, 2.12651298, 1}, + {4.7, 0.45, 2.34989971, 0.54998726, 0.16989648, 2.51982999, 2.18017001, 1}, + {4.8, 0.45, 2.39993631, 0.54999208, 0.16635652, 2.56631429, 2.23368571, 1}, + {4.9, 0.45, 2.44995997, 0.54999512, 0.16296121, 2.61293466, 2.28706534, 1}, + {5.0, 0.45, 2.4999751, 0.54999703, 0.15970181, 2.6596853, 2.3403147, 1}, + {5.1, 0.45, 2.54998467, 0.54999821, 0.1565703, 2.70656013, 2.39343987, 1}, + {5.2, 0.45, 2.59999066, 0.54999893, 0.15355927, 2.75355307, 2.44644693, 1}, + {5.3, 0.45, 2.64999437, 0.54999937, 0.15066188, 2.80065815, 2.49934185, 1}, + {5.4, 0.45, 2.69999664, 0.54999963, 0.14787183, 2.8478696, 2.5521304, 1}, + {5.5, 0.45, 2.74999801, 0.54999978, 0.14518324, 2.89518192, 2.60481808, 1}, + {5.6, 0.45, 2.79999884, 0.54999988, 0.14259067, 2.9425899, 2.6574101, 1}, + {5.7, 0.45, 2.84999933, 0.54999993, 0.14008908, 2.99008863, 2.70991137, 1}, + {5.8, 0.45, 2.89999962, 0.54999996, 0.13767374, 3.03767349, 2.76232651, 1}, + {5.9, 0.45, 2.94999978, 0.54999998, 0.13534029, 3.08534014, 2.81465986, 1}, + {6.0, 0.45, 2.99999988, 0.54999999, 0.13308462, 3.13308454, 2.86691546, 1} + }, + { + {2.0, 0.5, 0.0, 0.0, 0.0, 1.0, 1.0, 0}, + {2.1, 0.5, 0.44869156, 0.25849821, 0.33327761, 1.22824842, 0.87175158, 1}, + {2.2, 0.5, 0.68242228, 0.35213591, 0.31803939, 1.28581142, 0.91418858, 1}, + {2.3, 0.5, 0.84250174, 0.40090835, 0.30405912, 1.34243721, 0.95756279, 1}, + {2.4, 0.5, 0.96841366, 0.43106108, 0.29118868, 1.39800106, 1.00199894, 1}, + {2.5, 0.5, 1.0738155, 0.45110637, 0.27930804, 1.45241549, 1.04758451, 1}, + {2.6, 0.5, 1.16544657, 0.46496387, 0.26831818, 1.5056374, 1.0943626, 1}, + {2.7, 0.5, 1.24723424, 0.4747653, 0.25813518, 1.55767069, 1.14232931, 1}, + {2.8, 0.5, 1.32170772, 0.4817926, 0.24868574, 1.60856389, 1.19143611, 1}, + {2.9, 0.5, 1.39061051, 0.48687002, 0.23990401, 1.65840363, 1.24159637, 1}, + {3.0, 0.5, 1.45520527, 0.4905526, 0.23172974, 1.70730505, 1.29269495, 1}, + {3.1, 0.5, 1.51644027, 0.49322635, 0.22410735, 1.75540106, 1.34459894, 1}, + {3.2, 0.5, 1.57504634, 0.4951657, 0.21698568, 1.80283177, 1.39716823, 1}, + {3.3, 0.5, 1.63159652, 0.49656872, 0.21031794, 1.84973546, 1.45026454, 1}, + {3.4, 0.5, 1.68654452, 0.49757976, 0.20406173, 1.89624161, 1.50375839, 1}, + {3.5, 0.5, 1.740251, 0.49830468, 0.19817896, 1.94246628, 1.55753372, 1}, + {3.6, 0.5, 1.79300246, 0.49882135, 0.19263572, 1.98850956, 1.61149044, 1}, + {3.7, 0.5, 1.84502555, 0.49918708, 0.18740192, 2.03445477, 1.66554523, 1}, + {3.8, 0.5, 1.89649836, 0.49944402, 0.18245095, 2.08036902, 1.71963098, 1}, + {3.9, 0.5, 1.94755963, 0.49962306, 0.17775928, 2.12630453, 1.77369547, 1}, + {4.0, 0.5, 1.99831638, 0.49974675, 0.17330609, 2.17230053, 1.82769947, 1}, + {4.1, 0.5, 2.04885027, 0.49983142, 0.16907289, 2.21838525, 1.88161475, 1}, + {4.2, 0.5, 2.09922289, 0.49988883, 0.16504319, 2.26457795, 1.93542205, 1}, + {4.3, 0.5, 2.14948015, 0.4999274, 0.16120223, 2.31089079, 1.98910921, 1}, + {4.4, 0.5, 2.19965583, 0.49995304, 0.15753675, 2.35733046, 2.04266954, 1}, + {4.5, 0.5, 2.2497745, 0.49996992, 0.15403478, 2.40389957, 2.09610043, 1}, + {4.6, 0.5, 2.29985378, 0.49998092, 0.15068545, 2.45059776, 2.14940224, 1}, + {4.7, 0.5, 2.34990617, 0.49998802, 0.14747891, 2.49742263, 2.20257737, 1}, + {4.8, 0.5, 2.39994041, 0.49999255, 0.14440614, 2.5443704, 2.2556296, 1}, + {4.9, 0.5, 2.44996255, 0.49999541, 0.1414589, 2.59143643, 2.30856357, 1}, + {5.0, 0.5, 2.49997671, 0.4999972, 0.13862961, 2.63861563, 2.36138437, 1}, + {5.1, 0.5, 2.54998566, 0.49999831, 0.13591131, 2.68590271, 2.41409729, 1}, + {5.2, 0.5, 2.59999126, 0.49999899, 0.13329759, 2.73329235, 2.46670765, 1}, + {5.3, 0.5, 2.64999473, 0.4999994, 0.13078252, 2.78077936, 2.51922064, 1}, + {5.4, 0.5, 2.69999686, 0.49999965, 0.12836061, 2.82835872, 2.57164128, 1}, + {5.5, 0.5, 2.74999814, 0.4999998, 0.12602677, 2.87602566, 2.62397434, 1}, + {5.6, 0.5, 2.79999892, 0.49999988, 0.12377629, 2.92377564, 2.67622436, 1}, + {5.7, 0.5, 2.84999937, 0.49999993, 0.12160477, 2.9716044, 2.7283956, 1}, + {5.8, 0.5, 2.89999964, 0.49999996, 0.11950814, 3.01950792, 2.78049208, 1}, + {5.9, 0.5, 2.9499998, 0.49999998, 0.11748257, 3.06748245, 2.83251755, 1}, + {6.0, 0.5, 2.99999989, 0.49999999, 0.11552453, 3.11552446, 2.88447554, 1} + }, + { + {2.0, 0.55, 0.0, 0.0, 0.0, 1.0, 1.0, 0}, + {2.1, 0.55, 0.47169642, 0.23701703, 0.28675177, 1.20446254, 0.89553746, 1}, + {2.2, 0.55, 0.69673178, 0.31704774, 0.27365647, 1.26095901, 0.93904099, 1}, + {2.3, 0.55, 0.85318493, 0.36012574, 0.26165698, 1.3166329, 0.9833671, 1}, + {2.4, 0.55, 0.97682353, 0.38714561, 0.2506225, 1.37137916, 1.02862084, 1}, + {2.5, 0.55, 1.08055639, 0.40525985, 0.24044589, 1.42512571, 1.07487429, 1}, + {2.6, 0.55, 1.17086826, 0.41785174, 0.231038, 1.47783911, 1.12216089, 1}, + {2.7, 0.55, 1.25157764, 0.42679344, 0.22232306, 1.52952634, 1.17047366, 1}, + {2.8, 0.55, 1.32515949, 0.43322418, 0.21423513, 1.58023236, 1.21976764, 1}, + {2.9, 0.55, 1.39332559, 0.43788252, 0.20671565, 1.63003426, 1.26996574, 1}, + {3.0, 0.55, 1.45731642, 0.44126869, 0.1997118, 1.67903292, 1.32096708, 1}, + {3.1, 0.55, 1.51806205, 0.44373207, 0.19317564, 1.72734378, 1.37265622, 1}, + {3.2, 0.55, 1.57627695, 0.44552194, 0.18706359, 1.77508788, 1.42491212, 1}, + {3.3, 0.55, 1.63251889, 0.44681879, 0.18133623, 1.8223842, 1.4776158, 1}, + {3.4, 0.55, 1.68722751, 0.44775453, 0.17595809, 1.86934387, 1.53065613, 1}, + {3.5, 0.55, 1.74075075, 0.4484262, 0.17089742, 1.91606622, 1.58393378, 1}, + {3.6, 0.55, 1.79336388, 0.44890533, 0.16612598, 1.96263679, 1.63736321, 1}, + {3.7, 0.55, 1.84528394, 0.44924473, 0.16161869, 2.00912666, 1.69087334, 1}, + {3.8, 0.55, 1.89668102, 0.44948331, 0.15735329, 2.05559295, 1.74440705, 1}, + {3.9, 0.55, 1.94768732, 0.44964963, 0.15331007, 2.10208007, 1.79791993, 1}, + {4.0, 0.55, 1.99840468, 0.44976456, 0.14947148, 2.14862129, 1.85137871, 1}, + {4.1, 0.55, 2.04891066, 0.44984326, 0.14582189, 2.19524051, 1.90475949, 1}, + {4.2, 0.55, 2.09926376, 0.44989663, 0.14234731, 2.24195396, 1.95804604, 1}, + {4.3, 0.55, 2.14950751, 0.44993249, 0.13903516, 2.28877185, 2.01122815, 1}, + {4.4, 0.55, 2.19967396, 0.44995633, 0.13587414, 2.33569973, 2.06430027, 1}, + {4.5, 0.55, 2.24978638, 0.44997203, 0.13285398, 2.38273967, 2.11726033, 1}, + {4.6, 0.55, 2.29986149, 0.44998226, 0.12996538, 2.42989124, 2.17010876, 1}, + {4.7, 0.55, 2.34991112, 0.44998886, 0.12719986, 2.47715228, 2.22284772, 1}, + {4.8, 0.55, 2.39994356, 0.44999307, 0.12454968, 2.52451946, 2.27548054, 1}, + {4.9, 0.55, 2.44996452, 0.44999574, 0.12200774, 2.57198874, 2.32801126, 1}, + {5.0, 0.55, 2.49997793, 0.4499974, 0.11956751, 2.6195557, 2.3804443, 1}, + {5.1, 0.55, 2.54998642, 0.44999843, 0.11722301, 2.66721573, 2.43278427, 1}, + {5.2, 0.55, 2.59999173, 0.44999906, 0.11496869, 2.71496426, 2.48503574, 1}, + {5.3, 0.55, 2.64999501, 0.44999945, 0.11279946, 2.76279678, 2.53720322, 1}, + {5.4, 0.55, 2.69999702, 0.44999968, 0.11071057, 2.81070897, 2.58929103, 1}, + {5.5, 0.55, 2.74999824, 0.44999981, 0.10869764, 2.8586967, 2.6413033, 1}, + {5.6, 0.55, 2.79999897, 0.44999989, 0.10675661, 2.90675606, 2.69324394, 1}, + {5.7, 0.55, 2.84999941, 0.44999994, 0.10488369, 2.95488337, 2.74511663, 1}, + {5.8, 0.55, 2.89999966, 0.44999997, 0.10307535, 3.00307516, 2.79692484, 1}, + {5.9, 0.55, 2.94999981, 0.44999998, 0.10132831, 3.0513282, 2.8486718, 1}, + {6.0, 0.55, 2.99999989, 0.44999999, 0.0996395, 3.09963944, 2.90036056, 1} + }, + { + {2.0, 0.6, 0.0, 0.0, 0.0, 1.0, 1.0, 0}, + {2.1, 0.6, 0.48906653, 0.21274038, 0.24454465, 1.182471, 0.917529, 1}, + {2.2, 0.6, 0.70777594, 0.28134519, 0.23338893, 1.23800321, 0.96199679, 1}, + {2.3, 0.6, 0.86146588, 0.31922133, 0.22317659, 1.292823, 1.007177, 1}, + {2.4, 0.6, 0.98334736, 0.34325993, 0.21379372, 1.3468424, 1.0531576, 1}, + {2.5, 0.6, 1.08578332, 0.35948891, 0.20514629, 1.40000214, 1.09999786, 1}, + {2.6, 0.6, 1.17506859, 0.37082305, 0.19715562, 1.45227618, 1.14772382, 1}, + {2.7, 0.6, 1.25493924, 0.37889883, 0.18975472, 1.50367289, 1.19632711, 1}, + {2.8, 0.6, 1.32782843, 0.38472211, 0.18288555, 1.55423276, 1.24576724, 1}, + {2.9, 0.6, 1.39542311, 0.38894959, 0.17649694, 1.60402323, 1.29597677, 1}, + {3.0, 0.6, 1.45894618, 0.3920283, 0.17054328, 1.65313151, 1.34686849, 1}, + {3.1, 0.6, 1.51931329, 0.39427167, 0.16498365, 1.70165676, 1.39834324, 1}, + {3.2, 0.6, 1.57722593, 0.39590401, 0.15978122, 1.74970248, 1.45029752, 1}, + {3.3, 0.6, 1.63322992, 0.39708817, 0.15490297, 1.79737017, 1.50262983, 1}, + {3.4, 0.6, 1.68775387, 0.39794351, 0.15031931, 1.84475437, 1.55524563, 1}, + {3.5, 0.6, 1.74113581, 0.39855799, 0.14600387, 1.89193946, 1.60806054, 1}, + {3.6, 0.6, 1.79364231, 0.39899663, 0.14193319, 1.93899798, 1.66100202, 1}, + {3.7, 0.6, 1.84548298, 0.39930754, 0.13808639, 1.98599012, 1.71400988, 1}, + {3.8, 0.6, 1.89682171, 0.39952617, 0.13444496, 2.03296422, 1.76703578, 1}, + {3.9, 0.6, 1.94778568, 0.39967864, 0.13099238, 2.07995778, 1.82004222, 1}, + {4.0, 0.6, 1.99847268, 0.39978403, 0.12771397, 2.12699885, 1.87300115, 1}, + {4.1, 0.6, 2.04895718, 0.39985621, 0.12459655, 2.17410754, 1.92589246, 1}, + {4.2, 0.6, 2.09929524, 0.39990517, 0.12162834, 2.22129749, 1.97870251, 1}, + {4.3, 0.6, 2.14952859, 0.39993806, 0.1187987, 2.26857722, 2.03142278, 1}, + {4.4, 0.6, 2.19968792, 0.39995993, 0.11609803, 2.31595133, 2.08404867, 1}, + {4.5, 0.6, 2.24979553, 0.39997434, 0.11351762, 2.36342147, 2.13657853, 1}, + {4.6, 0.6, 2.29986742, 0.39998372, 0.11104955, 2.41098719, 2.18901281, 1}, + {4.7, 0.6, 2.34991493, 0.39998978, 0.10868661, 2.45864659, 2.24135341, 1}, + {4.8, 0.6, 2.39994597, 0.39999364, 0.10642219, 2.50639677, 2.29360323, 1}, + {4.9, 0.6, 2.44996605, 0.39999609, 0.10425024, 2.55423426, 2.34576574, 1}, + {5.0, 0.6, 2.49997888, 0.39999761, 0.10216519, 2.60215526, 2.39784474, 1}, + {5.1, 0.6, 2.549987, 0.39999856, 0.10016193, 2.65015581, 2.44984419, 1}, + {5.2, 0.6, 2.59999208, 0.39999914, 0.09823572, 2.69823199, 2.50176801, 1}, + {5.3, 0.6, 2.64999522, 0.39999949, 0.09638221, 2.74637996, 2.55362004, 1}, + {5.4, 0.6, 2.69999715, 0.3999997, 0.09459735, 2.794596, 2.605404, 1}, + {5.5, 0.6, 2.74999832, 0.39999983, 0.09287739, 2.8428766, 2.6571234, 1}, + {5.6, 0.6, 2.79999902, 0.3999999, 0.09121886, 2.8912184, 2.7087816, 1}, + {5.7, 0.6, 2.84999943, 0.39999994, 0.08961853, 2.93961826, 2.76038174, 1}, + {5.8, 0.6, 2.89999967, 0.39999997, 0.08807338, 2.98807323, 2.81192677, 1}, + {5.9, 0.6, 2.94999981, 0.39999998, 0.08658061, 3.03658053, 2.86341947, 1}, + {6.0, 0.6, 2.9999999, 0.39999999, 0.0851376, 3.08513755, 2.91486245, 1} + }, + { + {2.0, 0.65, 0.0, 0.0, 0.0, 1.0, 1.0, 0}, + {2.1, 0.65, 0.50227194, 0.18685737, 0.2059131, 1.16203865, 0.93796135, 1}, + {2.2, 0.65, 0.71630152, 0.24538478, 0.19652849, 1.21669115, 0.98330885, 1}, + {2.3, 0.65, 0.86787832, 0.27835719, 0.18794397, 1.27073656, 1.02926344, 1}, + {2.4, 0.65, 0.98840182, 0.29949043, 0.18006201, 1.32410194, 1.07589806, 1}, + {2.5, 0.65, 1.08983151, 0.31384341, 0.17280167, 1.37673883, 1.12326117, 1}, + {2.6, 0.65, 1.17831945, 0.32390777, 0.16609493, 1.42862696, 1.17137304, 1}, + {2.7, 0.65, 1.25753893, 0.33109978, 0.15988387, 1.47977498, 1.22022502, 1}, + {2.8, 0.65, 1.3298909, 0.33629757, 0.15411842, 1.53021841, 1.26978159, 1}, + {2.9, 0.65, 1.39704291, 0.34007799, 0.14875478, 1.5800151, 1.3199849, 1}, + {3.0, 0.65, 1.46020406, 0.34283547, 0.14375418, 1.62923927, 1.37076073, 1}, + {3.1, 0.65, 1.52027857, 0.34484751, 0.13908216, 1.67797486, 1.42202514, 1}, + {3.2, 0.65, 1.57795777, 0.34631325, 0.13470798, 1.72630923, 1.47369077, 1}, + {3.3, 0.65, 1.63377809, 0.34737763, 0.13060422, 1.77432782, 1.52567218, 1}, + {3.4, 0.65, 1.68815959, 0.34814709, 0.12674641, 1.82211008, 1.57788992, 1}, + {3.5, 0.65, 1.74143256, 0.34870026, 0.12311281, 1.86972685, 1.63027315, 1}, + {3.6, 0.65, 1.79385686, 0.34909537, 0.11968406, 1.91723894, 1.68276106, 1}, + {3.7, 0.65, 1.84563634, 0.34937554, 0.11644295, 1.96469675, 1.73530325, 1}, + {3.8, 0.65, 1.89693011, 0.34957264, 0.11337414, 2.01214072, 1.78785928, 1}, + {3.9, 0.65, 1.94786145, 0.34971012, 0.11046398, 2.05960219, 1.84039781, 1}, + {4.0, 0.65, 1.99852508, 0.34980517, 0.10770023, 2.10710457, 1.89289543, 1}, + {4.1, 0.65, 2.04899302, 0.34987027, 0.10507194, 2.15466462, 1.94533538, 1}, + {4.2, 0.65, 2.09931949, 0.34991444, 0.10256926, 2.20229368, 1.99770632, 1}, + {4.3, 0.65, 2.14954482, 0.34994411, 0.10018329, 2.24999882, 2.05000118, 1}, + {4.4, 0.65, 2.19969867, 0.34996385, 0.09790598, 2.29778379, 2.10221621, 1}, + {4.5, 0.65, 2.24980258, 0.34997684, 0.09573002, 2.34564993, 2.15435007, 1}, + {4.6, 0.65, 2.29987199, 0.34998531, 0.09364876, 2.39359682, 2.20640318, 1}, + {4.7, 0.65, 2.34991786, 0.34999078, 0.09165612, 2.44162279, 2.25837721, 1}, + {4.8, 0.65, 2.39994784, 0.34999426, 0.08974655, 2.48972538, 2.31027462, 1}, + {4.9, 0.65, 2.44996722, 0.34999647, 0.08791495, 2.53790164, 2.36209836, 1}, + {5.0, 0.65, 2.49997961, 0.34999785, 0.08615662, 2.58614835, 2.41385165, 1}, + {5.1, 0.65, 2.54998745, 0.3499987, 0.08446726, 2.63446217, 2.46553783, 1}, + {5.2, 0.65, 2.59999235, 0.34999922, 0.08284288, 2.68283978, 2.51716022, 1}, + {5.3, 0.65, 2.64999539, 0.34999954, 0.0812798, 2.73127793, 2.56872207, 1}, + {5.4, 0.65, 2.69999725, 0.34999973, 0.07977462, 2.7797735, 2.6202265, 1}, + {5.5, 0.65, 2.74999838, 0.34999984, 0.07832417, 2.82832351, 2.67167649, 1}, + {5.6, 0.65, 2.79999905, 0.34999991, 0.07692552, 2.87692514, 2.72307486, 1}, + {5.7, 0.65, 2.84999945, 0.34999995, 0.07557595, 2.92557573, 2.77442427, 1}, + {5.8, 0.65, 2.89999969, 0.34999997, 0.07427292, 2.97427279, 2.82572721, 1}, + {5.9, 0.65, 2.94999982, 0.34999998, 0.07301405, 3.02301398, 2.87698602, 1}, + {6.0, 0.65, 2.9999999, 0.34999999, 0.07179715, 3.07179711, 2.92820289, 1} + }, + { + {2.0, 0.7, 0.0, 0.0, 0.0, 1.0, 1.0, 0}, + {2.1, 0.7, 0.51229272, 0.16010177, 0.17028794, 1.14297401, 0.95702599, 1}, + {2.2, 0.7, 0.72284207, 0.20940656, 0.16253303, 1.19681765, 1.00318235, 1}, + {2.3, 0.7, 0.87280893, 0.23764642, 0.15544342, 1.25015461, 1.04984539, 1}, + {2.4, 0.7, 0.99228969, 0.25589869, 0.1489374, 1.30292528, 1.09707472, 1}, + {2.5, 0.7, 1.09294443, 0.26835944, 0.14294685, 1.35509057, 1.14490943, 1}, + {2.6, 0.7, 1.18081789, 0.27712777, 0.13741441, 1.40663477, 1.19336523, 1}, + {2.7, 0.7, 1.25953571, 0.28340972, 0.13229122, 1.45756607, 1.24243393, 1}, + {2.8, 0.7, 1.33147413, 0.2879588, 0.12753515, 1.50791472, 1.29208528, 1}, + {2.9, 0.7, 1.3982857, 0.29127271, 0.12310949, 1.5577293, 1.3422707, 1}, + {3.0, 0.7, 1.46116873, 0.29369318, 0.118982, 1.60707164, 1.39292836, 1}, + {3.1, 0.7, 1.52101859, 0.29546133, 0.11512419, 1.65601145, 1.44398855, 1}, + {3.2, 0.7, 1.57851867, 0.29675066, 0.1115108, 1.7046211, 1.4953789, 1}, + {3.3, 0.7, 1.63419814, 0.29768771, 0.10811942, 1.75297123, 1.54702877, 1}, + {3.4, 0.7, 1.68847042, 0.29836559, 0.10493011, 1.80112746, 1.59887254, 1}, + {3.5, 0.7, 1.74165989, 0.2988532, 0.10192518, 1.84914821, 1.65085179, 1}, + {3.6, 0.7, 1.7940212, 0.29920163, 0.09908887, 1.8970836, 1.7029164, 1}, + {3.7, 0.7, 1.84575381, 0.2994488, 0.09640717, 1.94497514, 1.75502486, 1}, + {3.8, 0.7, 1.89701313, 0.29962272, 0.09386759, 1.9928561, 1.8071439, 1}, + {3.9, 0.7, 1.94791949, 0.29974406, 0.09145896, 2.04075224, 1.85924776, 1}, + {4.0, 0.7, 1.9985652, 0.29982797, 0.08917128, 2.0886828, 1.9113172, 1}, + {4.1, 0.7, 2.04902046, 0.29988545, 0.08699555, 2.13666152, 1.96333848, 1}, + {4.2, 0.7, 2.09933806, 0.29992445, 0.08492368, 2.18469769, 2.01530231, 1}, + {4.3, 0.7, 2.14955725, 0.29995065, 0.08294835, 2.23279707, 2.06720293, 1}, + {4.4, 0.7, 2.19970691, 0.29996807, 0.08106292, 2.28096272, 2.11903728, 1}, + {4.5, 0.7, 2.24980798, 0.29997955, 0.07926137, 2.3291957, 2.1708043, 1}, + {4.6, 0.7, 2.29987549, 0.29998703, 0.0775382, 2.37749561, 2.22250439, 1}, + {4.7, 0.7, 2.34992011, 0.29999185, 0.07588839, 2.42586105, 2.27413895, 1}, + {4.8, 0.7, 2.39994926, 0.29999493, 0.07430734, 2.47428998, 2.32571002, 1}, + {4.9, 0.7, 2.44996811, 0.29999688, 0.07279084, 2.52277993, 2.37722007, 1}, + {5.0, 0.7, 2.49998017, 0.2999981, 0.07133501, 2.57132822, 2.42867178, 1}, + {5.1, 0.7, 2.54998779, 0.29999885, 0.06993628, 2.6199321, 2.4800679, 1}, + {5.2, 0.7, 2.59999256, 0.29999931, 0.06859134, 2.6685888, 2.5314112, 1}, + {5.3, 0.7, 2.64999552, 0.29999959, 0.06729716, 2.71729563, 2.58270437, 1}, + {5.4, 0.7, 2.69999732, 0.29999976, 0.06605092, 2.76605, 2.63395, 1}, + {5.5, 0.7, 2.74999842, 0.29999986, 0.06484999, 2.81484945, 2.68515055, 1}, + {5.6, 0.7, 2.79999908, 0.29999992, 0.06369196, 2.86369164, 2.73630836, 1}, + {5.7, 0.7, 2.84999947, 0.29999996, 0.06257455, 2.91257437, 2.78742563, 1}, + {5.8, 0.7, 2.89999969, 0.29999997, 0.06149568, 2.96149558, 2.83850442, 1}, + {5.9, 0.7, 2.94999983, 0.29999999, 0.06045338, 3.01045332, 2.88954668, 1}, + {6.0, 0.7, 2.9999999, 0.29999999, 0.05944582, 3.05944579, 2.94055421, 1} + }, + { + {2.0, 0.75, 0.0, 0.0, 0.0, 1.0, 1.0, 0}, + {2.1, 0.75, 0.51981368, 0.13295289, 0.13722413, 1.125119, 0.974881, 1}, + {2.2, 0.75, 0.72778973, 0.17357835, 0.13097888, 1.17821381, 1.02178619, 1}, + {2.3, 0.75, 0.87654496, 0.19717057, 0.12527195, 1.23089748, 1.06910252, 1}, + {2.4, 0.75, 0.99523635, 0.21253004, 0.12003684, 1.28312242, 1.11687758, 1}, + {2.5, 0.75, 1.09530319, 0.22306389, 0.11521794, 1.33485774, 1.16514226, 1}, + {2.6, 0.75, 1.18271025, 0.23049952, 0.11076834, 1.38609143, 1.21390857, 1}, + {2.7, 0.75, 1.2610474, 0.23583882, 0.10664807, 1.43683072, 1.26316928, 1}, + {2.8, 0.75, 1.3326722, 0.23971204, 0.10282274, 1.48710054, 1.31289946, 1}, + {2.9, 0.75, 1.39922577, 0.24253756, 0.09926251, 1.53694045, 1.36305955, 1}, + {3.0, 0.75, 1.46189819, 0.24460369, 0.09594127, 1.5864006, 1.4135994, 1}, + {3.1, 0.75, 1.52157802, 0.24611445, 0.09283609, 1.63553732, 1.46446268, 1}, + {3.2, 0.75, 1.5789426, 0.247217, 0.08992672, 1.684409, 1.515591, 1}, + {3.3, 0.75, 1.63451556, 0.24801884, 0.08719526, 1.73307254, 1.56692746, 1}, + {3.4, 0.75, 1.68870529, 0.24859923, 0.08462581, 1.78158078, 1.61841922, 1}, + {3.5, 0.75, 1.74183164, 0.24901691, 0.08220431, 1.8299807, 1.6700193, 1}, + {3.6, 0.75, 1.79414536, 0.24931548, 0.07991822, 1.87831257, 1.72168743, 1}, + {3.7, 0.75, 1.84584254, 0.24952733, 0.07775637, 1.92660975, 1.77339025, 1}, + {3.8, 0.75, 1.89707585, 0.24967644, 0.07570882, 1.97489894, 1.82510106, 1}, + {3.9, 0.75, 1.94796333, 0.24978049, 0.07376664, 2.02320079, 1.87679921, 1}, + {4.0, 0.75, 1.99859551, 0.24985245, 0.07192184, 2.07153074, 1.92846926, 1}, + {4.1, 0.75, 2.04904119, 0.24990174, 0.07016723, 2.11989979, 1.98010021, 1}, + {4.2, 0.75, 2.09935208, 0.24993519, 0.06849629, 2.16831535, 2.03168465, 1}, + {4.3, 0.75, 2.14956665, 0.24995766, 0.06690317, 2.21678205, 2.08321795, 1}, + {4.4, 0.75, 2.19971313, 0.24997261, 0.06538252, 2.26530229, 2.13469771, 1}, + {4.5, 0.75, 2.24981206, 0.24998246, 0.06392949, 2.31387691, 2.18612309, 1}, + {4.6, 0.75, 2.29987814, 0.24998887, 0.06253967, 2.36250557, 2.23749443, 1}, + {4.7, 0.75, 2.3499218, 0.24999301, 0.06120901, 2.41118712, 2.28881288, 1}, + {4.8, 0.75, 2.39995034, 0.24999565, 0.0599338, 2.4599199, 2.3400801, 1}, + {4.9, 0.75, 2.44996879, 0.24999732, 0.05871065, 2.50870191, 2.39129809, 1}, + {5.0, 0.75, 2.49998059, 0.24999837, 0.05753643, 2.55753099, 2.44246901, 1}, + {5.1, 0.75, 2.54998805, 0.24999902, 0.05640826, 2.60640491, 2.49359509, 1}, + {5.2, 0.75, 2.59999272, 0.24999941, 0.05532348, 2.65532144, 2.54467856, 1}, + {5.3, 0.75, 2.64999561, 0.24999965, 0.05427964, 2.70427841, 2.59572159, 1}, + {5.4, 0.75, 2.69999738, 0.2499998, 0.05327446, 2.75327373, 2.64672627, 1}, + {5.5, 0.75, 2.74999845, 0.24999988, 0.05230583, 2.8023054, 2.6976946, 1}, + {5.6, 0.75, 2.79999909, 0.24999993, 0.0513718, 2.85137155, 2.74862845, 1}, + {5.7, 0.75, 2.84999948, 0.24999996, 0.05047054, 2.90047039, 2.79952961, 1}, + {5.8, 0.75, 2.8999997, 0.24999998, 0.04960036, 2.94960027, 2.85039973, 1}, + {5.9, 0.75, 2.94999983, 0.24999999, 0.04875967, 2.99875963, 2.90124037, 1}, + {6.0, 0.75, 2.9999999, 0.24999999, 0.04794701, 3.04794699, 2.95205301, 1} + }, + { + {2.0, 0.8, 0.0, 0.0, 0.0, 1.0, 1.0, 0}, + {2.1, 0.8, 0.52533068, 0.10573781, 0.10636754, 1.10834143, 0.99165857, 1}, + {2.2, 0.8, 0.7314396, 0.1380212, 0.10152896, 1.16073883, 1.03926117, 1}, + {2.3, 0.8, 0.87930435, 0.15699011, 0.09710892, 1.2128159, 1.0871841, 1}, + {2.4, 0.8, 0.99741311, 0.16941871, 0.09305546, 1.26453602, 1.13546398, 1}, + {2.5, 0.8, 1.09704534, 0.17797735, 0.08932508, 1.31587559, 1.18412441, 1}, + {2.6, 0.8, 1.18410747, 0.18403573, 0.08588102, 1.36682569, 1.23317431, 1}, + {2.7, 0.8, 1.26216316, 0.18839498, 0.08269196, 1.41739228, 1.28260772, 1}, + {2.8, 0.8, 1.33355619, 0.19156218, 0.07973099, 1.467595, 1.332405, 1}, + {2.9, 0.8, 1.3999192, 0.19387549, 0.07697483, 1.51746477, 1.38253523, 1}, + {3.0, 0.8, 1.46243613, 0.19556877, 0.07440318, 1.56704062, 1.43295938, 1}, + {3.1, 0.8, 1.52199049, 0.19680792, 0.07199828, 1.61636631, 1.48363369, 1}, + {3.2, 0.8, 1.57925511, 0.19771285, 0.06974449, 1.66548713, 1.53451287, 1}, + {3.3, 0.8, 1.63474953, 0.19837135, 0.06762804, 1.71444721, 1.58555279, 1}, + {3.4, 0.8, 1.68887839, 0.1988482, 0.06563671, 1.76328744, 1.63671256, 1}, + {3.5, 0.8, 1.74195822, 0.19919149, 0.06375969, 1.81204421, 1.68795579, 1}, + {3.6, 0.8, 1.79423686, 0.19943696, 0.06198736, 1.86074866, 1.73925134, 1}, + {3.7, 0.8, 1.84590794, 0.19961117, 0.06031114, 1.90942658, 1.79057342, 1}, + {3.8, 0.8, 1.89712206, 0.19973381, 0.05872339, 1.95809861, 1.84190139, 1}, + {3.9, 0.8, 1.94799563, 0.1998194, 0.05721723, 2.00678071, 1.89321929, 1}, + {4.0, 0.8, 1.99861784, 0.1998786, 0.05578651, 2.05548479, 1.94451521, 1}, + {4.1, 0.8, 2.04905646, 0.19991915, 0.05442566, 2.10421935, 1.99578065, 1}, + {4.2, 0.8, 2.09936242, 0.19994667, 0.05312968, 2.1529901, 2.0470099, 1}, + {4.3, 0.8, 2.14957356, 0.19996517, 0.05189402, 2.20180058, 2.09819942, 1}, + {4.4, 0.8, 2.19971771, 0.19997747, 0.05071455, 2.25065266, 2.14934734, 1}, + {4.5, 0.8, 2.24981506, 0.19998556, 0.04958752, 2.29954696, 2.20045304, 1}, + {4.6, 0.8, 2.29988009, 0.19999084, 0.04850951, 2.3484832, 2.2515168, 1}, + {4.7, 0.8, 2.34992305, 0.19999425, 0.04747738, 2.39746049, 2.30253951, 1}, + {4.8, 0.8, 2.39995114, 0.19999642, 0.04648826, 2.44647753, 2.35352247, 1}, + {4.9, 0.8, 2.44996929, 0.1999978, 0.04553951, 2.49553277, 2.40446723, 1}, + {5.0, 0.8, 2.4999809, 0.19999866, 0.04462872, 2.54462452, 2.45537548, 1}, + {5.1, 0.8, 2.54998824, 0.19999919, 0.04375364, 2.59375106, 2.50624894, 1}, + {5.2, 0.8, 2.59999284, 0.19999952, 0.04291222, 2.64291065, 2.55708935, 1}, + {5.3, 0.8, 2.64999568, 0.19999971, 0.04210256, 2.69210161, 2.60789839, 1}, + {5.4, 0.8, 2.69999742, 0.19999983, 0.04132288, 2.74132231, 2.65867769, 1}, + {5.5, 0.8, 2.74999848, 0.1999999, 0.04057156, 2.79057122, 2.70942878, 1}, + {5.6, 0.8, 2.79999911, 0.19999994, 0.03984706, 2.83984687, 2.76015313, 1}, + {5.7, 0.8, 2.84999949, 0.19999997, 0.03914799, 2.88914788, 2.81085212, 1}, + {5.8, 0.8, 2.89999971, 0.19999998, 0.03847303, 2.93847296, 2.86152704, 1}, + {5.9, 0.8, 2.94999983, 0.19999999, 0.03782094, 2.9878209, 2.9121791, 1}, + {6.0, 0.8, 2.99999991, 0.19999999, 0.03719059, 3.03719057, 2.96280943, 1} + }, + { + {2.0, 0.85, 0.0, 0.0, 0.0, 1.0, 1.0, 0}, + {2.1, 0.85, 0.52921303, 0.07868755, 0.077432, 1.0925296, 1.0074704, 1}, + {2.2, 0.85, 0.73401824, 0.10282463, 0.07391092, 1.14427403, 1.05572597, 1}, + {2.3, 0.85, 0.88125553, 0.11715093, 0.07069517, 1.19578443, 1.10421557, 1}, + {2.4, 0.85, 0.99895249, 0.12659116, 0.06774673, 1.24703427, 1.15296573, 1}, + {2.5, 0.85, 1.09827719, 0.13311595, 0.0650337, 1.29800656, 1.20199344, 1}, + {2.6, 0.85, 1.1850952, 0.13774646, 0.06252912, 1.34869499, 1.25130501, 1}, + {2.7, 0.85, 1.26295173, 0.14108448, 0.06021002, 1.3991041, 1.3008959, 1}, + {2.8, 0.85, 1.3341808, 0.1435131, 0.0580567, 1.44924835, 1.35075165, 1}, + {2.9, 0.85, 1.40040905, 0.1452889, 0.05605211, 1.49915037, 1.40084963, 1}, + {3.0, 0.85, 1.46281608, 0.14658985, 0.05418146, 1.54883865, 1.45116135, 1}, + {3.1, 0.85, 1.52228177, 0.14754257, 0.05243182, 1.59834511, 1.50165489, 1}, + {3.2, 0.85, 1.57947578, 0.14823872, 0.05079184, 1.64770274, 1.55229726, 1}, + {3.3, 0.85, 1.63491472, 0.14874552, 0.04925155, 1.69694365, 1.60305635, 1}, + {3.4, 0.85, 1.68900059, 0.14911265, 0.0478021, 1.7460976, 1.6539024, 1}, + {3.5, 0.85, 1.74204757, 0.14937703, 0.04643567, 1.79519104, 1.70480896, 1}, + {3.6, 0.85, 1.79430144, 0.14956613, 0.04514532, 1.84424662, 1.75575338, 1}, + {3.7, 0.85, 1.8459541, 0.14970035, 0.04392484, 1.89328307, 1.80671693, 1}, + {3.8, 0.85, 1.89715469, 0.14979485, 0.04276868, 1.9423154, 1.8576846, 1}, + {3.9, 0.85, 1.94801843, 0.1498608, 0.04167188, 1.99135519, 1.90864481, 1}, + {4.0, 0.85, 1.99863361, 0.14990643, 0.04062997, 2.04041108, 1.95958892, 1}, + {4.1, 0.85, 2.04906725, 0.14993768, 0.03963892, 2.08948924, 2.01051076, 1}, + {4.2, 0.85, 2.09936972, 0.1499589, 0.03869508, 2.13859382, 2.06140618, 1}, + {4.3, 0.85, 2.14957845, 0.14997315, 0.03779516, 2.18772738, 2.11227262, 1}, + {4.4, 0.85, 2.19972095, 0.14998263, 0.03693616, 2.23689126, 2.16310874, 1}, + {4.5, 0.85, 2.24981718, 0.14998887, 0.03611534, 2.28608592, 2.21391408, 1}, + {4.6, 0.85, 2.29988146, 0.14999294, 0.03533022, 2.33531113, 2.26468887, 1}, + {4.7, 0.85, 2.34992394, 0.14999557, 0.03457851, 2.38456626, 2.31543374, 1}, + {4.8, 0.85, 2.3999517, 0.14999724, 0.03385812, 2.43385034, 2.36614966, 1}, + {4.9, 0.85, 2.44996964, 0.1499983, 0.03316713, 2.48316224, 2.41683776, 1}, + {5.0, 0.85, 2.49998112, 0.14999897, 0.03250379, 2.53250075, 2.46749925, 1}, + {5.1, 0.85, 2.54998838, 0.14999938, 0.03186646, 2.58186459, 2.51813541, 1}, + {5.2, 0.85, 2.59999292, 0.14999963, 0.03125364, 2.6312525, 2.5687475, 1}, + {5.3, 0.85, 2.64999573, 0.14999978, 0.03066395, 2.68066326, 2.61933674, 1}, + {5.4, 0.85, 2.69999745, 0.14999987, 0.0300961, 2.73009569, 2.66990431, 1}, + {5.5, 0.85, 2.7499985, 0.14999993, 0.0295489, 2.77954865, 2.72045135, 1}, + {5.6, 0.85, 2.79999912, 0.14999996, 0.02902124, 2.8290211, 2.7709789, 1}, + {5.7, 0.85, 2.84999949, 0.14999998, 0.02851209, 2.87851201, 2.82148799, 1}, + {5.8, 0.85, 2.89999971, 0.14999999, 0.02802051, 2.92802046, 2.87197954, 1}, + {5.9, 0.85, 2.94999983, 0.14999999, 0.02754558, 2.97754555, 2.92245445, 1}, + {6.0, 0.85, 2.99999991, 0.15, 0.02708649, 3.02708647, 2.97291353, 1} + }, + { + {2.0, 0.9, 0.0, 0.0, 0.0, 1.0, 1.0, 0}, + {2.1, 0.9, 0.53174219, 0.05196985, 0.05018313, 1.07758824, 1.02241176, 1}, + {2.2, 0.9, 0.73570255, 0.06805622, 0.04790169, 1.12871845, 1.07128155, 1}, + {2.3, 0.9, 0.88253073, 0.07768852, 0.04581839, 1.17969662, 1.12030338, 1}, + {2.4, 0.9, 0.99995864, 0.08406824, 0.04390853, 1.23050563, 1.16949437, 1}, + {2.5, 0.9, 1.09908227, 0.08849258, 0.04215132, 1.28113454, 1.21886546, 1}, + {2.6, 0.9, 1.18574063, 0.09163981, 0.04052922, 1.33157934, 1.26842066, 1}, + {2.7, 0.9, 1.26346692, 0.09391243, 0.03902727, 1.38184306, 1.31815694, 1}, + {2.8, 0.9, 1.3345888, 0.09556801, 0.03763263, 1.43193513, 1.36806487, 1}, + {2.9, 0.9, 1.40072899, 0.09677973, 0.03633423, 1.48187028, 1.41812972, 1}, + {3.0, 0.9, 1.4630642, 0.09766812, 0.03512248, 1.53166701, 1.46833299, 1}, + {3.1, 0.9, 1.52247197, 0.09831909, 0.03398899, 1.58134601, 1.51865399, 1}, + {3.2, 0.9, 1.57961986, 0.09879498, 0.03292643, 1.6309287, 1.5690713, 1}, + {3.3, 0.9, 1.63502258, 0.09914156, 0.03192835, 1.68043586, 1.61956414, 1}, + {3.4, 0.9, 1.68908038, 0.0993927, 0.03098904, 1.72988679, 1.67011321, 1}, + {3.5, 0.9, 1.74210591, 0.0995736, 0.03010346, 1.7792986, 1.7207014, 1}, + {3.6, 0.9, 1.79434361, 0.099703, 0.02926713, 1.82868597, 1.77131403, 1}, + {3.7, 0.9, 1.84598423, 0.09979487, 0.02847603, 1.87806103, 1.82193897, 1}, + {3.8, 0.9, 1.89717598, 0.09985955, 0.0277266, 1.92743348, 1.87256652, 1}, + {3.9, 0.9, 1.94803332, 0.0999047, 0.02701561, 1.97681082, 1.92318918, 1}, + {4.0, 0.9, 1.9986439, 0.09993593, 0.02634019, 2.02619865, 1.97380135, 1}, + {4.1, 0.9, 2.04907429, 0.09995734, 0.02569773, 2.07560094, 2.02439906, 1}, + {4.2, 0.9, 2.09937448, 0.09997186, 0.02508586, 2.12502038, 2.07497962, 1}, + {4.3, 0.9, 2.14958164, 0.09998162, 0.02450246, 2.17445863, 2.12554137, 1}, + {4.4, 0.9, 2.19972306, 0.09998811, 0.02394558, 2.22391655, 2.17608345, 1}, + {4.5, 0.9, 2.24981857, 0.09999238, 0.02341345, 2.27339443, 2.22660557, 1}, + {4.6, 0.9, 2.29988236, 0.09999517, 0.02290446, 2.32289212, 2.27710788, 1}, + {4.7, 0.9, 2.34992451, 0.09999697, 0.02241713, 2.37240921, 2.32759079, 1}, + {4.8, 0.9, 2.39995206, 0.09999811, 0.02195011, 2.42194508, 2.37805492, 1}, + {4.9, 0.9, 2.44996987, 0.09999884, 0.02150215, 2.47149898, 2.42850102, 1}, + {5.0, 0.9, 2.49998126, 0.09999929, 0.0210721, 2.52107014, 2.47892986, 1}, + {5.1, 0.9, 2.54998847, 0.09999957, 0.02065892, 2.57065771, 2.52934229, 1}, + {5.2, 0.9, 2.59999297, 0.09999974, 0.02026164, 2.6202609, 2.5797391, 1}, + {5.3, 0.9, 2.64999576, 0.09999985, 0.01987934, 2.6698789, 2.6301211, 1}, + {5.4, 0.9, 2.69999747, 0.09999991, 0.01951121, 2.71951094, 2.68048906, 1}, + {5.5, 0.9, 2.74999851, 0.09999995, 0.01915646, 2.7691563, 2.7308437, 1}, + {5.6, 0.9, 2.79999913, 0.09999997, 0.01881438, 2.81881429, 2.78118571, 1}, + {5.7, 0.9, 2.8499995, 0.09999998, 0.0184843, 2.86848425, 2.83151575, 1}, + {5.8, 0.9, 2.89999971, 0.09999999, 0.01816561, 2.91816558, 2.88183442, 1}, + {5.9, 0.9, 2.94999984, 0.09999999, 0.01785771, 2.9678577, 2.9321423, 1}, + {6.0, 0.9, 2.99999991, 0.1, 0.01756009, 3.01756008, 2.98243992, 1} + }, + { + {2.0, 0.95, 0.0, 0.0, 0.0, 1.0, 1.0, 0}, + {2.1, 0.95, 0.53313676, 0.02570928, 0.0244267, 1.06343538, 1.03656462, 1}, + {2.2, 0.95, 0.73663277, 0.03376787, 0.02331635, 1.11398541, 1.08601459, 1}, + {2.3, 0.95, 0.88323525, 0.03863079, 0.02230253, 1.16446135, 1.13553865, 1}, + {2.4, 0.95, 1.00051454, 0.04186666, 0.02137316, 1.21485487, 1.18514513, 1}, + {2.5, 0.95, 1.09952705, 0.04411771, 0.02051814, 1.26516066, 1.23483934, 1}, + {2.6, 0.95, 1.18609718, 0.04572246, 0.01972888, 1.3153768, 1.2846232, 1}, + {2.7, 0.95, 1.26375149, 0.04688308, 0.01899808, 1.36550478, 1.33449522, 1}, + {2.8, 0.95, 1.33481415, 0.04772955, 0.01831948, 1.41554921, 1.38445079, 1}, + {2.9, 0.95, 1.40090568, 0.04834963, 0.01768769, 1.46551728, 1.43448272, 1}, + {3.0, 0.95, 1.46320121, 0.04880455, 0.01709803, 1.515418, 1.484582, 1}, + {3.1, 0.95, 1.522577, 0.04913807, 0.01654642, 1.56526145, 1.53473855, 1}, + {3.2, 0.95, 1.57969942, 0.04938198, 0.0160293, 1.61505805, 1.58494195, 1}, + {3.3, 0.95, 1.63508212, 0.04955967, 0.01554353, 1.66481793, 1.63518207, 1}, + {3.4, 0.95, 1.68912443, 0.04968846, 0.01508634, 1.71455046, 1.68544954, 1}, + {3.5, 0.95, 1.74213812, 0.04978124, 0.01465528, 1.76426399, 1.73573601, 1}, + {3.6, 0.95, 1.79436689, 0.04984762, 0.01424817, 1.81396564, 1.78603436, 1}, + {3.7, 0.95, 1.84600087, 0.04989475, 0.01386308, 1.86366132, 1.83633868, 1}, + {3.8, 0.95, 1.89718774, 0.04992793, 0.01349825, 1.91335575, 1.88664425, 1}, + {3.9, 0.95, 1.94804153, 0.0499511, 0.01315214, 1.96305258, 1.93694742, 1}, + {4.0, 0.95, 1.99864958, 0.04996713, 0.01282333, 2.01275452, 1.98724548, 1}, + {4.1, 0.95, 2.04907817, 0.04997811, 0.01251056, 2.06246351, 2.03753649, 1}, + {4.2, 0.95, 2.09937711, 0.04998556, 0.01221269, 2.11218086, 2.08781914, 1}, + {4.3, 0.95, 2.1495834, 0.04999057, 0.01192868, 2.16190737, 2.13809263, 1}, + {4.4, 0.95, 2.19972423, 0.0499939, 0.01165757, 2.21164345, 2.18835655, 1}, + {4.5, 0.95, 2.24981933, 0.04999609, 0.01139851, 2.26138926, 2.23861074, 1}, + {4.6, 0.95, 2.29988286, 0.04999752, 0.01115072, 2.31114472, 2.28885528, 1}, + {4.7, 0.95, 2.34992483, 0.04999844, 0.01091347, 2.36090962, 2.33909038, 1}, + {4.8, 0.95, 2.39995227, 0.04999903, 0.0106861, 2.41068366, 2.38931634, 1}, + {4.9, 0.95, 2.44997, 0.0499994, 0.01046802, 2.46046648, 2.43953352, 1}, + {5.0, 0.95, 2.49998134, 0.04999964, 0.01025866, 2.5102577, 2.4897423, 1}, + {5.1, 0.95, 2.54998851, 0.04999978, 0.01005751, 2.56005692, 2.53994308, 1}, + {5.2, 0.95, 2.599993, 0.04999987, 0.0098641, 2.60986374, 2.59013626, 1}, + {5.3, 0.95, 2.64999578, 0.04999992, 0.00967798, 2.65967776, 2.64032224, 1}, + {5.4, 0.95, 2.69999748, 0.04999995, 0.00949876, 2.70949863, 2.69050137, 1}, + {5.5, 0.95, 2.74999851, 0.04999997, 0.00932605, 2.75932598, 2.74067402, 1}, + {5.6, 0.95, 2.79999913, 0.04999998, 0.00915952, 2.80915947, 2.79084053, 1}, + {5.7, 0.95, 2.8499995, 0.04999999, 0.00899882, 2.8589988, 2.8410012, 1}, + {5.8, 0.95, 2.89999971, 0.05, 0.00884367, 2.90884366, 2.89115634, 1}, + {5.9, 0.95, 2.94999984, 0.05, 0.00869378, 2.95869377, 2.94130623, 1}, + {6.0, 0.95, 2.99999991, 0.05, 0.00854888, 3.00854888, 2.99145112, 1} + }, + { + {2.0, 1.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1}, + {2.1, 1.0, 0.53356933, 0.0, 0.0, 1.05, 1.05, 1}, + {2.2, 1.0, 0.73692151, 0.0, 0.0, 1.1, 1.1, 1}, + {2.3, 1.0, 0.88345397, 0.0, 0.0, 1.15, 1.15, 1}, + {2.4, 1.0, 1.00068713, 0.0, 0.0, 1.2, 1.2, 1}, + {2.5, 1.0, 1.09966513, 0.0, 0.0, 1.25, 1.25, 1}, + {2.6, 1.0, 1.18620786, 0.0, 0.0, 1.3, 1.3, 1}, + {2.7, 1.0, 1.26383982, 0.0, 0.0, 1.35, 1.35, 1}, + {2.8, 1.0, 1.33488409, 0.0, 0.0, 1.4, 1.4, 1}, + {2.9, 1.0, 1.40096052, 0.0, 0.0, 1.45, 1.45, 1}, + {3.0, 1.0, 1.46324374, 0.0, 0.0, 1.5, 1.5, 1}, + {3.1, 1.0, 1.5226096, 0.0, 0.0, 1.55, 1.55, 1}, + {3.2, 1.0, 1.57972411, 0.0, 0.0, 1.6, 1.6, 1}, + {3.3, 1.0, 1.63510061, 0.0, 0.0, 1.65, 1.65, 1}, + {3.4, 1.0, 1.6891381, 0.0, 0.0, 1.7, 1.7, 1}, + {3.5, 1.0, 1.74214811, 0.0, 0.0, 1.75, 1.75, 1}, + {3.6, 1.0, 1.79437411, 0.0, 0.0, 1.8, 1.8, 1}, + {3.7, 1.0, 1.84600603, 0.0, 0.0, 1.85, 1.85, 1}, + {3.8, 1.0, 1.89719139, 0.0, 0.0, 1.9, 1.9, 1}, + {3.9, 1.0, 1.94804408, 0.0, 0.0, 1.95, 1.95, 1}, + {4.0, 1.0, 1.99865135, 0.0, 0.0, 2.0, 2.0, 1}, + {4.1, 1.0, 2.04907938, 0.0, 0.0, 2.05, 2.05, 1}, + {4.2, 1.0, 2.09937793, 0.0, 0.0, 2.1, 2.1, 1}, + {4.3, 1.0, 2.14958394, 0.0, 0.0, 2.15, 2.15, 1}, + {4.4, 1.0, 2.19972459, 0.0, 0.0, 2.2, 2.2, 1}, + {4.5, 1.0, 2.24981957, 0.0, 0.0, 2.25, 2.25, 1}, + {4.6, 1.0, 2.29988301, 0.0, 0.0, 2.3, 2.3, 1}, + {4.7, 1.0, 2.34992493, 0.0, 0.0, 2.35, 2.35, 1}, + {4.8, 1.0, 2.39995233, 0.0, 0.0, 2.4, 2.4, 1}, + {4.9, 1.0, 2.44997004, 0.0, 0.0, 2.45, 2.45, 1}, + {5.0, 1.0, 2.49998137, 0.0, 0.0, 2.5, 2.5, 1}, + {5.1, 1.0, 2.54998853, 0.0, 0.0, 2.55, 2.55, 1}, + {5.2, 1.0, 2.59999301, 0.0, 0.0, 2.6, 2.6, 1}, + {5.3, 1.0, 2.64999579, 0.0, 0.0, 2.65, 2.65, 1}, + {5.4, 1.0, 2.69999749, 0.0, 0.0, 2.7, 2.7, 1}, + {5.5, 1.0, 2.74999852, 0.0, 0.0, 2.75, 2.75, 1}, + {5.6, 1.0, 2.79999913, 0.0, 0.0, 2.8, 2.8, 1}, + {5.7, 1.0, 2.8499995, 0.0, 0.0, 2.85, 2.85, 1}, + {5.8, 1.0, 2.89999971, 0.0, 0.0, 2.9, 2.9, 1}, + {5.9, 1.0, 2.94999984, 0.0, 0.0, 2.95, 2.95, 1}, + {6.0, 1.0, 2.99999991, 0.0, 0.0, 3.0, 3.0, 1} + } + }; + // @formatter:on + + // Columns of the correction table. + private static final int WIDTH = 2, ASYMMETRY = 3, CORRECTION = 4, + WIDTH_STRONG = 5, WIDTH_WEAK = 6, VALID = 7; + + /** + * Interpolates bilinearly the given column of the correction table, between + * rows {@code iRe} and {@code iRe + 1} with parameter {@code b} and between + * {@code iWe} and {@code iWe + 1} with parameter {@code a}. + */ + private static double bilinear(final double a, final double b, final int iRe, + final int iWe, final int column) + { + final double[][] lo = CORRECTION_TABLE[iRe], hi = CORRECTION_TABLE[iRe + 1]; + return (1 - b) * ((1 - a) * lo[iWe][column] + a * lo[iWe + 1][column]) + b * + ((1 - a) * hi[iWe][column] + a * hi[iWe + 1][column]); + } + + private static boolean isValid(final int iRe, final int iWe) { + return CORRECTION_TABLE[iRe][iWe][VALID] == 1; + } + + /** + * Computes the true line width and asymmetry, the line position correction + * and the true widths on the strong and weak sides of a line with the given + * extracted width and gradient ratio, by bilinear interpolation in the + * correction table. Outside the table, all results are 0. + * + * @return true if the correction is not valid + */ + static boolean lineCorrections(final double sigma, final double widthEst, + final double ratioEst, final MutableDouble width, + final MutableDouble asymmetry, final MutableDouble correction, + final MutableDouble widthStrong, final MutableDouble widthWeak) + { + final double w = widthEst / sigma; + if (w < 2 || w > 6 || ratioEst < 0 || ratioEst > 1) { + width.setValue(0); + asymmetry.setValue(0); + correction.setValue(0); + widthStrong.setValue(0); + widthWeak.setValue(0); + return true; + } + final int iWe = Math.min(39, (int) Math.floor((w - 2) * 10)); + final int iRe = Math.min(19, (int) Math.floor(ratioEst * 20)); + final boolean valid = isValid(iRe, iWe) && isValid(iRe, iWe + 1) && isValid( + iRe + 1, iWe) && isValid(iRe + 1, iWe + 1); + final double a = (w - 2) * 10 - iWe; + final double b = ratioEst * 20 - iRe; + + width.setValue(bilinear(a, b, iRe, iWe, WIDTH) * sigma); + asymmetry.setValue(bilinear(a, b, iRe, iWe, ASYMMETRY)); + correction.setValue(bilinear(a, b, iRe, iWe, CORRECTION) * sigma); + widthStrong.setValue(bilinear(a, b, iRe, iWe, WIDTH_STRONG) * sigma); + widthWeak.setValue(bilinear(a, b, iRe, iWe, WIDTH_WEAK) * sigma); + return !valid; + } +} diff --git a/scijava-ops-image-gpl/src/main/java/org/scijava/ops/image/gpl/ridge/DefaultDetectLines.java b/scijava-ops-image-gpl/src/main/java/org/scijava/ops/image/gpl/ridge/DefaultDetectLines.java new file mode 100644 index 000000000..79b4cbb50 --- /dev/null +++ b/scijava-ops-image-gpl/src/main/java/org/scijava/ops/image/gpl/ridge/DefaultDetectLines.java @@ -0,0 +1,181 @@ +/*- + * #%L + * GPL-licensed image processing operations for SciJava Ops, translated from copyleft ImageJ/Fiji plugins. + * %% + * Copyright (C) 2026 SciJava developers. + * %% + * This program 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. + * + * This program 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 this program. If not, see + * . + * #L% + */ + +package org.scijava.ops.image.gpl.ridge; + +import java.util.ArrayList; +import java.util.IdentityHashMap; +import java.util.List; +import java.util.Map; + +import net.imglib2.Cursor; +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.type.numeric.RealType; +import net.imglib2.view.Views; + +import org.scijava.function.Functions; +import org.scijava.ops.spi.Nullable; + +/** + * Detects curvilinear structures (lines) in a 2D image with the unbiased line + * detector of Steger, as in the Fiji Ridge Detection plugin. + *

+ * Line points are the points where the first directional derivative of the + * Gaussian-smoothed image across the line vanishes, located with sub-pixel + * accuracy from the Hessian; their strength is the second directional + * derivative across the line. Line points are linked into lines by hysteresis + * thresholding on this strength, junctions are detected where lines meet, and + * lines are split at junctions. Optionally, the width of each line point is + * extracted by locating the line edges along its normal, and the bias of the + * width and position caused by asymmetric line profiles is corrected. + *

+ *

+ * Compared to {@code segment.detectRidges}, this op follows Steger closely + * and also detects dark lines, estimates line widths, corrects the width and + * position bias, reports junctions and contour classes, and can merge lines + * across crossings. + *

+ *

+ * This is a translation of the core of the + * Ridge + * Detection plugin by Thorsten Wagner and Mark Hiner (GPL v2+), itself a + * translation of the C implementation of Carsten Steger (with modifications by + * R. Balasubramanian), without its display parts. Deviations from the plugin: + * line IDs (used internally to resolve overlaps) start at 0 for each + * detection instead of being numbered by a global counter, which matches the + * first detection of a fresh plugin session; the overlap resolution iterates + * over lines in a deterministic order (the plugin uses identity hash order); + * a bug that lost the responses of the first points of lines with more than + * 100 points is fixed; and an out of range sigma throws an exception instead + * of showing an error. + *

+ *

+ * As in the plugin, the contrast estimate of {@link DetectedLine#intensity} + * is limited to 275 (larger values are reset to 0), which assumes an 8-bit + * gray value range. + *

+ *

+ * Reference: C. Steger, "An unbiased detector of curvilinear structures", + * IEEE Transactions on Pattern Analysis and Machine Intelligence + * 20(2):113-125, 1998. + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @author Thorsten Wagner + * @author Mark Hiner + * @author Carsten Steger + * @param type of the input image + * @implNote op names='segment.detectLines' + */ +public class DefaultDetectLines> implements + Functions.Arity11, Double, Double, Double, Boolean, Boolean, Boolean, Boolean, Double, Double, OverlapResolution, DetectedLines> +{ + + /** + * Detects lines in a 2D image. + * + * @param input the 2D image + * @param sigma the smoothing scale; for lines of width w, it should be at + * least w/(2 sqrt(3)), and at least 0.4 + * @param lowerThreshold the lower hysteresis threshold on the line point + * strength (the second directional derivative): line points weaker + * than this are rejected + * @param upperThreshold the upper hysteresis threshold: lines start at line + * points at least this strong + * @param darkLine whether to detect dark lines on a bright background + * instead of bright lines on a dark background (default false) + * @param estimateWidth whether to estimate the line width (default false) + * @param correctPosition whether to correct the bias of the line width and + * position caused by asymmetric line profiles, and to estimate the + * line asymmetry and contrast; implies estimateWidth (default false) + * @param extendLine whether to extend the line ends to find additional + * junctions (default true) + * @param minLength the minimum length of the reported lines (default 0) + * @param maxLength the maximum length of the reported lines, or 0 for no + * maximum (default 0) + * @param overlapResolution how to resolve crossing lines (default + * {@link OverlapResolution#NONE}) + * @return the detected lines and their junctions + */ + @Override + public DetectedLines apply( // + final RandomAccessibleInterval input, // + final Double sigma, // + final Double lowerThreshold, // + final Double upperThreshold, // + @Nullable final Boolean darkLine, // + @Nullable final Boolean estimateWidth, // + @Nullable final Boolean correctPosition, // + @Nullable final Boolean extendLine, // + @Nullable final Double minLength, // + @Nullable final Double maxLength, // + @Nullable final OverlapResolution overlapResolution // + ) { + if (input.numDimensions() != 2) { + throw new IllegalArgumentException( + "Line detection requires a 2D image, not " + input.numDimensions() + + "D"); + } + final boolean correct = correctPosition != null && correctPosition; + final boolean width = correct || estimateWidth != null && estimateWidth; + final int w = (int) input.dimension(0); + final int h = (int) input.dimension(1); + final float[] pixels = new float[w * h]; + final Cursor c = Views.flatIterable(input).cursor(); + for (int i = 0; i < pixels.length; i++) + pixels[i] = c.next().getRealFloat(); + + final LineDetector detector = new LineDetector(correct, width); + final Lines lines = detector.detectLines(pixels, w, h, sigma, + upperThreshold, lowerThreshold, minLength == null ? 0 : minLength, + maxLength == null ? 0 : maxLength, darkLine != null && darkLine, + extendLine == null || extendLine, overlapResolution == null + ? OverlapResolution.NONE : overlapResolution); + + final double x0 = input.min(0), y0 = input.min(1); + final Map map = new IdentityHashMap<>(); + final List result = new ArrayList<>(lines.size()); + for (final Line line : lines) { + if (x0 != 0 || y0 != 0) { + for (int i = 0; i < line.num; i++) { + line.col[i] += x0; + line.row[i] += y0; + } + } + final DetectedLine detected = new DetectedLine(line, width, + correct); + map.put(line, detected); + result.add(detected); + } + final List junctions = new ArrayList<>(); + for (final Junction j : detector.getJunctions()) { + final DetectedLine l1 = map.get(j.getLine1()); + final DetectedLine l2 = map.get(j.getLine2()); + // Note: junctions whose lines were not reported are dropped. + if (l1 == null || l2 == null) continue; + junctions.add(new LineJunction(j.x + x0, j.y + y0, l1, l2, j.pos, + j.isNonTerminal)); + } + return new DetectedLines(result, junctions); + } +} diff --git a/scijava-ops-image-gpl/src/main/java/org/scijava/ops/image/gpl/ridge/DetectedLine.java b/scijava-ops-image-gpl/src/main/java/org/scijava/ops/image/gpl/ridge/DetectedLine.java new file mode 100644 index 000000000..3028ebf72 --- /dev/null +++ b/scijava-ops-image-gpl/src/main/java/org/scijava/ops/image/gpl/ridge/DetectedLine.java @@ -0,0 +1,174 @@ +/*- + * #%L + * GPL-licensed image processing operations for SciJava Ops, translated from copyleft ImageJ/Fiji plugins. + * %% + * Copyright (C) 2026 SciJava developers. + * %% + * This program 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. + * + * This program 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 this program. If not, see + * . + * #L% + */ + +package org.scijava.ops.image.gpl.ridge; + +import java.util.ArrayList; +import java.util.List; + +import net.imglib2.RealPoint; +import net.imglib2.roi.geom.real.DefaultWritablePolyline; + +/** + * A line detected by {@code segment.detectLines}: a polyline through the + * sub-pixel positions of its line points, with per-point attributes. + *

+ * The attributes are indexed by vertex, in the order of the vertices at + * detection; they are not updated if vertices are added or removed later. + *

+ *

+ * The {@link #angle(int) angle} of a line point is the direction of the line + * normal, measured from the Y axis towards the X axis: the unit normal is + * {@code (sin(angle), cos(angle))} in (X, Y) coordinates. The normal points to + * the right of the line as it is traversed from its first to its last vertex; + * the {@link #widthRight(int) right width} is measured along the normal, the + * {@link #widthLeft(int) left width} against it. + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @author Thorsten Wagner + */ +public class DetectedLine extends DefaultWritablePolyline { + + private final ContourClass contourClass; + private final double[] angle; + private final double[] response; + private final double[] widthLeft; + private final double[] widthRight; + private final double[] asymmetry; + private final double[] intensity; + + DetectedLine(final Line line, final boolean width, final boolean corrected) { + super(vertices(line)); + final int n = line.num; + contourClass = line.getContourClass(); + angle = toDouble(line.angle, n); + response = toDouble(line.response, n); + widthLeft = width ? toDouble(line.widthL, n) : null; + widthRight = width ? toDouble(line.widthR, n) : null; + asymmetry = corrected ? toDouble(line.asymmetry, n) : null; + intensity = corrected ? toDouble(line.intensity, n) : null; + } + + /** @return the classification of the line by its junctions */ + public ContourClass contourClass() { + return contourClass; + } + + /** @return the number of line points at detection */ + public int size() { + return angle.length; + } + + /** + * @param i the index of the line point + * @return the angle of the line normal at the point, in radians + */ + public double angle(final int i) { + return angle[i]; + } + + /** + * @param i the index of the line point + * @return the response of the line detector at the point: the magnitude of + * the second directional derivative of the smoothed image across the + * line + */ + public double response(final int i) { + return response[i]; + } + + /** @return whether the line width was estimated */ + public boolean hasWidth() { + return widthLeft != null; + } + + /** + * @param i the index of the line point + * @return the width of the line to the left of the point, or NaN if the + * width was not estimated + */ + public double widthLeft(final int i) { + return widthLeft == null ? Double.NaN : widthLeft[i]; + } + + /** + * @param i the index of the line point + * @return the width of the line to the right of the point, or NaN if the + * width was not estimated + */ + public double widthRight(final int i) { + return widthRight == null ? Double.NaN : widthRight[i]; + } + + /** @return whether the width and position bias was corrected */ + public boolean hasCorrection() { + return asymmetry != null; + } + + /** + * @param i the index of the line point + * @return the asymmetry of the line profile at the point (the ratio of the + * background contrast between both sides; positive when the + * background is brighter on the right for bright lines), or NaN if + * the bias was not corrected + */ + public double asymmetry(final int i) { + return asymmetry == null ? Double.NaN : asymmetry[i]; + } + + /** + * @param i the index of the line point + * @return the estimated contrast of the line at the point (negative for + * dark lines), or NaN if the bias was not corrected + */ + public double intensity(final int i) { + return intensity == null ? Double.NaN : intensity[i]; + } + + /** @return the length of the polyline */ + public double length() { + double length = 0; + for (int i = 1; i < numVertices(); i++) { + length += Math.hypot(vertex(i).getDoublePosition(0) - vertex(i - 1) + .getDoublePosition(0), vertex(i).getDoublePosition(1) - vertex(i - 1) + .getDoublePosition(1)); + } + return length; + } + + private static List vertices(final Line line) { + final List points = new ArrayList<>(line.num); + for (int i = 0; i < line.num; i++) + points.add(new RealPoint(line.col[i], line.row[i])); + return points; + } + + private static double[] toDouble(final float[] values, final int n) { + if (values == null) return null; + final double[] result = new double[n]; + for (int i = 0; i < n; i++) + result[i] = values[i]; + return result; + } +} diff --git a/scijava-ops-image-gpl/src/main/java/org/scijava/ops/image/gpl/ridge/DetectedLines.java b/scijava-ops-image-gpl/src/main/java/org/scijava/ops/image/gpl/ridge/DetectedLines.java new file mode 100644 index 000000000..46d800dd0 --- /dev/null +++ b/scijava-ops-image-gpl/src/main/java/org/scijava/ops/image/gpl/ridge/DetectedLines.java @@ -0,0 +1,59 @@ +/*- + * #%L + * GPL-licensed image processing operations for SciJava Ops, translated from copyleft ImageJ/Fiji plugins. + * %% + * Copyright (C) 2026 SciJava developers. + * %% + * This program 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. + * + * This program 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 this program. If not, see + * . + * #L% + */ + +package org.scijava.ops.image.gpl.ridge; + +import java.util.Collections; +import java.util.List; + +/** + * Result of {@code segment.detectLines}: the detected lines and the junctions + * between them. + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +public class DetectedLines { + + private final List lines; + private final List junctions; + + DetectedLines(final List lines, + final List junctions) + { + this.lines = Collections.unmodifiableList(lines); + this.junctions = Collections.unmodifiableList(junctions); + } + + /** @return the detected lines (unmodifiable) */ + public List lines() { + return lines; + } + + /** + * @return the junctions between the detected lines (unmodifiable), sorted + * as by the plugin + */ + public List junctions() { + return junctions; + } +} diff --git a/scijava-ops-image-gpl/src/main/java/org/scijava/ops/image/gpl/ridge/EstimateLineParameters.java b/scijava-ops-image-gpl/src/main/java/org/scijava/ops/image/gpl/ridge/EstimateLineParameters.java new file mode 100644 index 000000000..058a88d48 --- /dev/null +++ b/scijava-ops-image-gpl/src/main/java/org/scijava/ops/image/gpl/ridge/EstimateLineParameters.java @@ -0,0 +1,97 @@ +/*- + * #%L + * GPL-licensed image processing operations for SciJava Ops, translated from copyleft ImageJ/Fiji plugins. + * %% + * Copyright (C) 2026 SciJava developers. + * %% + * This program 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. + * + * This program 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 this program. If not, see + * . + * #L% + */ + +package org.scijava.ops.image.gpl.ridge; + +import org.scijava.function.Functions; +import org.scijava.ops.spi.Nullable; + +/** + * Estimates the parameters of {@code segment.detectLines} (sigma and the + * hysteresis thresholds) from the width of the lines to detect and their + * contrast, as the Fiji Ridge Detection plugin does in its dialog. + *

+ * The smoothing scale is {@code sigma = w / (2 sqrt(3)) + 0.5} for lines of + * width {@code w}. Each threshold is 0.17 times the floor of the magnitude of + * the response at the center of a bar-shaped line of width {@code w} and + * contrast {@code c} (Steger 1998), + * {@code 2 c (w/2) / (sqrt(2 pi) sigma^3) exp(-(w/2)^2 / (2 sigma^2))}, with + * the low contrast for the lower threshold and the high contrast for the + * upper one. For dark lines, the contrasts are taken relative to the maximum + * gray value: the lower threshold uses {@code max - high} and the upper one + * {@code max - low}. Unlike the plugin dialog, the results are not rounded to + * two decimals. + *

+ *

+ * This is a translation of the parameter estimation of the + * Ridge + * Detection plugin by Thorsten Wagner (GPL v2+). + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @author Thorsten Wagner + * @implNote op names='segment.estimateLineParameters' + */ +public class EstimateLineParameters implements + Functions.Arity5 +{ + + /** + * Estimates the line detection parameters. + * + * @param lineWidth the width of the lines to detect + * @param lowContrast the lowest gray value of the lines (for bright lines) + * @param highContrast the highest gray value of the lines (for bright lines) + * @param darkLine whether the lines are dark on a bright background + * (default false) + * @param maxValue the maximum gray value, used for dark lines (default 255, + * as in the plugin) + * @return the array {sigma, lowerThreshold, upperThreshold} + */ + @Override + public double[] apply( // + final Double lineWidth, // + final Double lowContrast, // + final Double highContrast, // + @Nullable final Boolean darkLine, // + @Nullable final Double maxValue // + ) { + final boolean dark = darkLine != null && darkLine; + final double max = maxValue == null ? 255 : maxValue; + final double sigma = lineWidth / (2 * Math.sqrt(3)) + 0.5; + final double clow = dark ? max - highContrast : lowContrast; + final double chigh = dark ? max - lowContrast : highContrast; + return new double[] { sigma, threshold(lineWidth, sigma, clow), + threshold(lineWidth, sigma, chigh) }; + } + + private static double threshold(final double lineWidth, final double sigma, + final double contrast) + { + final double halfWidth = lineWidth / 2.0; + final double response = Math.abs(-2 * contrast * halfWidth / (Math.sqrt(2 * + Math.PI) * sigma * sigma * sigma) * Math.exp(-(halfWidth * halfWidth) / + (2 * sigma * sigma))); + return Math.floor(response) * 0.17; + } +} diff --git a/scijava-ops-image-gpl/src/main/java/org/scijava/ops/image/gpl/ridge/Junction.java b/scijava-ops-image-gpl/src/main/java/org/scijava/ops/image/gpl/ridge/Junction.java new file mode 100644 index 000000000..80c43b69d --- /dev/null +++ b/scijava-ops-image-gpl/src/main/java/org/scijava/ops/image/gpl/ridge/Junction.java @@ -0,0 +1,82 @@ +/*- + * #%L + * GPL-licensed image processing operations for SciJava Ops, translated from copyleft ImageJ/Fiji plugins. + * %% + * Copyright (C) 2026 SciJava developers. + * %% + * This program 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. + * + * This program 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 this program. If not, see + * . + * #L% + */ + +package org.scijava.ops.image.gpl.ridge; + +/** + * Working representation of a junction between two lines, as in the + * ij-ridgedetection {@code Junction} class. It is needed to split lines at + * junction points. + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @author Thorsten Wagner + * @author Carsten Steger + */ +class Junction implements Comparable { + + /** Index of line that is already processed. */ + int cont1; + + /** Index of line that runs into cont1. */ + int cont2; + + /** Index of the junction point in cont1. */ + int pos; + + /** X- (row-, before the coordinate swap) coordinate of the junction. */ + float x; + + /** Y- (column-, before the coordinate swap) coordinate of the junction. */ + float y; + + /** Line that is already processed. */ + Line lineCont1; + + /** Line that runs into lineCont1. */ + Line lineCont2; + + /** True if this junction sits on a start/end of at least one line. */ + boolean isNonTerminal = false; + + @Override + public int compareTo(final Junction o) { + return (((this.cont1 - o.cont1) != 0) ? this.cont1 - o.cont1 : this.pos - + o.pos); + } + + float getX() { + return x; + } + + float getY() { + return y; + } + + Line getLine1() { + return lineCont1; + } + + Line getLine2() { + return lineCont2; + } +} diff --git a/scijava-ops-image-gpl/src/main/java/org/scijava/ops/image/gpl/ridge/Line.java b/scijava-ops-image-gpl/src/main/java/org/scijava/ops/image/gpl/ridge/Line.java new file mode 100644 index 000000000..e47881d72 --- /dev/null +++ b/scijava-ops-image-gpl/src/main/java/org/scijava/ops/image/gpl/ridge/Line.java @@ -0,0 +1,117 @@ +/*- + * #%L + * GPL-licensed image processing operations for SciJava Ops, translated from copyleft ImageJ/Fiji plugins. + * %% + * Copyright (C) 2026 SciJava developers. + * %% + * This program 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. + * + * This program 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 this program. If not, see + * . + * #L% + */ + +package org.scijava.ops.image.gpl.ridge; + +/** + * Working representation of an extracted line (contour), as in the + * ij-ridgedetection {@code Line} class. Row coordinates are Y and column + * coordinates are X. + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @author Thorsten Wagner + * @author Carsten Steger + */ +class Line { + + /** Number of points. */ + int num; + + /** Row coordinates of the line points. */ + float[] row; + + /** Column coordinates of the line points. */ + float[] col; + + /** Angle of the normal (measured from the row axis). */ + float[] angle; + + /** Response of the line point (second derivative). */ + float[] response; + + /** Width to the left of the line. */ + float[] widthL; + + /** Width to the right of the line. */ + float[] widthR; + + /** Asymmetry of the line point. */ + float[] asymmetry; + + /** Intensity of the line point. */ + float[] intensity; + + private ContourClass contClass; + + private final int id; + + /** + * Creates a line with the next ID of the given ID source. + * + * @param ids the per-detection ID counter + */ + Line(final Lines ids) { + id = ids.nextId(); + } + + ContourClass getContourClass() { + return contClass; + } + + void setContourClass(final ContourClass contClass) { + this.contClass = contClass; + } + + float[] getXCoordinates() { + return col; + } + + float[] getYCoordinates() { + return row; + } + + int getNumber() { + return num; + } + + int getID() { + return id; + } + + int getStartOrdEndPosition(final float x, final float y) { + final double distStart = Math.sqrt(Math.pow(col[0] - x, 2) + Math.pow( + row[0] - y, 2)); + final double distEnd = Math.sqrt(Math.pow(col[(this.num - 1)] - x, 2) + + Math.pow(row[(this.num - 1)] - y, 2)); + return (distStart < distEnd ? 0 : this.num - 1); + } + + double estimateLength() { + double length = 0; + for (int i = 1; i < num; i++) { + length += Math.sqrt(Math.pow(col[i] - col[i - 1], 2) + Math.pow(row[i] - + row[i - 1], 2)); + } + return length; + } +} diff --git a/scijava-ops-image-gpl/src/main/java/org/scijava/ops/image/gpl/ridge/LineDetector.java b/scijava-ops-image-gpl/src/main/java/org/scijava/ops/image/gpl/ridge/LineDetector.java new file mode 100644 index 000000000..78dca7f19 --- /dev/null +++ b/scijava-ops-image-gpl/src/main/java/org/scijava/ops/image/gpl/ridge/LineDetector.java @@ -0,0 +1,611 @@ +/*- + * #%L + * GPL-licensed image processing operations for SciJava Ops, translated from copyleft ImageJ/Fiji plugins. + * %% + * Copyright (C) 2026 SciJava developers. + * %% + * This program 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. + * + * This program 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 this program. If not, see + * . + * #L% + */ + +package org.scijava.ops.image.gpl.ridge; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.Iterator; +import java.util.List; +import java.util.Set; + +import org.scijava.ops.image.gpl.ridge.LinesUtil.MutableInt; + +/** + * Driver of the line detection, from the ij-ridgedetection {@code LineDetector} + * class: extracts the lines and junctions with Steger's algorithm, then + * reconstructs the junctions and contour classes, splits lines at junctions, + * optionally resolves overlapping lines, and prunes lines by length. + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @author Thorsten Wagner + */ +class LineDetector { + + private final boolean doCorrectPosition; + + private final boolean doEstimateWidth; + + private List junctions; + + private Set alreadyProcessedJunctionPoints; + + LineDetector(final boolean doCorrectPosition, final boolean doEstimateWidth) { + this.doCorrectPosition = doCorrectPosition; + this.doEstimateWidth = doEstimateWidth; + } + + /** + * Detects lines. + * + * @param image the pixels of the image, row by row + * @param width the width of the image + * @param height the height of the image + * @param sigma the smoothing scale; at least width/(2*sqrt(3)) for lines of + * the given width + * @param upperThresh upper hysteresis threshold of the linking algorithm + * @param lowerThresh lower hysteresis threshold of the linking algorithm + * @param minLength minimum line length + * @param maxLength maximum line length (0 for no maximum) + * @param isDarkLine whether lines are darker than the background + * @param doExtendLine whether to extend lines to find more junctions + * @param overlap how overlapping lines are resolved + * @return the detected lines + */ + Lines detectLines(final float[] image, final int width, final int height, + final double sigma, final double upperThresh, final double lowerThresh, + final double minLength, final double maxLength, final boolean isDarkLine, + final boolean doExtendLine, final OverlapResolution overlap) + { + checkSigma(sigma, width, height); + return getLines(image, width, height, sigma, upperThresh, lowerThresh, + minLength, maxLength, isDarkLine ? LinesUtil.MODE_DARK + : LinesUtil.MODE_LIGHT, doExtendLine, overlap); + } + + /** @return the junctions found by the last detection */ + List getJunctions() { + return junctions; + } + + private void assignLinesToJunctions(Lines lines, List junctions) { + for (Junction j : junctions) { + j.lineCont1 = lines.get(j.cont1); + j.lineCont2 = lines.get(j.cont2); + } + } + + private void addAdditionalJunctionPointsAndLines(Lines lines, + List junctions) + { + + for (int i = 0; i < junctions.size(); i++) { + Junction splitPoint = junctions.get(i); + if (!alreadyProcessedJunctionPoints.contains(i)) { + + /* + * Find junctions with the same position as the split point + */ + List junctionsWithTheSamePosition = new ArrayList<>(); + alreadyProcessedJunctionPoints.add(i); + junctionsWithTheSamePosition.add(splitPoint); + for (int j = i + 1; j < junctions.size(); j++) { + if (!alreadyProcessedJunctionPoints.contains(j)) { + Junction junc2 = junctions.get(j); + if (Math.abs(junc2.x - splitPoint.x) < 0.01 && Math.abs(junc2.y - + splitPoint.y) < 0.01) + { + alreadyProcessedJunctionPoints.add(j); + junctionsWithTheSamePosition.add(junc2); + } + } + } + + /* + * Connect all lines which are connected with the processed line also + * with each other (new junctions point) + */ + + ArrayList connectedWithProcessedLine = new ArrayList(); + ArrayList connectedWithProcessedIndex = + new ArrayList(); + for (Junction junc : junctionsWithTheSamePosition) { + connectedWithProcessedLine.add(junc.getLine2()); + connectedWithProcessedIndex.add(junc.cont2); + } + for (int j = 0; j < connectedWithProcessedLine.size(); j++) { + for (int k = j + 1; k < connectedWithProcessedLine.size(); k++) { + Line l1 = connectedWithProcessedLine.get(j); + Line l2 = connectedWithProcessedLine.get(k); + Junction junc = new Junction(); + junc.lineCont1 = l1; + junc.lineCont2 = l2; + junc.x = splitPoint.x; + junc.y = splitPoint.y; + junc.cont1 = connectedWithProcessedIndex.get(j); + junc.cont2 = connectedWithProcessedIndex.get(k); + junc.pos = l1.getStartOrdEndPosition(junc.x, junc.y); + junctions.add(junc); + alreadyProcessedJunctionPoints.add(junctions.size() - 1); + } + } + + /* + * Split the line in two line at the split point if it is not at the end + * or beginning of the line + */ + Line l1 = splitPoint.getLine1(); + int pos = splitPoint.pos; + boolean isClosedContour = l1.col[0] == l1.col[l1.num - 1] && + l1.row[0] == l1.row[l1.num - 1]; + + if (isClosedContour) { + l1.setContourClass(ContourClass.CLOSED); + l1.setContourClass(reconstructContourClass(l1, l1 + .getStartOrdEndPosition(splitPoint.x, splitPoint.y))); + } + if (pos != 0 && pos != (l1.num - 1) && !isClosedContour) { + // Split the line at pos, which belongs to both parts. + int keepLength = pos + 1; + + float[] keepAsymmetry = new float[keepLength]; + float[] keepIntensity = new float[keepLength]; + + float[] keepAngle = new float[keepLength]; + float[] keepWidthL = new float[keepLength]; + float[] keepWidthR = new float[keepLength]; + + float[] keepCol = new float[keepLength]; + float[] keepRow = new float[keepLength]; + float[] keepResponse = new float[keepLength]; + + int splitSize = (int) (l1.num - pos); + + float[] splitAsymmetry = new float[splitSize]; + float[] splitIntensity = new float[splitSize]; + + float[] splitAngle = new float[splitSize]; + float[] splitWidthL = new float[splitSize]; + float[] splitWidthR = new float[splitSize]; + + float[] splitCol = new float[splitSize]; + float[] splitRow = new float[splitSize]; + float[] splitResponse = new float[splitSize]; + + if (doEstimateWidth) { + if (doCorrectPosition) { + System.arraycopy(l1.asymmetry, 0, keepAsymmetry, 0, keepLength); + System.arraycopy(l1.asymmetry, pos, splitAsymmetry, 0, splitSize); + + System.arraycopy(l1.intensity, 0, keepIntensity, 0, keepLength); + System.arraycopy(l1.intensity, pos, splitIntensity, 0, splitSize); + } + + System.arraycopy(l1.angle, 0, keepAngle, 0, keepLength); + System.arraycopy(l1.angle, pos, splitAngle, 0, splitSize); + + System.arraycopy(l1.widthL, 0, keepWidthL, 0, keepLength); + System.arraycopy(l1.widthL, pos, splitWidthL, 0, splitSize); + + System.arraycopy(l1.widthR, 0, keepWidthR, 0, keepLength); + System.arraycopy(l1.widthR, pos, splitWidthR, 0, splitSize); + } + + System.arraycopy(l1.col, 0, keepCol, 0, keepLength); + System.arraycopy(l1.col, pos, splitCol, 0, splitSize); + + System.arraycopy(l1.row, 0, keepRow, 0, keepLength); + System.arraycopy(l1.row, pos, splitRow, 0, splitSize); + + System.arraycopy(l1.response, 0, keepResponse, 0, keepLength); + System.arraycopy(l1.response, pos, splitResponse, 0, splitSize); + + Line lNew = new Line(lines); + lNew.angle = splitAngle; + lNew.asymmetry = splitAsymmetry; + lNew.col = splitCol; + lNew.row = splitRow; + lNew.response = splitResponse; + lNew.intensity = splitIntensity; + lNew.widthL = splitWidthL; + lNew.widthR = splitWidthR; + lNew.num = splitSize; + lNew.setContourClass(l1.getContourClass()); + lines.add(lNew); + int newID = lNew.getID(); + + // The IDs of all lines connected at this junction point. + Set lineIds = new HashSet(); + for (Junction junc : junctionsWithTheSamePosition) { + lineIds.add(junc.getLine1().getID()); + lineIds.add(junc.getLine2().getID()); + } + Iterator idIt = lineIds.iterator(); + while (idIt.hasNext()) { + int id = idIt.next(); + int connectWithLineID = lines.getIndexByID(id); + Line connectWith = lines.get(connectWithLineID); + Junction j = new Junction(); + j.cont1 = lines.size() - 1; + j.cont2 = connectWithLineID; + j.lineCont1 = lNew; + j.lineCont2 = connectWith; + j.x = splitPoint.x; + j.y = splitPoint.y; + j.pos = lNew.getStartOrdEndPosition(splitPoint.x, splitPoint.y); + junctions.add(j); + alreadyProcessedJunctionPoints.add(junctions.size() - 1); + } + + for (int j = 0; j < junctions.size(); j++) { + Junction junc2 = junctions.get(j); + if (junc2.cont1 == splitPoint.cont1 && junc2.pos > splitPoint.pos) { + junc2.cont1 = lines.getIndexByID(newID); + junc2.lineCont1 = lNew; + junc2.pos = junc2.pos - splitPoint.pos; + } + + double[] min = minDistance(junc2.getLine2(), junc2.x, junc2.y); + + if (junc2.cont2 == splitPoint.cont1 && + ((int) min[1]) > splitPoint.pos) + { + junc2.cont2 = lines.getIndexByID(newID); + junc2.lineCont2 = lNew; + } + + } + + l1.angle = keepAngle; + l1.asymmetry = keepAsymmetry; + l1.col = keepCol; + l1.row = keepRow; + l1.response = keepResponse; + l1.intensity = keepIntensity; + l1.widthL = keepWidthL; + l1.widthR = keepWidthR; + l1.num = keepLength; + + splitPoint.pos = l1.getStartOrdEndPosition(splitPoint.x, + splitPoint.y); + lines.set(splitPoint.cont1, l1); + + } + + } + } + } + + private List fixJunctions(Lines lines, List junctions) { + /* + * For some reason, the x and y coordinates are permuted + */ + for (Junction junction : junctions) { + float help = junction.x; + junction.x = junction.y; + junction.y = help; + } + + List newJunctions = new ArrayList<>(); + Set processedJunctions = new HashSet<>(); + for (int i = 0; i < junctions.size(); i++) { + Junction junc = junctions.get(i); + Line mainLine = null; + int mainLineIndex = -1; + int mainLinePos = -1; + ArrayList secondaryLines = new ArrayList(); + ArrayList secondaryLineIndex = new ArrayList(); + ArrayList secondaryLinePos = new ArrayList(); + + // Process each junction position only once. + if (isNewPosition(processedJunctions, junc.x, junc.y)) { + + // Find the main line and the secondary lines. + for (int j = 0; j < lines.size(); j++) { + Line l = lines.get(j); + + double[] mindist = minDistance(l, junc.x, junc.y); + if (mindist[0] < 0.1) { + // The junction lies on the line: on a secondary line if it is at + // its start or end, else on the main line. + if (mindist[1] == 0 || mindist[1] == (l.num - 1)) { + secondaryLines.add(l); + secondaryLineIndex.add(j); + secondaryLinePos.add((int) mindist[1]); + } + else { + if (mainLine != null) { + if (mainLine.getID() == l.getID()) { + continue; + } + } + mainLine = l; + mainLineIndex = j; + mainLinePos = (int) mindist[1]; + + } + } + } + if (mainLine != null) { + for (int j = 0; j < secondaryLines.size(); j++) { + Junction newJunc = new Junction(); + newJunc.cont1 = mainLineIndex; + newJunc.cont2 = secondaryLineIndex.get(j); + newJunc.x = junc.x; + newJunc.y = junc.y; + newJunc.pos = mainLinePos; + newJunctions.add(newJunc); + + } + } + else { + // Note: In some cases there is no main line, due to an upstream bug + // its author could not find; all secondary lines are then paired. + HashSet uniqueIDs = new HashSet(); + ArrayList uniqueLines = new ArrayList(); + ArrayList uniqueLineIndex = new ArrayList(); + ArrayList uniqueLinePos = new ArrayList(); + for (int j = 0; j < secondaryLines.size(); j++) { + if (!uniqueIDs.contains(secondaryLines.get(j).getID())) { + uniqueIDs.add(secondaryLines.get(j).getID()); + uniqueLines.add(secondaryLines.get(j)); + uniqueLineIndex.add(secondaryLineIndex.get(j)); + uniqueLinePos.add(secondaryLinePos.get(j)); + + } + + } + for (int j = 0; j < uniqueLines.size(); j++) { + for (int k = j + 1; k < uniqueLines.size(); k++) { + Junction newJunc = new Junction(); + newJunc.cont1 = uniqueLineIndex.get(j); + newJunc.cont2 = uniqueLineIndex.get(k); + newJunc.x = junc.x; + newJunc.y = junc.y; + newJunc.pos = uniqueLinePos.get(j); + newJunctions.add(newJunc); + alreadyProcessedJunctionPoints.add(newJunctions.size() - 1); + + } + } + } + + } + } + return newJunctions; + + } + + private ContourClass reconstructContourClass(Line l, int pos) { + ContourClass currentClass = l.getContourClass(); + + boolean hasJunctionAtStartpoint = pos == 0 ? true : false; + boolean hasJunctionAtEndpoint = pos == (l.num - 1) ? true : false; + + if (currentClass == ContourClass.NO_JUNCTION && hasJunctionAtStartpoint) { + return ContourClass.START_JUNCTION; + } + + if (currentClass == ContourClass.NO_JUNCTION && hasJunctionAtEndpoint) { + return ContourClass.END_JUNCTION; + } + + if (currentClass == ContourClass.START_JUNCTION && hasJunctionAtEndpoint) { + return ContourClass.BOTH_JUNCTIONS; + } + + if (currentClass == ContourClass.END_JUNCTION && hasJunctionAtStartpoint) { + return ContourClass.BOTH_JUNCTIONS; + } + + if (currentClass == ContourClass.CLOSED && (hasJunctionAtEndpoint || + hasJunctionAtStartpoint)) + { + return ContourClass.BOTH_JUNCTIONS; + } + + return currentClass; + + } + + /** + * Records a position in a set of packed coordinates, returning true if it + * was not there yet. Coordinates compare with {@code ==}, like the + * {@code Point2D.Float} list of the original: {@code -0} equals {@code 0}, + * and a NaN position is never considered seen. + */ + private static boolean isNewPosition(final Set seen, final float x, + final float y) + { + if (Float.isNaN(x) || Float.isNaN(y)) return true; + // Note: Adding 0 maps -0 to +0, so that both pack to the same bits. + final long key = ((long) Float.floatToIntBits(x + 0f) << 32) | (Float + .floatToIntBits(y + 0f) & 0xffffffffL); + return seen.add(key); + } + + private double[] minDistance(Line l, float x, float y) { + double min = Double.MAX_VALUE; + double index = -1; + for (int i = 0; i < l.num; i++) { + double d = Math.sqrt(Math.pow(l.col[i] - x, 2) + Math.pow(l.row[i] - y, + 2)); + if (d < min) { + min = d; + index = i; + } + } + double[] ret = { min, index }; + return ret; + } + + private void deleteJunctions(Lines contours, List junctions, + Line c) + { + deleteJunctions(contours, junctions, c, OverlapResolution.NONE); + } + + private void deleteJunctions(Lines contours, List junctions, Line c, + OverlapResolution overlapOption) + { + + ArrayList remove = new ArrayList(); + for (Junction junction : junctions) { + // Note: With the slope overlap resolution, cont1 and cont2 hold line IDs + // rather than indices. + if (overlapOption == OverlapResolution.SLOPE) { + if (junction.cont1 == c.getID() || junction.cont2 == c.getID()) { + remove.add(junction); + } + } + else { + if (contours.get((int) junction.cont1).getID() == c.getID() || contours + .get((int) junction.cont2).getID() == c.getID()) + { + remove.add(junction); + } + } + } + for (Junction junction : remove) { + junctions.remove(junction); + } + } + + private void fixContours(Lines contours, List junctions) { + + ArrayList remove = new ArrayList(); + // Contours with only a single position cannot be valid. + for (Line contour : contours) { + if (contour.num == 1) { + deleteJunctions(contours, junctions, contour); + remove.add(contour); + } + // Note: The contour class is reconstructed from the junctions later. + contour.setContourClass(ContourClass.NO_JUNCTION); + } + + for (Line c : remove) { + contours.remove(c); + } + + // Note: For some reason, the first and the last contours can be the same. + if (contours.size() >= 2) { + if (contours.get(0).getID() == contours.get(contours.size() - 1) + .getID()) + { + contours.remove(contours.size() - 1); + } + } + } + + private void pruneContours(Lines contours, List junctions, + double minLength, double maxLength, OverlapResolution overlapOption) + { + ArrayList remove = new ArrayList(); + + for (Line c : contours) { + if ((c.estimateLength() < minLength) || (maxLength > 0 && c + .estimateLength() > maxLength)) + { + deleteJunctions(contours, junctions, c, overlapOption); + remove.add(c); + } + } + for (Line c : remove) { + + contours.remove(c); + } + } + + private Lines getLines(final float[] image, final int cols, final int rows, + final double sigma, final double high, final double low, + final double minLength, final double maxLength, final int mode, + final boolean doExtendLine, final OverlapResolution overlapOption) + { + Lines contours = new Lines(); + List resultJunction = new ArrayList<>(); + final MutableInt hnumCont = new MutableInt(0); + final Position p = new Position(); + p.findLines(image, cols, rows, contours, hnumCont, sigma, low, high, mode, + doEstimateWidth, doCorrectPosition, doExtendLine, resultJunction); + + fixContours(contours, resultJunction); + alreadyProcessedJunctionPoints = new HashSet(); + // Note: In rare cases, the upstream algorithm corrupts the results (its + // author could not find the bug), so the solution is reconstructed from + // the junction points, which are not corrupted. + resultJunction = fixJunctions(contours, resultJunction); + + assignLinesToJunctions(contours, resultJunction); + + addAdditionalJunctionPointsAndLines(contours, resultJunction); + Collections.sort(resultJunction); + junctions = resultJunction; + + // Reconstruct the contour classes. + for (int i = 0; i < contours.size(); i++) { + contours.get(i).setContourClass(ContourClass.NO_JUNCTION); + } + + for (int i = 0; i < contours.size(); i++) { + boolean isClosedContour = contours.get(i).col[0] == contours.get( + i).col[contours.get(i).num - 1] && contours.get(i).row[0] == contours + .get(i).row[contours.get(i).num - 1]; + if (isClosedContour) { + contours.get(i).setContourClass(ContourClass.CLOSED); + } + } + + for (int i = 0; i < junctions.size(); i++) { + Junction j = junctions.get(i); + j.getLine1().setContourClass(reconstructContourClass(j.getLine1(), + j.pos)); + float x = j.getLine1().getXCoordinates()[j.pos]; + float y = j.getLine1().getYCoordinates()[j.pos]; + j.getLine2().setContourClass(reconstructContourClass(j.getLine2(), j + .getLine2().getStartOrdEndPosition(x, y))); + } + + if (overlapOption == OverlapResolution.SLOPE) contours = + new SlopeOverlapResolver().resolve(contours, junctions); + + if (minLength != 0 || maxLength != 0) { + pruneContours(contours, junctions, minLength, maxLength, overlapOption); + } + + return contours; + + } + + private void checkSigma(double sigma, int width, int height) { + int minDim; + minDim = width < height ? width : height; + if (sigma < 0.4) throw new IllegalArgumentException("Sigma out of range: " + + sigma + " < 0.4"); + if (LinesUtil.maskSize(LinesUtil.MAX_SIZE_MASK_2, sigma) >= minDim) + throw new IllegalArgumentException("Sigma out of range: " + sigma + + " is too large for the image size"); + } + +} diff --git a/scijava-ops-image-gpl/src/main/java/org/scijava/ops/image/gpl/ridge/LineJunction.java b/scijava-ops-image-gpl/src/main/java/org/scijava/ops/image/gpl/ridge/LineJunction.java new file mode 100644 index 000000000..dd162f9d9 --- /dev/null +++ b/scijava-ops-image-gpl/src/main/java/org/scijava/ops/image/gpl/ridge/LineJunction.java @@ -0,0 +1,99 @@ +/*- + * #%L + * GPL-licensed image processing operations for SciJava Ops, translated from copyleft ImageJ/Fiji plugins. + * %% + * Copyright (C) 2026 SciJava developers. + * %% + * This program 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. + * + * This program 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 this program. If not, see + * . + * #L% + */ + +package org.scijava.ops.image.gpl.ridge; + +import net.imglib2.RealLocalizable; + +/** + * A junction between two lines detected by {@code segment.detectLines}. + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @author Thorsten Wagner + */ +public class LineJunction implements RealLocalizable { + + private final double x; + private final double y; + private final DetectedLine line1; + private final DetectedLine line2; + private final int position; + private final boolean nonTerminal; + + LineJunction(final double x, final double y, final DetectedLine line1, + final DetectedLine line2, final int position, final boolean nonTerminal) + { + this.x = x; + this.y = y; + this.line1 = line1; + this.line2 = line2; + this.position = position; + this.nonTerminal = nonTerminal; + } + + /** @return the X coordinate of the junction */ + public double x() { + return x; + } + + /** @return the Y coordinate of the junction */ + public double y() { + return y; + } + + /** @return the first line of the junction */ + public DetectedLine line1() { + return line1; + } + + /** @return the second line of the junction, which runs into the first */ + public DetectedLine line2() { + return line2; + } + + /** + * @return the index of the junction point among the vertices of + * {@link #line1()}, as reported by the plugin + */ + public int position() { + return position; + } + + /** + * @return true if the overlap resolution found that the junction lies on + * neither line end (always false without overlap resolution) + */ + public boolean isNonTerminal() { + return nonTerminal; + } + + @Override + public int numDimensions() { + return 2; + } + + @Override + public double getDoublePosition(final int d) { + return d == 0 ? x : y; + } +} diff --git a/scijava-ops-image-gpl/src/main/java/org/scijava/ops/image/gpl/ridge/Lines.java b/scijava-ops-image-gpl/src/main/java/org/scijava/ops/image/gpl/ridge/Lines.java new file mode 100644 index 000000000..03b0f1e09 --- /dev/null +++ b/scijava-ops-image-gpl/src/main/java/org/scijava/ops/image/gpl/ridge/Lines.java @@ -0,0 +1,69 @@ +/*- + * #%L + * GPL-licensed image processing operations for SciJava Ops, translated from copyleft ImageJ/Fiji plugins. + * %% + * Copyright (C) 2026 SciJava developers. + * %% + * This program 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. + * + * This program 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 this program. If not, see + * . + * #L% + */ + +package org.scijava.ops.image.gpl.ridge; + +import java.util.ArrayList; + +/** + * A list of lines, which also hands out the line IDs of one detection run. + *

+ * Note: the plugin numbers lines with a global counter that is never reset; + * here the IDs restart at 0 for each detection, as in the first detection of + * a fresh plugin session. + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @author Thorsten Wagner + */ +class Lines extends ArrayList { + + private final int[] idCounter; + + /** Creates an empty list with a new ID counter. */ + Lines() { + idCounter = new int[1]; + } + + /** + * Creates an empty list that shares the ID counter of another list. + * + * @param ids the list whose ID counter to share + */ + Lines(final Lines ids) { + idCounter = ids.idCounter; + } + + int nextId() { + return idCounter[0]++; + } + + int getIndexByID(final int id) { + for (int i = 0; i < this.size(); i++) { + if (this.get(i).getID() == id) { + return i; + } + } + return -1; + } +} diff --git a/scijava-ops-image-gpl/src/main/java/org/scijava/ops/image/gpl/ridge/LinesUtil.java b/scijava-ops-image-gpl/src/main/java/org/scijava/ops/image/gpl/ridge/LinesUtil.java new file mode 100644 index 000000000..4ebf3e6d5 --- /dev/null +++ b/scijava-ops-image-gpl/src/main/java/org/scijava/ops/image/gpl/ridge/LinesUtil.java @@ -0,0 +1,147 @@ +/*- + * #%L + * GPL-licensed image processing operations for SciJava Ops, translated from copyleft ImageJ/Fiji plugins. + * %% + * Copyright (C) 2026 SciJava developers. + * %% + * This program 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. + * + * This program 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 this program. If not, see + * . + * #L% + */ + +package org.scijava.ops.image.gpl.ridge; + +/** + * Constants and helpers shared by the line detector classes, from the + * ij-ridgedetection {@code LinesUtil} class, plus small mutable holders that + * stand in for C output parameters. + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @author Thorsten Wagner + * @author Carsten Steger + */ +final class LinesUtil { + + private LinesUtil() { + // Prevent instantiation of utility class. + } + + /** Derivative in row direction. */ + static final int DERIV_R = 1; + + /** Derivative in column direction. */ + static final int DERIV_C = 2; + + /** Second derivative in row direction. */ + static final int DERIV_RR = 3; + + /** Second derivative in row and column direction. */ + static final int DERIV_RC = 4; + + /** Second derivative in column direction. */ + static final int DERIV_CC = 5; + + /** Extract bright lines. */ + static final int MODE_LIGHT = 1; + + /** Extract dark lines. */ + static final int MODE_DARK = 2; + + static final int INITIAL_SIZE = 100; + + static final int REALLOC_FACTOR = 2; + + /** Size for Gaussian mask. */ + static final double MAX_SIZE_MASK_0 = 3.09023230616781; + + /** Size for 1st derivative mask. */ + static final double MAX_SIZE_MASK_1 = 3.46087178201605; + + /** Size for 2nd derivative mask. */ + static final double MAX_SIZE_MASK_2 = 3.82922419517181; + + /** Maximum mask index. */ + static int maskSize(final double max, final double sigma) { + return (int) Math.ceil(max * sigma); + } + + /** + * Translate row and column coordinates of an image into an index into its + * one-dimensional array. + */ + static int linCoor(final int row, final int col, final int width) { + return row * width + col; + } + + /** Mirror the row coordinate at the borders of the image. */ + static int mirrorRow(final int row, final int height) { + return ((row) < 0 ? -(row) : (row) >= height ? height - (row) + height - 2 + : (row)); + } + + /** Mirror the column coordinate at the borders of the image. */ + static int mirrorCol(final int col, final int width) { + return ((col) < 0 ? -(col) : (col) >= width ? width - (col) + width - 2 + : (col)); + } + + /** Mutable int, standing in for a C {@code int*} output parameter. */ + static final class MutableInt { + + private int value; + + MutableInt() {} + + MutableInt(final int value) { + this.value = value; + } + + int getValue() { + return value; + } + + int intValue() { + return value; + } + + void setValue(final int value) { + this.value = value; + } + } + + /** Mutable double, standing in for a C {@code double*} output parameter. */ + static final class MutableDouble { + + private double value; + + MutableDouble() {} + + MutableDouble(final double value) { + this.value = value; + } + + double getValue() { + return value; + } + + double doubleValue() { + return value; + } + + void setValue(final double value) { + this.value = value; + } + } +} diff --git a/scijava-ops-image-gpl/src/main/java/org/scijava/ops/image/gpl/ridge/Link.java b/scijava-ops-image-gpl/src/main/java/org/scijava/ops/image/gpl/ridge/Link.java new file mode 100644 index 000000000..1754911da --- /dev/null +++ b/scijava-ops-image-gpl/src/main/java/org/scijava/ops/image/gpl/ridge/Link.java @@ -0,0 +1,1214 @@ +/*- + * #%L + * GPL-licensed image processing operations for SciJava Ops, translated from copyleft ImageJ/Fiji plugins. + * %% + * Copyright (C) 2026 SciJava developers. + * %% + * This program 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. + * + * This program 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 this program. If not, see + * . + * #L% + */ + +package org.scijava.ops.image.gpl.ridge; + +import java.util.List; + +import org.scijava.ops.image.gpl.ridge.LinesUtil.MutableDouble; +import org.scijava.ops.image.gpl.ridge.LinesUtil.MutableInt; +import org.scijava.ops.image.gpl.ridge.Threshold.Chord; +import org.scijava.ops.image.gpl.ridge.Threshold.Region; +import org.scijava.ops.image.gpl.ridge.Width.Offset; + +/** + * Linking of line points into lines with hysteresis thresholds, detection of + * junctions, extension of line ends to find additional junctions, and splitting + * of lines at junctions, from the ij-ridgedetection {@code Link} class (a + * translation of the {@code link.c} of Carsten Steger). + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @author Thorsten Wagner + * @author Carsten Steger + */ +class Link { + + static final double MAX_ANGLE_DIFFERENCE = Math.PI / 6.0; + + /* + * This data structure facilitates the quick search for the next possible + * starting point of a line. An array of crossrefs will be accumulated and + * sorted according to its value. x and y are the coordinates of a point in + * the image. When this point has been processed it will be marked as done. + */ + private static final class Crossref implements Comparable { + + short x; + + short y; + + double value; + + boolean done; + + /* Sort by decreasing value. */ + @Override + public int compareTo(final Crossref arg0) { + if (this.value > arg0.value) return -1; + if (this.value < arg0.value) return 1; + return 0; + } + } + + /* + * This table contains the three appropriate neighbor pixels that the linking + * algorithm must examine. It is indexed by the octant the current line angle + * lies in, e.g., 0 if the angle in degrees lies within [-22.5,22.5]. + */ + final static int[][][] dirtab = new int[][][] { { { 1, 0 }, { 1, -1 }, { 1, + 1 } }, { { 1, 1 }, { 1, 0 }, { 0, 1 } }, { { 0, 1 }, { 1, 1 }, { -1, 1 } }, + { { -1, 1 }, { 0, 1 }, { -1, 0 } }, { { -1, 0 }, { -1, 1 }, { -1, -1 } }, { + { -1, -1 }, { -1, 0 }, { 0, -1 } }, { { 0, -1 }, { -1, -1 }, { 1, -1 } }, + { { 1, -1 }, { 0, -1 }, { 1, 0 } } }; + + /* + * This table contains the two neighbor pixels that the linking algorithm + * should examine and mark as processed in case there are double responses. + */ + final static int[][][] cleartab = new int[][][] { { { 0, 1 }, { 0, -1 } }, { { + -1, 1 }, { 1, -1 } }, { { -1, 0 }, { 1, 0 } }, { { -1, -1 }, { 1, 1 } }, { { + 0, -1 }, { 0, 1 } }, { { 1, -1 }, { -1, 1 } }, { { 1, 0 }, { -1, 0 } }, { + { 1, 1 }, { -1, -1 } } }; + + /* + * Compute the response of the operator with sub-pixel accuracy by using the + * facet model to interpolate the pixel accurate responses. + */ + private double interpolateResponse(float[] resp, int x, int y, double px, + double py, int width, int height) + { + double i1, i2, i3, i4, i5, i6, i7, i8, i9; + double t1, t2, t3, t4, t5, t6; + double d, dr, dc, drr, drc, dcc; + double xx, yy; + + i1 = resp[LinesUtil.linCoor(LinesUtil.mirrorRow(x - 1, height), LinesUtil + .mirrorCol(y - 1, width), width)]; + i2 = resp[LinesUtil.linCoor(LinesUtil.mirrorRow(x - 1, height), y, width)]; + i3 = resp[LinesUtil.linCoor(LinesUtil.mirrorRow(x - 1, height), LinesUtil + .mirrorCol(y + 1, width), width)]; + i4 = resp[LinesUtil.linCoor(x, LinesUtil.mirrorCol(y - 1, width), width)]; + i5 = resp[LinesUtil.linCoor(x, y, width)]; + i6 = resp[LinesUtil.linCoor(x, LinesUtil.mirrorCol(y + 1, width), width)]; + i7 = resp[LinesUtil.linCoor(LinesUtil.mirrorRow(x + 1, height), LinesUtil + .mirrorCol(y - 1, width), width)]; + i8 = resp[LinesUtil.linCoor(LinesUtil.mirrorRow(x + 1, height), y, width)]; + i9 = resp[LinesUtil.linCoor(LinesUtil.mirrorRow(x + 1, height), LinesUtil + .mirrorCol(y + 1, width), width)]; + t1 = i1 + i2 + i3; + t2 = i4 + i5 + i6; + t3 = i7 + i8 + i9; + t4 = i1 + i4 + i7; + t5 = i2 + i5 + i8; + t6 = i3 + i6 + i9; + d = (-i1 + 2 * i2 - i3 + 2 * i4 + 5 * i5 + 2 * i6 - i7 + 2 * i8 - i9) / 9; + dr = (t3 - t1) / 6; + dc = (t6 - t4) / 6; + drr = (t1 - 2 * t2 + t3) / 6; + dcc = (t4 - 2 * t5 + t6) / 6; + drc = (i1 - i3 - i7 + i9) / 4; + xx = px - x; + yy = py - y; + return d + xx * dr + yy * dc + xx * xx * drr + xx * yy * drc + yy * yy * + dcc; + } + + /* + * Calculate the closest point to (px,py) on the line (lx,ly) + t*(dx,dy) and + * return the result in (cx,cy), plus the parameter in t. + */ + private void closestPoint(double lx, double ly, double dx, double dy, + double px, double py, MutableDouble cx, MutableDouble cy, MutableDouble t) + { + double mx, my, den, nom, tt; + mx = px - lx; + my = py - ly; + den = dx * dx + dy * dy; + nom = mx * dx + my * dy; + if (den != 0) tt = nom / den; + else tt = 0; + cx.setValue(lx + tt * dx); + cy.setValue(ly + tt * dy); + t.setValue(tt); + } + + /* + * Interpolate the gradient of the gradient images gradx and grady with width + * width at the point (px,py) using linear interpolation, and return the + * result in (gx,gy). + */ + private void interpolateGradient(float[] gradx, float[] grady, double px, + double py, int width, MutableDouble gx, MutableDouble gy) + { + int gix, giy, gpos; + double gfx, gfy, gx1, gy1, gx2, gy2, gx3, gy3, gx4, gy4; + + gix = (int) Math.floor(px); + giy = (int) Math.floor(py); + + gfx = px % 1.0;; + gfy = py % 1.0; + gpos = LinesUtil.linCoor(gix, giy, width); + gx1 = gradx[gpos]; + gy1 = grady[gpos]; + gpos = LinesUtil.linCoor(gix + 1, giy, width); + gx2 = gradx[gpos]; + gy2 = grady[gpos]; + gpos = LinesUtil.linCoor(gix, giy + 1, width); + gx3 = gradx[gpos]; + gy3 = grady[gpos]; + gpos = LinesUtil.linCoor(gix + 1, giy + 1, width); + gx4 = gradx[gpos]; + gy4 = grady[gpos]; + gx.setValue((1 - gfy) * ((1 - gfx) * gx1 + gfx * gx2) + gfy * ((1 - gfx) * + gx3 + gfx * gx4)); + gy.setValue((1 - gfy) * ((1 - gfx) * gy1 + gfx * gy2) + gfy * ((1 - gfx) * + gy3 + gfx * gy4)); + } + + /* + * This function links the line points into lines. The input to this function + * are the response of the filter, i.e., the second directional derivative + * along (nx[l],ny[l]), contained in eigval[l], and the sub-pixel position of + * each line point, contained in (px[l],py[l]). The parameters low and high + * are the hysteresis thresholds for the linking, while width and height are + * the dimensions of the five float-images. The linked lines are returned in + * result, and the number of lines detected is returned in numResult. + */ + void computeContours(byte[] ismax, float[] eigval, float[] normx, + float[] normy, float[] posx, float[] posy, float[] gradx, float[] grady, + Lines contours, MutableInt numResult, double sigma, boolean extendLines, + int mode, double low, double high, int width, int height, + List junctions) + { + int i = 0, j = 0, k, l, it, pos, nextpos, nexti; + int begin, end; + int x, y; + int octant, lastOctant; + int[] label; + int numCont, numPnt; + int sizeCont, sizePnt; + float[] row, col, trow, tcol; + float[] angle, tangle; + float[] resp, tresp; + Junction[] junc; + int numJunc, sizeJunc; + Line[] cont; + Line tmpCont; + ContourClass cls; + double max; + int maxx, maxy; + int nextx, nexty; + double nx, ny; + double alpha, nextalpha, diff, mindiff, dist, mindist; + double beta, lastBeta, diff1, diff2; + double px, py, nextpx = 0, nextpy = 0; + double dx, dy; + float tmp; + int area; + int[] indx; + int indxMax; + boolean nextismax; + Crossref[] cross; + int m = 0, maxLine, numAdd; + MutableInt numLine = new MutableInt(); + double length, response; + Offset[] line; + double mx, my, gx, gy, s, endAngle = 0, endResp = 0; + MutableDouble t = new MutableDouble(); + float[] extx, exty; + boolean addExt; + Region seg = new Region(); + Chord[] rl; + Width w = new Width(); + + /* + * The image label contains information on the pixels that have been + * processed by the linking algorithm. + */ + label = new int[(width * height)]; + + /* + * The image indx is an index into the table of all pixels that possibly + * could be starting points for new lines. It is used to quickly determine + * the next starting point of a line. + */ + indx = new int[(width * height)]; + java.util.Arrays.fill(indx, 0); + + numCont = 0; + numJunc = 0; + sizeCont = LinesUtil.INITIAL_SIZE; + sizePnt = LinesUtil.INITIAL_SIZE; + sizeJunc = LinesUtil.INITIAL_SIZE; + cont = new Line[sizeCont]; + for (int o = 0; o < cont.length; o++) { + cont[o] = new Line(contours); + } + row = new float[sizePnt]; + col = new float[sizePnt]; + angle = new float[sizePnt]; + resp = new float[sizePnt]; + junc = new Junction[sizeJunc]; + for (int o = 0; o < junc.length; o++) { + junc[o] = new Junction(); + } + + /* Select all pixels that can be starting points for lines. */ + Threshold.threshold(ismax, 2, width, height, seg); + + /* Count the number of possible starting points. */ + area = 0; + for (i = 0; i < seg.num; i++) + area += seg.rl[i].ce - seg.rl[i].cb + 1; + + /* Create the index of possible starting points. */ + cross = new Crossref[area]; + for (int o = 0; o < cross.length; o++) { + cross[o] = new Crossref(); + } + k = 0; + rl = seg.rl; + for (i = 0; i < seg.num; i++) { + x = rl[i].r; + for (y = rl[i].cb; y <= rl[i].ce; y++) { + pos = LinesUtil.linCoor(x, y, width); + cross[k].x = (short) x; + cross[k].y = (short) y; + cross[k].value = eigval[pos]; + cross[k].done = false; + k++; + } + } + + java.util.Arrays.sort(cross); + for (i = 0; i < area; i++) + indx[LinesUtil.linCoor(cross[i].x, cross[i].y, width)] = i + 1; + + /* Link lines points. */ + indxMax = 0; + for (;;) { + /* + * Contour class unknown at this point; therefore assume both ends free. + */ + cls = ContourClass.NO_JUNCTION; + /* Search for next starting point. */ + while (indxMax < area && cross[indxMax].done) + indxMax++; + /* Stop if no feasible starting point exists. */ + if (indxMax == area) break; + max = cross[indxMax].value; + maxx = cross[indxMax].x; + maxy = cross[indxMax].y; + if (max == 0.0) break; + + /* Add starting point to the line. */ + numPnt = 0; + pos = LinesUtil.linCoor(maxx, maxy, width); + label[pos] = (numCont + 1); + if (!(indx[pos] == 0)) cross[(indx[pos] - 1)].done = true; + row[numPnt] = posx[pos]; + col[numPnt] = posy[pos]; + /* Select line direction. */ + nx = -normy[pos]; + ny = normx[pos]; + alpha = Math.atan2(ny, nx); + if (alpha < 0.0) alpha += 2.0 * Math.PI; + if (alpha >= Math.PI) alpha -= Math.PI; + octant = (int) (Math.floor(4.0 / Math.PI * alpha + 0.5)) % 4; + /* + * Select normal to the line. The normal points to the right of the line + * as the line is traversed from 0 to num-1. Since the points are sorted + * in reverse order before the second iteration, the first beta actually + * has to point to the left of the line! + */ + beta = alpha + Math.PI / 2.0; + if (beta >= 2.0 * Math.PI) beta -= 2.0 * Math.PI; + angle[numPnt] = (float) beta; + resp[numPnt] = (float) interpolateResponse(eigval, maxx, maxy, posx[pos], + posy[pos], width, height); + numPnt++; + /* Mark double responses as processed. */ + for (i = 0; i < 2; i++) { + nextx = maxx + cleartab[octant][i][0]; + nexty = maxy + cleartab[octant][i][1]; + if (nextx < 0 || nextx >= height || nexty < 0 || nexty >= width) + continue; + nextpos = LinesUtil.linCoor(nextx, nexty, width); + if (ismax[nextpos] > 0) { + nx = -normy[nextpos]; + ny = normx[nextpos]; + nextalpha = Math.atan2(ny, nx); + if (nextalpha < 0.0) nextalpha += 2.0 * Math.PI; + if (nextalpha >= Math.PI) nextalpha -= Math.PI; + diff = Math.abs(alpha - nextalpha); + if (diff >= Math.PI / 2.0) diff = Math.PI - diff; + if (diff < MAX_ANGLE_DIFFERENCE) { + label[nextpos] = (numCont + 1); + if (!(indx[nextpos] == 0)) cross[(indx[nextpos] - 1)].done = true; + } + } + } + + for (it = 1; it <= 2; it++) { + if (it == 1) { + /* + * Search along the initial line direction in the first iteration. + */ + x = maxx; + y = maxy; + pos = LinesUtil.linCoor(x, y, width); + nx = -normy[pos]; + ny = normx[pos]; + alpha = Math.atan2(ny, nx); + if (alpha < 0.0) alpha += 2.0 * Math.PI; + if (alpha >= Math.PI) alpha -= Math.PI; + lastOctant = (int) (Math.floor(4.0 / Math.PI * alpha + 0.5)) % 4; + lastBeta = alpha + Math.PI / 2.0; + if (lastBeta >= 2.0 * Math.PI) lastBeta -= 2.0 * Math.PI; + } + else { + /* Search in the opposite direction in the second iteration. */ + x = maxx; + y = maxy; + pos = LinesUtil.linCoor(x, y, width); + nx = -normy[pos]; + ny = normx[pos]; + alpha = Math.atan2(ny, nx); + if (alpha < 0.0) alpha += 2.0 * Math.PI; + if (alpha >= Math.PI) alpha -= Math.PI; + lastOctant = (int) (Math.floor(4.0 / Math.PI * alpha + 0.5)) % 4 + 4; + lastBeta = alpha + Math.PI / 2.0; + if (lastBeta >= 2.0 * Math.PI) lastBeta -= 2.0 * Math.PI; + } + if (it == 2) { + /* Sort the points found in the first iteration in reverse. */ + for (i = 0; i < numPnt / 2; i++) { + tmp = row[i]; + row[i] = row[(numPnt - 1 - i)]; + row[(numPnt - 1 - i)] = tmp; + tmp = col[i]; + col[i] = col[(numPnt - 1 - i)]; + col[(numPnt - 1 - i)] = tmp; + tmp = angle[i]; + angle[i] = angle[(numPnt - 1 - i)]; + angle[(numPnt - 1 - i)] = tmp; + tmp = resp[i]; + resp[i] = resp[(numPnt - 1 - i)]; + resp[(numPnt - 1 - i)] = tmp; + } + } + + /* Now start adding appropriate neighbors to the line. */ + for (;;) { + pos = LinesUtil.linCoor(x, y, width); + nx = -normy[pos]; + ny = normx[pos]; + px = posx[pos]; + py = posy[pos]; + /* Orient line direction w.r.t. the last line direction. */ + alpha = Math.atan2(ny, nx); + if (alpha < 0.0) alpha += 2.0 * Math.PI; + if (alpha >= Math.PI) alpha -= Math.PI; + octant = (int) (Math.floor(4.0 / Math.PI * alpha + 0.5)) % 4; + switch (octant) { + case 0: + if (lastOctant >= 3 && lastOctant <= 5) octant = 4; + break; + case 1: + if (lastOctant >= 4 && lastOctant <= 6) octant = 5; + break; + case 2: + if (lastOctant >= 4 && lastOctant <= 7) octant = 6; + break; + case 3: + if (lastOctant == 0 || lastOctant >= 6) octant = 7; + break; + } + lastOctant = octant; + + /* Determine appropriate neighbor. */ + nextismax = false; + nexti = 1; + mindiff = Double.MAX_VALUE; + for (i = 0; i < 3; i++) { + nextx = x + dirtab[octant][i][0]; + nexty = y + dirtab[octant][i][1]; + if (nextx < 0 || nextx >= height || nexty < 0 || nexty >= width) + continue; + nextpos = LinesUtil.linCoor(nextx, nexty, width); + if (ismax[nextpos] == 0) continue; + nextpx = posx[nextpos]; + nextpy = posy[nextpos]; + dx = nextpx - px; + dy = nextpy - py; + dist = Math.sqrt(dx * dx + dy * dy); + nx = -normy[nextpos]; + ny = normx[nextpos]; + nextalpha = Math.atan2(ny, nx); + if (nextalpha < 0.0) nextalpha += 2.0 * Math.PI; + if (nextalpha >= Math.PI) nextalpha -= Math.PI; + diff = Math.abs(alpha - nextalpha); + if (diff >= Math.PI / 2.0) diff = Math.PI - diff; + diff = dist + diff; + if (diff < mindiff) { + mindiff = diff; + nexti = i; + } + if (!(ismax[nextpos] == 0)) nextismax = true; + } + + /* Mark double responses as processed. */ + for (i = 0; i < 2; i++) { + nextx = x + cleartab[octant][i][0]; + nexty = y + cleartab[octant][i][1]; + if (nextx < 0 || nextx >= height || nexty < 0 || nexty >= width) + continue; + nextpos = LinesUtil.linCoor(nextx, nexty, width); + if (ismax[nextpos] > 0) { + nx = -normy[nextpos]; + ny = normx[nextpos]; + nextalpha = Math.atan2(ny, nx); + if (nextalpha < 0.0) nextalpha += 2.0 * Math.PI; + if (nextalpha >= Math.PI) nextalpha -= Math.PI; + diff = Math.abs(alpha - nextalpha); + if (diff >= Math.PI / 2.0) diff = Math.PI - diff; + if (diff < MAX_ANGLE_DIFFERENCE) { + label[nextpos] = (numCont + 1); + if (!(indx[nextpos] == 0)) cross[(indx[nextpos] - 1)].done = + true; + } + } + } + + /* Have we found the end of the line? */ + if (!nextismax) break; + /* If not, add the neighbor to the line. */ + x += dirtab[octant][nexti][0]; + y += dirtab[octant][nexti][1]; + if (numPnt >= sizePnt) { + sizePnt = (int) Math.floor((double) (sizePnt * + LinesUtil.REALLOC_FACTOR)); + float[] newArr = new float[sizePnt]; + for (int o = 0; o < row.length; o++) { + newArr[o] = row[o]; + } + row = newArr; + + newArr = new float[sizePnt]; + for (int o = 0; o < col.length; o++) { + newArr[o] = col[o]; + } + col = newArr; + + newArr = new float[sizePnt]; + for (int o = 0; o < angle.length; o++) { + newArr[o] = angle[o]; + } + angle = newArr; + + newArr = new float[sizePnt]; + for (int o = 0; o < resp.length; o++) { + // Note: the plugin assigns resp[o] = resp[o] here, losing the + // responses of long lines. + newArr[o] = resp[o]; + } + resp = newArr; + } + pos = LinesUtil.linCoor(x, y, width); + row[numPnt] = posx[pos]; + col[numPnt] = posy[pos]; + + /* + * Orient normal to the line direction w.r.t. the last normal. + */ + nx = normx[pos]; + ny = normy[pos]; + beta = Math.atan2(ny, nx); + if (beta < 0.0) beta += 2.0 * Math.PI; + if (beta >= Math.PI) beta -= Math.PI; + diff1 = Math.abs(beta - lastBeta); + if (diff1 >= Math.PI) diff1 = 2.0 * Math.PI - diff1; + diff2 = Math.abs(beta + Math.PI - lastBeta); + if (diff2 >= Math.PI) diff2 = 2.0 * Math.PI - diff2; + if (diff1 < diff2) { + angle[numPnt] = (float) beta; + lastBeta = beta; + } + else { + angle[numPnt] = (float) (beta + Math.PI); + lastBeta = beta + Math.PI; + } + + resp[numPnt] = (float) interpolateResponse(eigval, x, y, posx[pos], + posy[pos], width, height); + numPnt++; + + /* + * If the appropriate neighbor is already processed a junction point + * is found. + */ + if (label[pos] > 0) { + if (numJunc >= sizeJunc) { + sizeJunc = (int) Math.floor((double) (sizeJunc * + LinesUtil.REALLOC_FACTOR)); + Junction[] junch = new Junction[sizeJunc]; + for (int o = 0; o < junch.length; o++) { + if (o < junc.length) junch[o] = junc[o]; + else junch[o] = new Junction(); + } + junc = junch; + } + /* Look for the junction point in the other line. */ + k = label[pos] - 1; + if (k == numCont) { + /* Line intersects itself. */ + for (j = 0; j < numPnt - 1; j++) { + if (row[j] == posx[pos] && col[j] == posy[pos]) { + if (j == 0) { + /* Contour is closed. */ + cls = ContourClass.CLOSED; + for (i = 0; i < numPnt / 2; i++) { + tmp = row[i]; + row[i] = row[(numPnt - 1 - i)]; + row[(numPnt - 1 - i)] = tmp; + tmp = col[i]; + col[i] = col[(numPnt - 1 - i)]; + col[(numPnt - 1 - i)] = tmp; + tmp = angle[i]; + angle[i] = angle[(numPnt - 1 - i)]; + angle[(numPnt - 1 - i)] = tmp; + tmp = resp[i]; + resp[i] = resp[(numPnt - 1 - i)]; + resp[(numPnt - 1 - i)] = tmp; + } + it = 2; + } + else { + if (it == 2) { + /* Determine contour class. */ + if (cls == ContourClass.START_JUNCTION) cls = + ContourClass.BOTH_JUNCTIONS; + else cls = ContourClass.END_JUNCTION; + /* Index j is the correct index. */ + junc[numJunc].cont1 = numCont; + junc[numJunc].cont2 = numCont; + junc[numJunc].pos = j; + junc[numJunc].x = posx[pos]; + junc[numJunc].y = posy[pos]; + numJunc++; + } + else { + /* Determine contour class. */ + cls = ContourClass.START_JUNCTION; + /* + * Index numPnt-1-j is the correct index since the line is + * going to be sorted in reverse. + */ + junc[numJunc].cont1 = numCont; + junc[numJunc].cont2 = numCont; + junc[numJunc].pos = numPnt - 1 - j; + junc[numJunc].x = posx[pos]; + junc[numJunc].y = posy[pos]; + numJunc++; + } + } + break; + } + } + /* + * Mark this case as being processed for the algorithm below. + */ + j = -1; + } + else { + + for (j = 0; j < cont[k].num; j++) { + if (cont[k].row[j] == posx[pos] && cont[k].col[j] == posy[pos]) + break; + } + /* + * If no point can be found on the other line a double response + * must have occured. In this case, find the nearest point on the + * other line and add it to the current line. + */ + if (j == cont[k].num) { + mindist = Double.MAX_VALUE; + j = -1; + for (l = 0; l < cont[k].num; l++) { + dx = posx[pos] - cont[k].row[l]; + dy = posy[pos] - cont[k].col[l]; + dist = Math.sqrt(dx * dx + dy * dy); + if (dist < mindist) { + mindist = dist; + j = l; + } + } + /* + * Add the point with index j to the current line. + */ + if (numPnt >= sizePnt) { + sizePnt = (int) Math.floor((double) (sizePnt * + LinesUtil.REALLOC_FACTOR)); + float[] newArr = new float[sizePnt]; + for (int o = 0; o < row.length; o++) { + newArr[o] = row[o]; + } + row = newArr; + newArr = new float[sizePnt]; + for (int o = 0; o < col.length; o++) { + newArr[o] = col[o]; + } + col = newArr; + newArr = new float[sizePnt]; + for (int o = 0; o < angle.length; o++) { + newArr[o] = angle[o]; + } + angle = newArr; + newArr = new float[sizePnt]; + for (int o = 0; o < resp.length; o++) { + // Note: the plugin assigns resp[o] = resp[o] here. + newArr[o] = resp[o]; + } + resp = newArr; + } + + row[numPnt] = cont[k].row[j]; + col[numPnt] = cont[k].col[j]; + beta = cont[k].angle[j]; + if (beta >= Math.PI) beta -= Math.PI; + diff1 = Math.abs(beta - lastBeta); + if (diff1 >= Math.PI) diff1 = 2.0 * Math.PI - diff1; + diff2 = Math.abs(beta + Math.PI - lastBeta); + if (diff2 >= Math.PI) diff2 = 2.0 * Math.PI - diff2; + if (diff1 < diff2) angle[numPnt] = (float) beta; + else angle[numPnt] = (float) (beta + Math.PI); + resp[numPnt] = cont[k].response[j]; + numPnt++; + } + } + /* + * Add the junction point only if it is not one of the other line's + * endpoints. + */ + if (j > 0 && j < cont[k].num - 1) { + /* Determine contour class. */ + if (it == 1) cls = ContourClass.START_JUNCTION; + else if (cls == ContourClass.START_JUNCTION) cls = + ContourClass.BOTH_JUNCTIONS; + else cls = ContourClass.END_JUNCTION; + /* Add the new junction. */ + + junc[numJunc].cont1 = k; + junc[numJunc].cont2 = numCont; + junc[numJunc].pos = j; + junc[numJunc].x = row[(numPnt - 1)]; + junc[numJunc].y = col[(numPnt - 1)]; + numJunc++; + } + break; + } + label[pos] = (numCont + 1); + if (!(indx[pos] == 0)) cross[(indx[pos] - 1)].done = true; + } + } + + if (numPnt > 1) { + /* Only add lines with at least two points. */ + if (numCont >= sizeCont) { + sizeCont = (int) Math.floor((double) (sizeCont * + LinesUtil.REALLOC_FACTOR)); + Line[] conth = new Line[sizeCont]; + for (int o = 0; o < conth.length; o++) { + if (o < cont.length) conth[o] = cont[o]; + else conth[o] = new Line(contours); + } + cont = conth; + } + cont[numCont] = new Line(contours); + + cont[numCont].row = java.util.Arrays.copyOf(row, numPnt); + cont[numCont].col = java.util.Arrays.copyOf(col, numPnt); + cont[numCont].angle = java.util.Arrays.copyOf(angle, numPnt); + cont[numCont].response = java.util.Arrays.copyOf(resp, numPnt); + + cont[numCont].widthR = null; + cont[numCont].widthL = null; + cont[numCont].asymmetry = null; + cont[numCont].intensity = null; + cont[numCont].num = numPnt; + cont[numCont].setContourClass(cls); + numCont++; + } + else { + /* + * Delete the point from the label image; we can use maxx and maxy as + * the coordinates in the label image in this case. + */ + for (i = -1; i <= 1; i++) { + for (j = -1; j <= 1; j++) { + pos = LinesUtil.linCoor(LinesUtil.mirrorRow(maxx + i, height), + LinesUtil.mirrorCol(maxy + j, width), width); + if (label[pos] == numCont + 1) label[pos] = 0; + } + } + } + } + + /* + * Now try to extend the lines at their ends to find additional junctions. + */ + if (extendLines) { + /* Sign by which the gradient has to be multiplied below. */ + if (mode == LinesUtil.MODE_LIGHT) s = 1; + else s = -1; + double MAX_LINE_EXTENSION = 2.5 * sigma; + length = MAX_LINE_EXTENSION; + maxLine = (int) Math.ceil(length * 3); + line = new Offset[maxLine]; + for (int o = 0; o < line.length; o++) { + line[o] = new Offset(); + } + extx = new float[maxLine]; + exty = new float[maxLine]; + for (i = 0; i < numCont; i++) { + tmpCont = cont[i]; + numPnt = tmpCont.num; + if (numPnt == 1) continue; + if (tmpCont.getContourClass() == ContourClass.CLOSED) continue; + trow = tmpCont.row; + tcol = tmpCont.col; + tangle = tmpCont.angle; + tresp = tmpCont.response; + /* Check both ends of the line (it==-1: start, it==1: end). */ + for (it = -1; it <= 1; it += 2) { + /* + * Determine the direction of the search line. This is done by using + * the normal to the line (angle). Since this normal may point to the + * left of the line (see below) we have to check for this case by + * comparing the normal to the direction of the line at its respective + * end point. + */ + if (it == -1) { + /* Start point of the line. */ + if (tmpCont.getContourClass() == ContourClass.START_JUNCTION || + tmpCont.getContourClass() == ContourClass.BOTH_JUNCTIONS) + continue; + dx = trow[1] - trow[0]; + dy = tcol[1] - tcol[0]; + alpha = tangle[0]; + nx = Math.cos(alpha); + ny = Math.sin(alpha); + if (nx * dy - ny * dx < 0) { + /* Turn the normal by +90 degrees. */ + mx = -ny; + my = nx; + } + else { + /* Turn the normal by -90 degrees. */ + mx = ny; + my = -nx; + } + px = trow[0]; + py = tcol[0]; + response = tresp[0]; + } + else { + /* End point of the line. */ + if (tmpCont.getContourClass() == ContourClass.END_JUNCTION || + tmpCont.getContourClass() == ContourClass.BOTH_JUNCTIONS) + continue; + dx = trow[(numPnt - 1)] - trow[(numPnt - 2)]; + dy = tcol[(numPnt - 1)] - tcol[(numPnt - 2)]; + alpha = tangle[(numPnt - 1)]; + nx = Math.cos(alpha); + ny = Math.sin(alpha); + if (nx * dy - ny * dx < 0) { + /* Turn the normal by -90 degrees. */ + mx = ny; + my = -nx; + } + else { + /* Turn the normal by +90 degrees. */ + mx = -ny; + my = nx; + } + px = trow[(numPnt - 1)]; + py = tcol[(numPnt - 1)]; + response = tresp[(numPnt - 1)]; + } + /* + * Determine the current pixel and calculate the pixels on the search + * line. + */ + x = (int) Math.floor(px + 0.5); + y = (int) Math.floor(py + 0.5); + dx = px - x; + dy = py - y; + w.bresenham(mx, my, dx, dy, length, line, numLine); + /* + * Now determine whether we can go only uphill (bright lines) or + * downhill (dark lines) until we hit another line. + */ + numAdd = 0; + addExt = false; + for (k = 0; k < numLine.intValue(); k++) { + nextx = x + line[k].x; + nexty = y + line[k].y; + MutableDouble hnextpx = new MutableDouble(nextpx); + MutableDouble hnextpy = new MutableDouble(nextpy); + closestPoint(px, py, mx, my, (double) nextx, (double) nexty, + hnextpx, hnextpy, t); + nextpx = hnextpx.getValue(); + nextpy = hnextpy.getValue(); + /* + * Ignore points before or less than half a pixel away from the true + * end point of the line. + */ + if (t.getValue() <= 0.5) continue; + /* + * Stop if the gradient can't be interpolated any more or if the + * next point lies outside the image. + */ + if (nextpx < 0 || nextpy < 0 || nextpx >= height - 1 || + nextpy >= width - 1 || nextx < 0 || nexty < 0 || + nextx >= height || nexty >= width) break; + MutableDouble hgx = new MutableDouble(); + MutableDouble hgy = new MutableDouble(); + interpolateGradient(gradx, grady, nextpx, nextpy, width, hgx, hgy); + gx = hgx.getValue(); + gy = hgy.getValue(); + /* + * Stop if we can't go uphill anymore. This is determined by the dot + * product of the line direction and the gradient. If it is smaller + * than 0 we go downhill (reverse for dark lines). + */ + nextpos = LinesUtil.linCoor(nextx, nexty, width); + if (s * (mx * gx + my * gy) < 0 && label[nextpos] == 0) break; + /* Have we hit another line? */ + if (label[nextpos] > 0) { + m = label[nextpos] - 1; + /* Search for the junction point on the other line. */ + mindist = Double.MAX_VALUE; + j = -1; + for (l = 0; l < cont[m].num; l++) { + dx = nextpx - cont[m].row[l]; + dy = nextpy - cont[m].col[l]; + dist = Math.sqrt(dx * dx + dy * dy); + if (dist < mindist) { + mindist = dist; + j = l; + } + } + /* + * This should not happen... But better safe than sorry... + */ + if (mindist > 3.0) { + break; + } + extx[numAdd] = cont[m].row[j]; + exty[numAdd] = cont[m].col[j]; + endResp = cont[m].response[j]; + endAngle = cont[m].angle[j]; + beta = endAngle; + if (beta >= Math.PI) beta -= Math.PI; + diff1 = Math.abs(beta - alpha); + if (diff1 >= Math.PI) diff1 = 2.0 * Math.PI - diff1; + diff2 = Math.abs(beta + Math.PI - alpha); + if (diff2 >= Math.PI) diff2 = 2.0 * Math.PI - diff2; + if (diff1 < diff2) endAngle = beta; + else endAngle = beta + Math.PI; + numAdd++; + addExt = true; + break; + } + else { + extx[numAdd] = (float) nextpx; + exty[numAdd] = (float) nextpy; + numAdd++; + } + } + if (addExt) { + /* Make room for the new points. */ + numPnt += numAdd; + float[] newArr = new float[numPnt]; + for (int o = 0; o < trow.length; o++) { + newArr[o] = trow[o]; + } + trow = newArr; + + newArr = new float[numPnt]; + for (int o = 0; o < tcol.length; o++) { + newArr[o] = tcol[o]; + } + tcol = newArr; + + newArr = new float[numPnt]; + for (int o = 0; o < tangle.length; o++) { + newArr[o] = tangle[o]; + } + tangle = newArr; + + newArr = new float[numPnt]; + for (int o = 0; o < tresp.length; o++) { + newArr[o] = tresp[o]; + } + tresp = newArr; + + tmpCont.row = trow; + tmpCont.col = tcol; + tmpCont.angle = tangle; + tmpCont.response = tresp; + tmpCont.num = numPnt; + if (it == -1) { + /* Move points on the line up numAdd places. */ + for (k = numPnt - 1 - numAdd; k >= 0; k--) { + trow[(k + numAdd)] = trow[k]; + tcol[(k + numAdd)] = tcol[k]; + tangle[(k + numAdd)] = tangle[k]; + tresp[(k + numAdd)] = tresp[k]; + } + /* Insert points at the beginning of the line. */ + for (k = 0; k < numAdd; k++) { + trow[k] = extx[(numAdd - 1 - k)]; + tcol[k] = exty[(numAdd - 1 - k)]; + tangle[k] = (float) alpha; + tresp[k] = (float) response; + } + tangle[0] = (float) endAngle; + tresp[0] = (float) endResp; + /* Adapt indices of the previously found junctions. */ + for (k = 0; k < numJunc; k++) { + if (junc[k].cont1 == i) junc[k].pos += numAdd; + } + } + else { + /* Insert points at the end of the line. */ + for (k = 0; k < numAdd; k++) { + trow[(numPnt - numAdd + k)] = extx[k]; + tcol[(numPnt - numAdd + k)] = exty[k]; + tangle[(numPnt - numAdd + k)] = (float) alpha; + tresp[(numPnt - numAdd + k)] = (float) response; + } + tangle[(numPnt - 1)] = (float) endAngle; + tresp[(numPnt - 1)] = (float) endResp; + } + /* If necessary, make room for the new junction. */ + if (numJunc >= sizeJunc) { + sizeJunc = (int) Math.floor((double) (sizeJunc * + LinesUtil.REALLOC_FACTOR)); + Junction[] junch = new Junction[sizeJunc]; + for (int o = 0; o < junch.length; o++) { + if (o < junc.length) junch[o] = junc[o]; + else junch[o] = new Junction(); + } + junc = junch; + + } + /* + * Add the junction point only if it is not one of the other line's + * endpoints. + */ + if (j > 0 && j < cont[m].num - 1) { + if (it == -1) { + if (tmpCont.getContourClass() == ContourClass.END_JUNCTION) + tmpCont.setContourClass(ContourClass.BOTH_JUNCTIONS); + else tmpCont.setContourClass(ContourClass.START_JUNCTION); + } + else { + if (tmpCont.getContourClass() == ContourClass.START_JUNCTION) + tmpCont.setContourClass(ContourClass.BOTH_JUNCTIONS); + else tmpCont.setContourClass(ContourClass.END_JUNCTION); + } + junc[numJunc].cont1 = m; + junc[numJunc].cont2 = i; + junc[numJunc].pos = j; + if (it == -1) { + junc[numJunc].x = trow[0]; + junc[numJunc].y = tcol[0]; + } + else { + junc[numJunc].x = trow[(numPnt - 1)]; + junc[numJunc].y = tcol[(numPnt - 1)]; + } + numJunc++; + } + } + } + + } + } + + /* Done with linking. Now split the lines at the junction points. */ + java.util.Arrays.sort(junc); + for (i = 0; i < numJunc; i += k) { + j = junc[i].cont1; + tmpCont = cont[j]; + numPnt = tmpCont.num; + /* Count how often line j needs to be split. */ + for (k = 0; junc[(i + k)].cont1 == j && i + k < numJunc; k++); + + if (k == 1 && tmpCont.row[0] == tmpCont.row[(numPnt - 1)] && + tmpCont.col[0] == tmpCont.col[(numPnt - 1)]) + { + /* + * If only one junction point is found and the line is closed it only + * needs to be rearranged cyclically, but not split. + */ + begin = junc[i].pos; + trow = tmpCont.row; + tcol = tmpCont.col; + tangle = tmpCont.angle; + tresp = tmpCont.response; + tmpCont.row = new float[numPnt]; + tmpCont.col = new float[numPnt]; + tmpCont.angle = new float[numPnt]; + tmpCont.response = new float[numPnt]; + for (l = 0; l < numPnt; l++) { + pos = begin + l; + /* Skip starting point so that it is not added twice. */ + if (pos >= numPnt) pos = begin + l - numPnt + 1; + tmpCont.row[l] = trow[pos]; + tmpCont.col[l] = tcol[pos]; + tmpCont.angle[l] = tangle[pos]; + tmpCont.response[l] = tresp[pos]; + } + /* Modify contour class. */ + tmpCont.setContourClass(ContourClass.BOTH_JUNCTIONS); + + } + else { + /* Otherwise the line has to be split. */ + for (l = 0; l <= k; l++) { + if (l == 0) begin = 0; + else begin = junc[(i + l - 1)].pos; + if (l == k) end = tmpCont.num - 1; + else end = junc[(i + l)].pos; + numPnt = end - begin + 1; + if (numPnt == 1 && k > 1) { + /* Do not add one point segments. */ + continue; + } + if (numCont >= sizeCont) { + sizeCont = (int) Math.floor((double) (sizeCont * + LinesUtil.REALLOC_FACTOR)); + Line[] conth = new Line[sizeCont]; + for (int o = 0; o < cont.length; o++) { + conth[o] = cont[o]; + } + cont = conth; + } + cont[numCont] = new Line(contours); + + cont[numCont].row = new float[numPnt]; + cont[numCont].col = new float[numPnt]; + cont[numCont].angle = new float[numPnt]; + cont[numCont].response = new float[numPnt]; + + System.arraycopy(tmpCont.row, begin, cont[numCont].row, 0, numPnt); + + System.arraycopy(tmpCont.col, begin, cont[numCont].col, 0, numPnt); + + System.arraycopy(tmpCont.angle, begin, cont[numCont].angle, 0, + numPnt); + + System.arraycopy(tmpCont.response, begin, cont[numCont].response, 0, + numPnt); + + cont[numCont].widthR = null; + cont[numCont].widthL = null; + cont[numCont].asymmetry = null; + cont[numCont].intensity = null; + cont[numCont].num = numPnt; + /* Modify contour class. */ + if (l == 0) { + if (tmpCont.getContourClass() == ContourClass.START_JUNCTION || + tmpCont.getContourClass() == ContourClass.BOTH_JUNCTIONS) + cont[numCont].setContourClass(ContourClass.BOTH_JUNCTIONS); + else cont[numCont].setContourClass(ContourClass.END_JUNCTION); + } + else if (l == k) { + if (tmpCont.getContourClass() == ContourClass.END_JUNCTION || + tmpCont.getContourClass() == ContourClass.BOTH_JUNCTIONS) + cont[numCont].setContourClass(ContourClass.BOTH_JUNCTIONS); + else cont[numCont].setContourClass(ContourClass.START_JUNCTION); + } + else { + cont[numCont].setContourClass(ContourClass.BOTH_JUNCTIONS); + } + numCont++; + } + cont[j] = cont[--numCont]; + } + } + + /* Finally, check whether all angles point to the right of the line. */ + for (i = 0; i < numCont; i++) { + tmpCont = cont[i]; + numPnt = tmpCont.num; + if (numPnt > 1) { + trow = tmpCont.row; + tcol = tmpCont.col; + tangle = tmpCont.angle; + /* + * One point of the contour is enough to determine the orientation. + */ + + k = (numPnt - 1) / 2; + /* + * The next few lines are ok because lines have at least two points. + */ + dx = trow[(k + 1)] - trow[k]; + dy = tcol[(k + 1)] - tcol[k]; + nx = Math.cos(tangle[k]); + ny = Math.sin(tangle[k]); + /* + * If the angles point to the left of the line they have to be adapted. + * The orientation is determined by looking at the z-component of the + * cross-product of (dx,dy,0) and (nx,ny,0). + */ + if (nx * dy - ny * dx < 0) { + for (j = 0; j < numPnt; j++) { + tangle[j] += Math.PI; + if (tangle[j] >= 2 * Math.PI) tangle[j] -= 2 * Math.PI; + } + } + } + } + + for (Line c : cont) { + if (c != null && c.getContourClass() != null) { + contours.add(c); + + } + } + + for (Junction jun : junc) { + if (jun != null && !(jun.cont1 == 0 && jun.cont2 == 0)) { + junctions.add(jun); + } + } + numResult.setValue(numCont); + } + +} diff --git a/scijava-ops-image-gpl/src/main/java/org/scijava/ops/image/gpl/ridge/Normal.java b/scijava-ops-image-gpl/src/main/java/org/scijava/ops/image/gpl/ridge/Normal.java new file mode 100644 index 000000000..e399fd7da --- /dev/null +++ b/scijava-ops-image-gpl/src/main/java/org/scijava/ops/image/gpl/ridge/Normal.java @@ -0,0 +1,170 @@ +/*- + * #%L + * GPL-licensed image processing operations for SciJava Ops, translated from copyleft ImageJ/Fiji plugins. + * %% + * Copyright (C) 2026 SciJava developers. + * %% + * This program 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. + * + * This program 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 this program. If not, see + * . + * #L% + */ + +package org.scijava.ops.image.gpl.ridge; + +/** + * The cumulative normal distribution function, computed with the rational + * Chebyshev approximations of the error function of W. J. Cody (1969), from the + * ij-ridgedetection {@code Normal} class (a translation of the {@code normal.c} + * of Carsten Steger). + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @author Thorsten Wagner + * @author Carsten Steger + */ +final class Normal { + + private Normal() { + // Prevent instantiation of utility class. + } + + private static final double SQRTPI = 1.772453850905516027; + + private static final double UPPERLIMIT = 20.0; + + private static final double P10 = 242.66795523053175; + + private static final double P11 = 21.979261618294152; + + private static final double P12 = 6.9963834886191355; + + private static final double P13 = -.035609843701815385; + + private static final double Q10 = 215.05887586986120; + + private static final double Q11 = 91.164905404514901; + + private static final double Q12 = 15.082797630407787; + + private static final double Q13 = 1.0; + + private static final double P20 = 300.4592610201616005; + + private static final double P21 = 451.9189537118729422; + + private static final double P22 = 339.3208167343436870; + + private static final double P23 = 152.9892850469404039; + + private static final double P24 = 43.16222722205673530; + + private static final double P25 = 7.211758250883093659; + + private static final double P26 = .5641955174789739711; + + private static final double P27 = -.0000001368648573827167067; + + private static final double Q20 = 300.4592609569832933; + + private static final double Q21 = 790.9509253278980272; + + private static final double Q22 = 931.3540948506096211; + + private static final double Q23 = 638.9802644656311665; + + private static final double Q24 = 277.5854447439876434; + + private static final double Q25 = 77.00015293522947295; + + private static final double Q26 = 12.78272731962942351; + + private static final double Q27 = 1.0; + + private static final double P30 = -.00299610707703542174; + + private static final double P31 = -.0494730910623250734; + + private static final double P32 = -.226956593539686930; + + private static final double P33 = -.278661308609647788; + + private static final double P34 = -.0223192459734184686; + + private static final double Q30 = .0106209230528467918; + + private static final double Q31 = .191308926107829841; + + private static final double Q32 = 1.05167510706793207; + + private static final double Q33 = 1.98733201817135256; + + private static final double Q34 = 1.0; + + private static final double SQRT2 = 1.41421356237309504880; + + static double getNormal(double x) { + int sn; + double R1, R2, y, y2, y3, y4, y5, y6, y7; + double erf, erfc, z, z2, z3, z4; + double phi; + + if (x < -UPPERLIMIT) return 0.0; + if (x > UPPERLIMIT) return 1.0; + + y = x / SQRT2; + if (y < 0) { + y = -y; + sn = -1; + } + else sn = 1; + + y2 = y * y; + y4 = y2 * y2; + y6 = y4 * y2; + + if (y < 0.46875) { + R1 = P10 + P11 * y2 + P12 * y4 + P13 * y6; + R2 = Q10 + Q11 * y2 + Q12 * y4 + Q13 * y6; + erf = y * R1 / R2; + if (sn == 1) phi = 0.5 + 0.5 * erf; + else phi = 0.5 - 0.5 * erf; + } + else if (y < 4.0) { + y3 = y2 * y; + y5 = y4 * y; + y7 = y6 * y; + R1 = P20 + P21 * y + P22 * y2 + P23 * y3 + P24 * y4 + P25 * y5 + P26 * + y6 + P27 * y7; + R2 = Q20 + Q21 * y + Q22 * y2 + Q23 * y3 + Q24 * y4 + Q25 * y5 + Q26 * + y6 + Q27 * y7; + erfc = Math.exp(-y2) * R1 / R2; + if (sn == 1) phi = 1.0 - 0.5 * erfc; + else phi = 0.5 * erfc; + } + else { + z = y4; + z2 = z * z; + z3 = z2 * z; + z4 = z2 * z2; + R1 = P30 + P31 * z + P32 * z2 + P33 * z3 + P34 * z4; + R2 = Q30 + Q31 * z + Q32 * z2 + Q33 * z3 + Q34 * z4; + erfc = (Math.exp(-y2) / y) * (1.0 / SQRTPI + R1 / (R2 * y2)); + if (sn == 1) phi = 1.0 - 0.5 * erfc; + else phi = 0.5 * erfc; + } + + return phi; + } + +} diff --git a/scijava-ops-image-gpl/src/main/java/org/scijava/ops/image/gpl/ridge/OverlapResolution.java b/scijava-ops-image-gpl/src/main/java/org/scijava/ops/image/gpl/ridge/OverlapResolution.java new file mode 100644 index 000000000..a5f88c23d --- /dev/null +++ b/scijava-ops-image-gpl/src/main/java/org/scijava/ops/image/gpl/ridge/OverlapResolution.java @@ -0,0 +1,43 @@ +/*- + * #%L + * GPL-licensed image processing operations for SciJava Ops, translated from copyleft ImageJ/Fiji plugins. + * %% + * Copyright (C) 2026 SciJava developers. + * %% + * This program 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. + * + * This program 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 this program. If not, see + * . + * #L% + */ + +package org.scijava.ops.image.gpl.ridge; + +/** + * How the line detector resolves lines that overlap (cross) each other. + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @author Mark Hiner + */ +public enum OverlapResolution { + + /** Lines are split at each junction, and not merged again. */ + NONE, + + /** + * Line fragments meeting at junctions are merged so that the merged lines + * are as straight as possible, assuming that a straight crossing is more + * likely than two touching turns. + */ + SLOPE +} diff --git a/scijava-ops-image-gpl/src/main/java/org/scijava/ops/image/gpl/ridge/Position.java b/scijava-ops-image-gpl/src/main/java/org/scijava/ops/image/gpl/ridge/Position.java new file mode 100644 index 000000000..59635bdb5 --- /dev/null +++ b/scijava-ops-image-gpl/src/main/java/org/scijava/ops/image/gpl/ridge/Position.java @@ -0,0 +1,251 @@ +/*- + * #%L + * GPL-licensed image processing operations for SciJava Ops, translated from copyleft ImageJ/Fiji plugins. + * %% + * Copyright (C) 2026 SciJava developers. + * %% + * This program 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. + * + * This program 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 this program. If not, see + * . + * #L% + */ + +package org.scijava.ops.image.gpl.ridge; + +import java.util.List; + +import org.scijava.ops.image.gpl.ridge.LinesUtil.MutableDouble; +import org.scijava.ops.image.gpl.ridge.LinesUtil.MutableInt; + +/** + * Extraction of line points with sub-pixel accuracy from the Hessian of the + * image, from the ij-ridgedetection {@code Position} class (a translation of + * the {@code position.c} of Carsten Steger). + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @author Thorsten Wagner + * @author Carsten Steger + */ +class Position { + + /* + * The pixel boundaries need to be enlarged slightly since in practice it + * frequently happens for neighboring pixels a and b that pixel a says a + * maximum lies within pixel b and vice versa. This presents no problem since + * linking algoritm will take care of this. + */ + private static final double PIXEL_BOUNDARY = 0.6; + + /** + * Solve the linear equation a*x+b=0 and return the result in t and the number + * of solutions in num. + */ + void solveLinear(double a, double b, MutableDouble t, MutableInt num) { + + if (a == 0.0) { // + num.setValue(0); + return; + } + else { + num.setValue(1); + t.setValue(-b / a); + return; + } + } + + /** + * Compute the eigenvalues and eigenvectors of the Hessian matrix given by + * dfdrr, dfdrc, and dfdcc, and sort them in descending order according to + * their absolute values. + */ + void computeEigenvals(double dfdrr, double dfdrc, double dfdcc, + double[] eigval, double[][] eigvec) + { + double theta, t, c, s, e1, e2, n1, n2; + + /* Compute the eigenvalues and eigenvectors of the Hessian matrix. */ + if (dfdrc != 0.0) { + theta = 0.5 * (dfdcc - dfdrr) / dfdrc; + t = 1.0 / (Math.abs(theta) + Math.sqrt(theta * theta + 1.0)); + if (theta < 0.0) t = -t; + c = 1.0 / Math.sqrt(t * t + 1.0); + s = t * c; + e1 = dfdrr - t * dfdrc; + e2 = dfdcc + t * dfdrc; + } + else { + c = 1.0; + s = 0.0; + e1 = dfdrr; + e2 = dfdcc; + } + n1 = c; + n2 = -s; + + /* + * If the absolute value of an eigenvalue is larger than the other, put that + * eigenvalue into first position. If both are of equal absolute value, put + * the negative one first. + */ + if (Math.abs(e1) > Math.abs(e2)) { + eigval[0] = e1; + eigval[1] = e2; + eigvec[0][0] = n1; + eigvec[0][1] = n2; + eigvec[1][0] = -n2; + eigvec[1][1] = n1; + } + else if (Math.abs(e1) < Math.abs(e2)) { + eigval[0] = e2; + eigval[1] = e1; + eigvec[0][0] = -n2; + eigvec[0][1] = n1; + eigvec[1][0] = n1; + eigvec[1][1] = n2; + } + else { + if (e1 < e2) { + eigval[0] = e1; + eigval[1] = e2; + eigvec[0][0] = n1; + eigvec[0][1] = n2; + eigvec[1][0] = -n2; + eigvec[1][1] = n1; + } + else { + eigval[0] = e2; + eigval[1] = e1; + eigvec[0][0] = -n2; + eigvec[0][1] = n1; + eigvec[1][0] = n1; + eigvec[1][1] = n2; + } + } + } + + /* + * For each point in the image determine whether there is a local maximum of + * the second directional derivative in the direction (nx[l],ny[l]) within the + * pixels's boundaries. If so, set ismax[l] to 2 if the eigenvalue ev[l] is + * larger than high, to 1 if ev[l] is larger than low, and to 0 otherwise. + * Furthermore, put the sub-pixel position of the maximum into (px[l],py[l]). + * The parameter mode determines whether maxima (dark lines points) or minima + * (bright line points) should be selected. The partial derivatives of the + * image are input as ku[]. + */ + private void computeLinePoints(float[][] ku, byte[] ismax, float[] ev, + float[] nx, float[] ny, float[] px, float[] py, int width, int height, + double low, double high, int mode) + { + int r, c, l; + double[] k = new double[5]; + double[] eigval = new double[2]; + double[][] eigvec = new double[2][2]; + double a, b; + MutableDouble t = new MutableDouble(); + MutableInt num = new MutableInt(); + double n1, n2; + double p1, p2; + double val; + + for (r = 0; r < height; r++) { + for (c = 0; c < width; c++) { + l = LinesUtil.linCoor(r, c, width); + + k[0] = ku[0][l]; + k[1] = ku[1][l]; + k[2] = ku[2][l]; + k[3] = ku[3][l]; + k[4] = ku[4][l]; + ev[l] = (float) 0.0; + nx[l] = (float) 0.0; + ny[l] = (float) 0.0; + computeEigenvals(k[2], k[3], k[4], eigval, eigvec); + if (mode == LinesUtil.MODE_LIGHT) val = -eigval[0]; + else val = eigval[0]; + if (val > 0.0) { + ev[l] = (float) val; + n1 = eigvec[0][0]; + n2 = eigvec[0][1]; + a = k[2] * n1 * n1 + 2.0 * k[3] * n1 * n2 + k[4] * n2 * n2; + b = k[0] * n1 + k[1] * n2; + solveLinear(a, b, t, num); + if (num.intValue() != 0) { + p1 = t.doubleValue() * n1; + p2 = t.doubleValue() * n2; + if (Math.abs(p1) <= PIXEL_BOUNDARY && Math.abs( + p2) <= PIXEL_BOUNDARY) + { + if (val >= low) { + if (val >= high) ismax[l] = 2; + else ismax[l] = 1; + } + nx[l] = (float) n1; + ny[l] = (float) n2; + px[l] = (float) (r + p1); + py[l] = (float) (c + p2); + } + } + } + } + } + } + + /* + * Main routine to detect lines in an image of dimension width * height. The + * extracted lines are returned in result, while numResult is the number of + * detected lines. The parameter sigma is the amount of smoothing that the + * Gaussian kernel performs, while low and high are the hysteresis thresholds + * used in the linking algorithm. With mode, either bright or dark lines can + * be selected. The parameter computeWidth determines whether the line width + * should be extracted, while correctPos determines whether the line width and + * position correction should be applied. + */ + void findLines(float[] image, int width, int height, Lines contours, + MutableInt numResult, double sigma, double low, double high, int mode, + boolean computeWidth, boolean correctPos, boolean extendLines, + List junctions) + { + byte[] ismax; + float[] ev, n1, n2, p1, p2; + float[][] k = new float[5][(width * height)]; + Convol convol = new Convol(); + convol.convolveGauss(image, k[0], width, height, sigma, LinesUtil.DERIV_R); + convol.convolveGauss(image, k[1], width, height, sigma, LinesUtil.DERIV_C); + convol.convolveGauss(image, k[2], width, height, sigma, LinesUtil.DERIV_RR); + convol.convolveGauss(image, k[3], width, height, sigma, LinesUtil.DERIV_RC); + + convol.convolveGauss(image, k[4], width, height, sigma, LinesUtil.DERIV_CC); + + ismax = new byte[(width * height)]; + ev = new float[(width * height)]; + n1 = new float[(width * height)]; + n2 = new float[(width * height)]; + p1 = new float[(width * height)]; + p2 = new float[(width * height)]; + + computeLinePoints(k, ismax, ev, n1, n2, p1, p2, width, height, low, high, + mode); + + Link l = new Link(); + l.computeContours(ismax, ev, n1, n2, p1, p2, k[0], k[1], contours, + numResult, sigma, extendLines, mode, low, high, width, height, junctions); + + Width w = new Width(); + if (computeWidth) w.computeLineWidth(k[0], k[1], width, height, sigma, mode, + correctPos, contours, numResult); + + } + +} diff --git a/scijava-ops-image-gpl/src/main/java/org/scijava/ops/image/gpl/ridge/SlopeOverlapResolver.java b/scijava-ops-image-gpl/src/main/java/org/scijava/ops/image/gpl/ridge/SlopeOverlapResolver.java new file mode 100644 index 000000000..447987fa5 --- /dev/null +++ b/scijava-ops-image-gpl/src/main/java/org/scijava/ops/image/gpl/ridge/SlopeOverlapResolver.java @@ -0,0 +1,801 @@ +/*- + * #%L + * GPL-licensed image processing operations for SciJava Ops, translated from copyleft ImageJ/Fiji plugins. + * %% + * Copyright (C) 2026 SciJava developers. + * %% + * This program 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. + * + * This program 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 this program. If not, see + * . + * #L% + */ + +package org.scijava.ops.image.gpl.ridge; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Resolve overlap between lines by selecting the fragment with the closest + * matching slope (which is really just trying to preserve straightness - + * implying that a straight passover is more likely than two overlapping turns), + * from the ij-ridgedetection {@code SlopeOverlapResolver} class. + *

+ * Note: unlike the plugin, which uses hash sets and maps of lines (iterated in + * identity hash order), this uses linked sets and maps, so that the result does + * not change from one run to the next. + *

+ *

+ * Note: As in the plugin, the lines meeting at a junction are paired greedily, + * each line with the one giving the straightest merge, rather than by + * minimizing the total error; a line left without a partner is kept as a + * singleton. + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @author Mark Hiner + */ +class SlopeOverlapResolver { + + /** Distance below which two coordinates are considered equal. */ + private static final float SIGMA = 2.0f; + + /** + * Length from the junction point used to compute the slope of a line segment. + * Smaller distances are more subject to variance, while larger distances + * could introduce error due to the curvature of the segments. + */ + private static final int SLOPE_DIST = 5; + + /** + * Straightness comparisons use the longest segment within this tolerance; a + * value of 1 would require perfect straightness. + */ + private static final float STRAIGHT_TOLERANCE = 1.02f; + + /** + * Given a set of lines and junctions, resolve any overlapping lines as + * necessary. + */ + Lines resolve(final Lines originalLines, final List junctions) { + + final Set enclosedLines = new LinkedHashSet(); + final List> nWayIntersections = new ArrayList>(); + findOverlap(enclosedLines, nWayIntersections, junctions); + + final Map> startIntersections = + new LinkedHashMap>(); + final Map> endIntersections = + new LinkedHashMap>(); + + buildIntersectionMaps(originalLines, enclosedLines, startIntersections, + endIntersections); + + final List> lineMerges = new ArrayList>(); + + // find enclosed merges + buildMergeList(lineMerges, enclosedLines, startIntersections, + endIntersections); + + // find n-way intersections + buildMergeList(lineMerges, nWayIntersections); + + // perform the actual merges. This will also populate the lineMap + // with mappings from original to merged lines. + final Map lineMap = new LinkedHashMap(); + final Lines resolvedLines = buildResolvedList(originalLines, lineMerges, + lineMap); + + // use lineMap to update references in original junction points + // the updatedJunctions list will be populated so we can further process + // any updated junctions. + final Set updatedJunctions = updateJunctions(junctions, lineMap); + + // Remove any Junctions that are no longer valid + pruneJunctions(junctions, updatedJunctions); + + // Update the intersection points of each surviving junction. + updateContourClasses(updatedJunctions); + + return resolvedLines; + } + + /** + * Step 1: find enclosed lines (lines with a junction at both start and end + * point). + */ + private void findOverlap(final Set enclosed, + final List> nWay, final List junctions) + { + // Remember if a Junction is located at the start or end point of a Line + Map startMatches = new LinkedHashMap(); + Map endMatches = new LinkedHashMap(); + + // These enclosed lines will be treated as areas of overlap + for (final Junction j : junctions) { + // check if this junction sits on the start or end of either of its lines + if (matchesStart(j, j.getLine1())) startMatches.put(j.getLine1(), j); + if (matchesEnd(j, j.getLine1())) endMatches.put(j.getLine1(), j); + + if (matchesStart(j, j.getLine2())) startMatches.put(j.getLine2(), j); + if (matchesEnd(j, j.getLine2())) endMatches.put(j.getLine2(), j); + } + + enclosed.addAll(startMatches.keySet()); + enclosed.retainAll(endMatches.keySet()); + + // The lines that intersect at the same point as two or more other lines, + // none of which are enclosed. + final Set tSections = new LinkedHashSet(startMatches.keySet()); + tSections.addAll(endMatches.keySet()); + tSections.removeAll(enclosed); + final List> lineSets = new ArrayList>(); + + // Build our list of intersecting sets + for (final Line l1 : tSections) { + boolean found = false; + + // search all line sets until we find an intersection with + // the current line + for (int i = 0; i < lineSets.size() && !found; i++) { + final List intersects = lineSets.get(i); + + for (int j = 0; j < intersects.size() && !found; j++) { + final Line l2 = intersects.get(j); + + // Note: There is no need to check that all lines intersect at the + // same point (rather than forming chains), because the enclosed + // segments that such chains would create were filtered out above. + if (intersects(l1, l2, SIGMA)) { + found = true; + intersects.add(l1); + } + } + } + + // if no match, start next list + if (!found) { + final List lineList = new ArrayList(); + lineList.add(l1); + lineSets.add(lineList); + } + } + + // populate nWay intersection list + for (final List intersects : lineSets) { + if (intersects.size() >= 3) nWay.add(intersects); + } + + // clean up enclosed lines.. any enclosed line that intersects with another + // enclosed line at BOTH ends should be removed. + + boolean pruneEnclosed = true; + + while (pruneEnclosed) { + pruneEnclosed = false; + Line toRemove = null; + + for (final Line l1 : enclosed) { + boolean foundStartMatch = false; + boolean foundEndMatch = false; + + for (final Line l2 : enclosed) { + if (l2 == l1) continue; + else if (intersectsStart(l1, l2, SIGMA)) foundStartMatch = true; + else if (intersectsEnd(l1, l2, SIGMA)) foundEndMatch = true; + + if (foundStartMatch && foundEndMatch) break; + } + + // found a line to prune + // remove it and restart + if (foundStartMatch && foundEndMatch) { + toRemove = l1; + pruneEnclosed = true; + } + } + + if (toRemove != null) enclosed.remove(toRemove); + } + + } + + /** + * Step 2: for each enclosed line, we want to map to the sets of all other + * lines with one intersection at the start, and all lines with one + * intersection at the end. + */ + private void buildIntersectionMaps(final Lines lines, + final Set enclosedLines, + final Map> startIntersections, + final Map> endIntersections) + { + for (final Line l1 : enclosedLines) { + final List startIsect = new ArrayList(); + final List endIsect = new ArrayList(); + for (final Line l2 : lines) { + if (l2 == l1) continue; + else if (intersectsStart(l1, l2, SIGMA)) startIsect.add(l2); + else if (intersectsEnd(l1, l2, SIGMA)) endIsect.add(l2); + } + startIntersections.put(l1, startIsect); + endIntersections.put(l1, endIsect); + } + + } + + /** + * Step 3a: With all mapped combinations determined, we determine the + * combinations that best preserve slope/straightness. + */ + private void buildMergeList(final List> lineMerges, + final Set enclosedLines, + final Map> startIntersections, + final Map> endIntersections) + { + for (final Line enclosed : enclosedLines) { + + // 1. first compute points of interest. We separate them by + // count in case there are not even pairings + final List startPoints = new ArrayList(); + final List endPoints = new ArrayList(); + + final List startLines = startIntersections.get(enclosed); + final List endLines = endIntersections.get(enclosed); + + final float[] enclosedStart = new float[] { enclosed.getXCoordinates()[0], + enclosed.getYCoordinates()[0] }; + + final float[] enclosedEnd = new float[] { enclosed + .getXCoordinates()[enclosed.getNumber() - 1], enclosed + .getYCoordinates()[enclosed.getNumber() - 1] }; + + for (final Line l : startLines) { + startPoints.add(getInterceptPoint(enclosedStart, l)); + } + + for (final Line l : endLines) { + endPoints.add(getInterceptPoint(enclosedEnd, l)); + } + + // 2. For each line in the small set, find the line in the large set + // that would create the straightest merge. + while (startLines.size() > 0 && endLines.size() > 0) { + int startIndex = 0; + int endIndex = 0; + float minStraightness = Float.MAX_VALUE; + final List toMerge = new ArrayList(); + + for (int i = 0; i < startLines.size(); i++) { + for (int j = 0; j < endLines.size(); j++) { + final float straightness = straightCalc(startPoints.get(i), + enclosedStart, enclosedEnd, endPoints.get(j)); + + if (straightness < minStraightness) { + startIndex = i; + endIndex = j; + minStraightness = straightness; + } + } + } + + // Create the merge set + toMerge.add(startLines.remove(startIndex)); + toMerge.add(enclosed); + toMerge.add(endLines.remove(endIndex)); + + // add it to the list of merges + lineMerges.add(toMerge); + + // remove the selected points + startPoints.remove(startIndex); + endPoints.remove(endIndex); + } + + // 3. Unmatched lines are treated as singletons. + final List unmatched = startLines.size() > 0 ? startLines + : endLines; + for (final Line line : unmatched) { + final List toMerge = new ArrayList(); + toMerge.add(line); + lineMerges.add(toMerge); + } + } + + // Join the merges where the end of one is the start of another. + int i = 0, j = 1; + while (j < lineMerges.size()) { + final List list1 = lineMerges.get(i); + final List list2 = lineMerges.get(j); + + if (list1.get(0) == list2.get(list2.size() - 1)) { + lineMerges.remove(i); + list1.remove(0); + list2.addAll(list1); + i = 0; + j = 1; + } + else if (list2.get(0) == list1.get(list1.size() - 1)) { + lineMerges.remove(j); + list2.remove(0); + list1.addAll(list2); + i = 0; + j = 1; + } + else { + j++; + if (j == lineMerges.size()) { + i++; + j = i + 1; + } + } + } + + } + + /** + * Step 3b: The process for determining merges from N-way merges is to find + * the point where each line intersects, then compute the straightness of each + * potential merge and take the straightest. + */ + private void buildMergeList(List> lineMerges, + List> nWayIntersections) + { + for (final List iSection : nWayIntersections) { + // 1. Check the first two lines to determine the junction + final float[] junction = new float[2]; + + final Line testLine = iSection.get(0); + int index = 0; + if (intersectsEnd(testLine, iSection.get(1), SIGMA)) index = testLine + .getNumber() - 1; + + junction[0] = testLine.getXCoordinates()[index]; + junction[1] = testLine.getYCoordinates()[index]; + + // 2. Pair lines based on their straightness; merge sets are added as + // pairs or unpaired singleton lines. + while (iSection.size() > 1) { + final List merge = new ArrayList(); + int idx1 = 0; + int idx2 = 1; + float minStraightness = Float.MAX_VALUE; + + // Find the merge with most straightness in our set of lines + for (int i = 0; i < iSection.size(); i++) { + final float[] icept = getInterceptPoint(junction, iSection.get(i)); + for (int j = i + 1; j < iSection.size(); j++) { + final float[] jcept = getInterceptPoint(junction, iSection.get(j)); + + final float curStraightness = straightCalc(icept, junction, jcept); + if (curStraightness < minStraightness) { + minStraightness = curStraightness; + idx1 = i; + idx2 = j; + } + } + } + + // Add merged pair and remove from lists + merge.add(iSection.get(idx1)); + merge.add(iSection.get(idx2)); + iSection.remove(idx1); + // Note: idx2 shifts down after removing idx1 from the same list. + idx2--; + iSection.remove(idx2); + + lineMerges.add(merge); + } + + if (iSection.size() == 1) { + lineMerges.add(iSection); + } + } + } + + private Lines buildResolvedList(final Lines originalLines, + final List> lineMerges, final Map lineMap) + { + final Set finalLines = new LinkedHashSet(originalLines); + + for (final List toMerge : lineMerges) { + // remove the individual, unmerged lines + finalLines.removeAll(toMerge); + + int newSize = 0; + for (final Line l : toMerge) { + newSize += l.getNumber(); + } + + // build and add the merged line + Line merged = null; + if (toMerge.size() == 1) { + merged = toMerge.get(0); + } + else { + merged = new Line(originalLines); + merged.angle = new float[newSize]; + merged.asymmetry = new float[newSize]; + merged.col = new float[newSize]; + merged.row = new float[newSize]; + merged.response = new float[newSize]; + merged.intensity = new float[newSize]; + merged.widthL = new float[newSize]; + merged.widthR = new float[newSize]; + merged.num = newSize; + // Assume the lines are now standing alone or no longer + // intersect on terminal ends and thus are "no_junc" class. + // This will be updated later after the Junction points are + // reassessed. + merged.setContourClass(ContourClass.NO_JUNCTION); + + // Get the enclosed line + + int pos = 0; + for (int i = 0; i < toMerge.size(); i++) { + final Line line = toMerge.get(i); + Line adjacent = null; + + if (i == 0) adjacent = toMerge.get(i + 1); + else adjacent = toMerge.get(i - 1); + + int num = line.getNumber(); + fillArray(line, adjacent, i == 0, merged.angle, pos, line.angle, num); + fillArray(line, adjacent, i == 0, merged.asymmetry, pos, + line.asymmetry, num); + fillArray(line, adjacent, i == 0, merged.col, pos, line.col, num); + fillArray(line, adjacent, i == 0, merged.row, pos, line.row, num); + fillArray(line, adjacent, i == 0, merged.response, pos, line.response, + num); + fillArray(line, adjacent, i == 0, merged.intensity, pos, + line.intensity, num); + fillArray(line, adjacent, i == 0, merged.widthL, pos, line.widthL, + num); + fillArray(line, adjacent, i == 0, merged.widthR, pos, line.widthR, + num); + pos += num; + + // map the original line to the merged + lineMap.put(line, merged); + } + } + + finalLines.add(merged); + } + + final Lines resolvedLines = new Lines(originalLines); + resolvedLines.addAll(finalLines); + + return resolvedLines; + } + + /** + * Look through all {@link Junction}s. If either of the lines has been merged, + * the Junction is updated to reference the merged line. Returns the set of + * all such modified Junctions. + */ + private Set updateJunctions(final List junctions, + final Map lineMap) + { + final Set updated = new LinkedHashSet(); + + for (final Junction junction : junctions) { + Line mergedLine = null; + if ((mergedLine = lineMap.get(junction.lineCont1)) != null) { + junction.lineCont1 = mergedLine; + junction.cont1 = mergedLine.getID(); + updated.add(junction); + } + mergedLine = null; + if ((mergedLine = lineMap.get(junction.lineCont2)) != null) { + junction.lineCont2 = mergedLine; + junction.cont2 = mergedLine.getID(); + updated.add(junction); + } + } + + return updated; + } + + /** + * Remove all redundant {@link Junction}s from the list. This includes + * Junctions with two references to the same line (because their lines were + * merged) and cases where multiple Junctions refer to the same lines as each + * other and occupy the same physical position. + */ + private void pruneJunctions(final List junctions, + final Set updatedJunctions) + { + final Map jMap = new LinkedHashMap(); + for (final Junction j : updatedJunctions) { + String key = null; + // Remove Junctions with references to the same Line + if (j.cont1 == j.cont2) junctions.remove(j); + // Remove Junctions of the same two lines at the same x,y point + else if (jMap.containsKey((key = getKey(j)))) junctions.remove(j); + else { + // Keep the junction and register it as "the" definitive junction + // for its two lines at this point. + jMap.put(key, j); + } + } + + // Update the updatedJunctions set by removing all Junction instances + // that have been removed from the master Junctions collection. + updatedJunctions.retainAll(junctions); + } + + /** + * For each {@link Junction} in the provided set, update the Junction's + * {@link Junction#pos}, {@link Junction#isNonTerminal}, and each line's + * {@link ContourClass} as appropriate. + */ + private void updateContourClasses(final Set updatedJunctions) { + for (final Junction j : updatedJunctions) { + // process both lines. + // For isNonTerminal to be updated, the junction point can't be on + // either line's terminals. + // We only update the pos of the Junction on the first line. + j.isNonTerminal = processLine(j, j.lineCont1, true); + j.isNonTerminal = processLine(j, j.lineCont2, false) && j.isNonTerminal; + } + } + + /** + * Iterate over the points of the line and find the pos of the junction If pos + * is 0 or line.length, update the line's contour class If this is the first + * line, set the Junction's pos to match If this junction doesn't sit on + * either line's terminals, set the Junction's isNonTerminal to true + * + * @param updatePos - if true, update the pos of the given Junction + * @return True if the junction point was NOT on the start or end terminal of + * the given line. + */ + private boolean processLine(final Junction j, final Line line, + final boolean updatePos) + { + int pos = -1; + final float[] x = line.getXCoordinates(); + final float[] y = line.getYCoordinates(); + // loop over all points of the line, or until we find the + // point of intersection with the junction. + for (int i = 0; i < line.num && pos < 0; i++) { + if (Float.compare(x[i], j.x) == 0 && Float.compare(y[i], j.y) == 0) pos = + i; + } + + // update the Junction position if requested + if (updatePos) j.pos = pos; + + // update contour class if appropriate + // Nothing can supersede "cont_both_junc" + if (!line.getContourClass().equals(ContourClass.BOTH_JUNCTIONS)) { + if (pos == 0) { + // If this line is already an "end_junc", upgrade it to a "both" + if (line.getContourClass().equals(ContourClass.END_JUNCTION)) { + line.setContourClass(ContourClass.BOTH_JUNCTIONS); + } + // Otherwise, set it as a "start_junc" + else { + line.setContourClass(ContourClass.START_JUNCTION); + } + } + else if (pos == line.num - 1) { + // If this line is already a "start_junc", upgrade it to a "both" + if (line.getContourClass().equals(ContourClass.START_JUNCTION)) { + line.setContourClass(ContourClass.BOTH_JUNCTIONS); + } + // Otherwise, set it as a "end_junc" + else { + line.setContourClass(ContourClass.END_JUNCTION); + } + } + } + + // Check the position of the junction within the line + return !(pos == 0 || pos == line.num - 1); + } + + /** + * Compute the striaghtness between the 3 given points. This is done by + * comparing distances: {@code ((p1 + p2) + (p2 + 3)) / (p3 + p1)} The closer + * the value is to 1, the straighter the line. + */ + private float straightCalc(final float[]... points) { + float ideal = dist(points[0], points[points.length - 1]); + float sum = 0; + for (int i = 1; i < points.length; i++) + sum += dist(points[i - 1], points[i]); + + return sum / ideal; + } + + private float dist(final float[] p1, final float[] p2) { + return (float) Math.sqrt(Math.pow((p2[0] - p1[0]), 2) + Math.pow((p2[1] - + p1[1]), 2)); + } + + /** + * Copy to the target at the given position. Use the source array if it is + * non-null. Otherwise fill with 0's + */ + private void fillArray(final Line line, final Line adjacent, + final boolean adjacentIsNext, final float[] target, int pos, + final float[] source, final int length) + { + if (source == null) Arrays.fill(target, pos, pos + length, 0f); + else { + // Reverse the source if the line meets the next line at its start, or + // the previous line at its end. + if ((adjacentIsNext && intersectsStart(line, adjacent, SIGMA)) || + (!adjacentIsNext && intersectsEnd(line, adjacent, SIGMA))) + { + for (int i = source.length - 1; i >= 0; i--) { + target[pos++] = source[i]; + } + } + else System.arraycopy(source, 0, target, pos, length); + } + } + + /** + * Helper method to get the point at {@code SLOPE_DIST} positions away from + * the intercept point between the query line and given point. + */ + private float[] getInterceptPoint(final float[] p1, final Line query) { + int dist = 0; + final List points = new ArrayList(); + points.add(p1); + + return findLongestPath(query, dist, points); + } + + /** + * Recursive helper method. Iteratively searches to the next + * {@link #SLOPE_DIST} position in the query line. As long as this point + * maintains straightness within {@link #STRAIGHT_TOLERANCE}, and we haven't + * gone past the query line boundaries, recursion continues. + */ + private float[] findLongestPath(final Line query, int dist, + final List points) + { + final float[] p1 = points.get(0); + dist += SLOPE_DIST; + + int pos = 0; + + // The position to check depends on whether the query line meets the + // original point at its start or its end. + if (Math.abs(p1[0] - query.getXCoordinates()[0]) < SIGMA && Math.abs(p1[1] - + query.getYCoordinates()[0]) < SIGMA) + { + pos = Math.min(dist, query.getNumber() - 1); + } + else { + pos = Math.max(0, query.getNumber() - 1 - dist); + } + + final float[] p2 = new float[2]; + + p2[0] = query.getXCoordinates()[pos]; + p2[1] = query.getYCoordinates()[pos]; + + // if our position has reached the start (or end) of the query line, + // return p2. + if (pos == 0 || pos == query.getNumber() - 1) { + return p2; + } + + // If we still have a straight enough line, recurse to the next position + points.add(p2); + if (straightCalc(points.toArray(new float[points + .size()][])) <= STRAIGHT_TOLERANCE) + { + return findLongestPath(query, dist, points); + } + + // If the line is not straight enough, return the previous point. + return points.get(points.size() - 2); + } + + private boolean intersects(final Line l1, final Line l2, + final float threshold) + { + return intersectsStart(l1, l2, threshold) || intersectsEnd(l1, l2, + threshold); + } + + /** + * Helper method to determine if the query line intersects with the target at + * its start terminal. + */ + private boolean intersectsStart(final Line target, final Line query, + final float threshold) + { + final float[] tStart = getPoint(target, 0); + final float[] qStart = getPoint(query, 0); + final float[] qEnd = getPoint(query, query.getNumber() - 1); + return intersects(tStart, qStart, threshold) || intersects(tStart, qEnd, + threshold); + } + + /** + * Helper method to determine if the query line intersects with the target at + * its end terminal. + */ + private boolean intersectsEnd(final Line target, final Line query, + final float threshold) + { + final float[] tEnd = getPoint(target, target.getNumber() - 1); + final float[] qStart = getPoint(query, 0); + final float[] qEnd = getPoint(query, query.getNumber() - 1); + return intersects(tEnd, qStart, threshold) || intersects(tEnd, qEnd, + threshold); + } + + private boolean intersects(final float[] tEnd, final float[] qEnd, + final float threshold) + { + return Math.abs(qEnd[0] - tEnd[0]) < threshold && Math.abs(qEnd[1] - + tEnd[1]) < threshold; + } + + /** + * Builds a unique key identifying a given {@link Junction} based on the two + * associated lines, and the Junction's position. + */ + private String getKey(final Junction junction) { + final StringBuilder sb = new StringBuilder(); + + sb.append(Math.min(junction.cont1, junction.cont2)); + sb.append(Math.max(junction.cont1, junction.cont2)); + sb.append(junction.x); + sb.append(junction.y); + + return sb.toString(); + } + + /** + * Helper method to return the x and y coordinates of the point at the + * specified index of a given line. + */ + private float[] getPoint(final Line target, final int i) { + float[] coords = new float[2]; + coords[0] = target.getXCoordinates()[i]; + coords[1] = target.getYCoordinates()[i]; + return coords; + } + + /** + * Helper method to determine if a junction sits on the start point of a line. + */ + private boolean matchesStart(final Junction junction, final Line line) { + return line.getXCoordinates()[0] == junction.getX() && line + .getYCoordinates()[0] == junction.getY(); + } + + /** + * Helper method to determine if a junction sits on the end point of a line. + */ + private boolean matchesEnd(final Junction junction, final Line line) { + int count = line.getNumber() - 1; + return line.getXCoordinates()[count] == junction.getX() && line + .getYCoordinates()[count] == junction.getY(); + } +} diff --git a/scijava-ops-image-gpl/src/main/java/org/scijava/ops/image/gpl/ridge/Threshold.java b/scijava-ops-image-gpl/src/main/java/org/scijava/ops/image/gpl/ridge/Threshold.java new file mode 100644 index 000000000..8d89b4359 --- /dev/null +++ b/scijava-ops-image-gpl/src/main/java/org/scijava/ops/image/gpl/ridge/Threshold.java @@ -0,0 +1,134 @@ +/*- + * #%L + * GPL-licensed image processing operations for SciJava Ops, translated from copyleft ImageJ/Fiji plugins. + * %% + * Copyright (C) 2026 SciJava developers. + * %% + * This program 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. + * + * This program 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 this program. If not, see + * . + * #L% + */ + +package org.scijava.ops.image.gpl.ridge; + +/** + * Run-length encoding of the pixels of a byte image above a threshold, from the + * ij-ridgedetection {@code Threshold}, {@code Region} and {@code Chord} classes + * (a translation of the C code of Carsten Steger). + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @author Thorsten Wagner + * @author Carsten Steger + */ +final class Threshold { + + private Threshold() { + // Prevent instantiation of utility class. + } + + /** A run-length encoded region. */ + static final class Region { + + /** Number of chords. */ + int num; + + /** Array of chords. */ + Chord[] rl; + } + + /** One run of a run-length encoded region. */ + static final class Chord { + + /** Row coordinate of the chord. */ + short r; + + /** Column coordinate of the start of the chord. */ + short cb; + + /** Column coordinate of the end of the chord. */ + short ce; + } + + static void threshold(byte[] image, int min, int width, int height, + Region out) + { + int grey; + int r, c, l, num, numMax; + boolean inside; + Chord[] rl; + + inside = false; + num = 0; + numMax = LinesUtil.INITIAL_SIZE; + rl = new Chord[numMax]; + for (int i = 0; i < numMax; i++) { + rl[i] = new Chord(); + } + out.rl = null; + out.num = 0; + + for (r = 0; r < height; r++) { + for (c = 0; c < width; c++) { + l = LinesUtil.linCoor(r, c, width); + grey = image[l]; + if (grey >= min) { + if (!inside) { + inside = true; + rl[num].r = (short) r; + rl[num].cb = (short) c; + } + } + else { + if (inside) { + inside = false; + rl[num].ce = (short) (c - 1); + num++; + if (num >= numMax) { + numMax = (int) Math.floor((double) (numMax * + LinesUtil.REALLOC_FACTOR)); + Chord[] rlh = new Chord[numMax]; + for (int i = 0; i < rlh.length; i++) { + if (i < rl.length) rlh[i] = rl[i]; + else rlh[i] = new Chord(); + } + rl = rlh; + } + } + } + } + if (inside) { + inside = false; + rl[num].ce = (short) (width - 1); + num++; + if (num >= numMax) { + numMax = (int) Math.floor((double) (numMax * + LinesUtil.REALLOC_FACTOR)); + Chord[] rlh = new Chord[numMax]; + for (int i = 0; i < rlh.length; i++) { + if (i < rl.length) rlh[i] = rl[i]; + else rlh[i] = new Chord(); + } + rl = rlh; + } + } + } + out.rl = new Chord[num]; + for (int i = 0; i < num; i++) { + out.rl[i] = rl[i]; + } + out.num = num; + } + +} diff --git a/scijava-ops-image-gpl/src/main/java/org/scijava/ops/image/gpl/ridge/Width.java b/scijava-ops-image-gpl/src/main/java/org/scijava/ops/image/gpl/ridge/Width.java new file mode 100644 index 000000000..aa43fe5fa --- /dev/null +++ b/scijava-ops-image-gpl/src/main/java/org/scijava/ops/image/gpl/ridge/Width.java @@ -0,0 +1,536 @@ +/*- + * #%L + * GPL-licensed image processing operations for SciJava Ops, translated from copyleft ImageJ/Fiji plugins. + * %% + * Copyright (C) 2026 SciJava developers. + * %% + * This program 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. + * + * This program 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 this program. If not, see + * . + * #L% + */ + +package org.scijava.ops.image.gpl.ridge; + +import java.util.ArrayList; + +import org.scijava.ops.image.gpl.ridge.LinesUtil.MutableDouble; +import org.scijava.ops.image.gpl.ridge.LinesUtil.MutableInt; + +/** + * Extraction of the line width, by locating the edges of each line point along + * its normal with a facet model edge detector, and the optional correction of + * the width and position bias, from the ij-ridgedetection {@code Width} class + * (a translation of the {@code width.c} of Carsten Steger). + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @author Thorsten Wagner + * @author Carsten Steger + */ +class Width { + + /** A pixel offset. */ + static final class Offset { + + int x, y; + } + + /* + * This constant is introduced because for very narrow lines the facet model + * width detection scheme sometimes extracts the line width too narrow. Since + * the correction function has a very steep slope in that area, this will lead + * to lines of almost zero width, especially since the bilinear interpolation + * in correct.c will tend to overcorrect. Therefore it is wise to make the + * extracted line width slightly larger before correction. + */ + static final double LINE_WIDTH_COMPENSATION = 1.05; + + /* Minimum line width allowed (used for outlier check in fixLocations()) */ + static final double MIN_LINE_WIDTH = 0.1; + + /* Maximum contrast allowed (used for outlier check in fixLocations()) */ + static final double MAX_CONTRAST = 275.0; + + /* + * Modified Bresenham algorithm. It returns in line all pixels that are + * intersected by a half line less than length away from the point (px,py) + * aint the direction (nx,ny). The point (px,py) must lie within the pixel of + * the origin, i.e., fabs(px) <= 0.5 and fabs(py) <= 0.5. + */ + void bresenham(double nx, double ny, double px, double py, double length, + Offset[] line, MutableInt numPoints) + { + int i, n, x, y, s1, s2, xchg, maxit; + double e, dx, dy, t; + + x = 0; + y = 0; + dx = Math.abs(nx); + dy = Math.abs(ny); + s1 = (int) Math.signum(nx); + s2 = (int) Math.signum(ny); + px *= s1; + py *= s2; + if (dy > dx) { + t = dx; + dx = dy; + dy = t; + t = px; + px = py; + py = t; + xchg = 1; + } + else { + xchg = 0; + } + maxit = (int) Math.ceil(length * dx); + e = (0.5 - px) * dy / dx - (0.5 - py); + n = 0; + for (i = 0; i <= maxit; i++) { + line[n].x = x; + line[n].y = y; + n++; + while (e >= -1e-8) { + if (!(xchg == 0)) x += s1; + else y += s2; + e--; + if (e > -1) { + line[n].x = x; + line[n].y = y; + n++; + } + } + if (!(xchg == 0)) y += s2; + else x += s1; + e += dy / dx; + } + numPoints.setValue(n); + } + + /* + * Fill gaps in the arrays master, slave1, and slave2, i.e., points where + * master=0, by interpolation (interior points) or extrapolation (end points). + * The array master will usually be the width of the line, while slave1 and + * slave2 will be values that depend on master[i] being 0, e.g., the gradient + * at each line point. The arrays slave1 and slave2 can be NULL. + */ + private void fillGaps(double[] master, double[] slave1, double[] slave2, + Line cont) + { + int i, j, k, s, e; + int numPoints; + double mS, mE, s1S, s1E, s2S, s2E, dR, dC, arcLen, len; + + numPoints = cont.num; + for (i = 0; i < numPoints; i++) { + if (master[i] == 0) { + for (j = i + 1; j < numPoints; j++) { + if (master[j] > 0) break; + } + mS = 0; + mE = 0; + s1S = 0; + s1E = 0; + s2S = 0; + s2E = 0; + if (i > 0 && j < numPoints - 1) { + s = i; + e = j - 1; + mS = master[(s - 1)]; + mE = master[(e + 1)]; + if (slave1 != null) { + s1S = slave1[(s - 1)]; + s1E = slave1[(e + 1)]; + } + if (slave2 != null) { + s2S = slave2[(s - 1)]; + s2E = slave2[(e + 1)]; + } + } + else if (i > 0) { + s = i; + e = numPoints - 2; + mS = master[(s - 1)]; + mE = master[(s - 1)]; + master[(e + 1)] = mE; + if (slave1 != null) { + s1S = slave1[(s - 1)]; + s1E = slave1[(s - 1)]; + slave1[(e + 1)] = s1E; + } + if (slave2 != null) { + s2S = slave2[(s - 1)]; + s2E = slave2[(s - 1)]; + slave2[(e + 1)] = s2E; + } + } + else if (j < numPoints - 1) { + s = 1; + e = j - 1; + mS = master[(e + 1)]; + mE = master[(e + 1)]; + master[(s - 1)] = mS; + if (slave1 != null) { + s1S = slave1[(e + 1)]; + s1E = slave1[(e + 1)]; + slave1[(s - 1)] = s1S; + } + if (slave2 != null) { + s2S = slave2[(e + 1)]; + s2E = slave2[(e + 1)]; + slave2[(s - 1)] = s2S; + } + } + else { + s = 1; + e = numPoints - 2; + mS = master[(s - 1)]; + mE = master[(e + 1)]; + if (slave1 != null) { + s1S = slave1[(s - 1)]; + s1E = slave1[(e + 1)]; + } + if (slave2 != null) { + s2S = slave2[(s - 1)]; + s2E = slave2[(e + 1)]; + } + } + arcLen = 0; + for (k = s; k <= e + 1; k++) { + dR = cont.row[k] - cont.row[(k - 1)]; + dC = cont.col[k] - cont.col[(k - 1)]; + arcLen += Math.sqrt(dR * dR + dC * dC); + } + len = 0; + for (k = s; k <= e; k++) { + dR = cont.row[k] - cont.row[(k - 1)]; + dC = cont.col[k] - cont.col[(k - 1)]; + len += Math.sqrt(dR * dR + dC * dC); + master[k] = (arcLen - len) / arcLen * mS + len / arcLen * mE; + if (slave1 != null) slave1[k] = (arcLen - len) / arcLen * s1S + len / + arcLen * s1E; + if (slave2 != null) slave2[k] = (arcLen - len) / arcLen * s2S + len / + arcLen * s2E; + } + i = j; + } + } + } + + /* + * Correct the extracted line positions and widths. The algorithm first closes + * gaps in the extracted data widthL, widthR, gradL, and gradR to provide + * meaningful input over the whole line. Then the correction is calculated. + * After this, gaps that have been introduced by the width correction are + * again closed. Finally, the position correction is applied if correctPos is + * set. The results are returned in widthL, widthR, and cont. + */ + private void fixLocations(double[] widthL, double[] widthR, double[] gradL, + double[] gradR, double[] posX, double[] posY, double[] correction, + double[] contr, double[] asymm, double sigma, int mode, boolean correctPos, + Line cont) + { + int i; + int numPoints; + double px, py; + double nx, ny; + double wEst, rEst; + MutableDouble wReal, hReal, corr; + wReal = new MutableDouble(); + hReal = new MutableDouble(); + corr = new MutableDouble(); + MutableDouble wStrong = new MutableDouble(); + MutableDouble wWeak = new MutableDouble(); + double correct, asymmetry, response, width, contrast; + boolean weakIsR; + boolean correctStart, correctEnd; + Convol convol = new Convol(); + fillGaps(widthL, gradL, null, cont); + fillGaps(widthR, gradR, null, cont); + + numPoints = cont.num; + + /* Calculate true line width, asymmetry, and position correction. */ + if (correctPos) { + /* + * Do not correct the position of a junction point if its width is found + * by interpolation, i.e., if the position could be corrected differently + * for each junction point, thereby destroying the junction. + */ + correctStart = ((cont.getContourClass() == ContourClass.NO_JUNCTION || + cont.getContourClass() == ContourClass.END_JUNCTION || cont + .getContourClass() == ContourClass.CLOSED) && (widthR[0] > 0 && + widthL[0] > 0)); + correctEnd = ((cont.getContourClass() == ContourClass.NO_JUNCTION || cont + .getContourClass() == ContourClass.START_JUNCTION || cont + .getContourClass() == ContourClass.CLOSED) && (widthR[(numPoints - + 1)] > 0 && widthL[(numPoints - 1)] > 0)); + /* + * Calculate the true width and assymetry, and its corresponding + * correction for each line point. + */ + for (i = 0; i < numPoints; i++) { + if (widthR[i] > 0 && widthL[i] > 0) { + wEst = (widthR[i] + widthL[i]) * LINE_WIDTH_COMPENSATION; + if (gradR[i] <= gradL[i]) { + rEst = gradR[i] / gradL[i]; + weakIsR = true; + } + else { + rEst = gradL[i] / gradR[i]; + weakIsR = false; + } + Correct.lineCorrections(sigma, wEst, rEst, wReal, hReal, corr, + wStrong, wWeak); + wReal.setValue(wReal.getValue() / LINE_WIDTH_COMPENSATION); + corr.setValue(corr.getValue() / LINE_WIDTH_COMPENSATION); + widthR[i] = wReal.getValue(); + widthL[i] = wReal.getValue(); + if (weakIsR) { + asymm[i] = hReal.getValue(); + correction[i] = -corr.getValue(); + } + else { + asymm[i] = -hReal.getValue(); + correction[i] = corr.getValue(); + } + } + } + + fillGaps(widthL, correction, asymm, cont); + for (i = 0; i < numPoints; i++) + widthR[i] = widthL[i]; + + /* Adapt the correction for junction points if necessary. */ + if (!correctStart) correction[0] = 0; + if (!correctEnd) correction[(numPoints - 1)] = 0; + + for (i = 0; i < numPoints; i++) { + px = posX[i]; + py = posY[i]; + nx = Math.cos(cont.angle[i]); + ny = Math.sin(cont.angle[i]); + px = px + correction[i] * nx; + py = py + correction[i] * ny; + posX[i] = px; + posY[i] = py; + } + } + + /* Update the position of a line and add the extracted width. */ + cont.widthL = new float[numPoints]; + cont.widthR = new float[numPoints]; + for (i = 0; i < numPoints; i++) { + cont.widthL[i] = (float) widthL[i]; + cont.widthR[i] = (float) widthR[i]; + cont.row[i] = (float) posX[i]; + cont.col[i] = (float) posY[i]; + } + + /* Now calculate the true contrast. */ + if (correctPos) { + cont.asymmetry = new float[numPoints]; + cont.intensity = new float[numPoints]; + for (i = 0; i < numPoints; i++) { + response = cont.response[i]; + asymmetry = Math.abs(asymm[i]); + correct = Math.abs(correction[i]); + width = cont.widthL[i]; + if (width < MIN_LINE_WIDTH) contrast = 0; + else contrast = (response / Math.abs(convol.phi2(correct + width, + sigma) + (asymmetry - 1) * convol.phi2(correct - width, sigma))); + + if (contrast > MAX_CONTRAST) contrast = 0; + contr[i] = contrast; + } + fillGaps(contr, null, null, cont); + for (i = 0; i < numPoints; i++) { + cont.asymmetry[i] = (float) asymm[i]; + if (mode == LinesUtil.MODE_LIGHT) cont.intensity[i] = (float) contr[i]; + else cont.intensity[i] = (float) -contr[i]; + } + } + } + + /* + * Extract the line width by using a facet model line detector on an image of + * the absolute value of the gradient. + */ + void computeLineWidth(float[] dx, float[] dy, int width, int height, + double sigma, int mode, boolean correctPos, ArrayList contours, + MutableInt numContours) + { + float[] grad; + int i, j, k; + int r, c, l; + int x, y, dir; + Offset[] line; + int maxLine, numLine = 0; + double length; + Line cont; + int numPoints, maxNumPoints; + double[] widthR, widthL; + double[] gradR, gradL; + double[] posX, posY, correct, asymm, contrast; + double d, dr, dc, drr, drc, dcc; + double i1, i2, i3, i4, i5, i6, i7, i8, i9; + double t1, t2, t3, t4, t5, t6; + double[] eigval = new double[2]; + double[][] eigvec = new double[2][2]; + double a, b, t = 0; + int num = 0; + double nx, ny; + double n1, n2; + double p1, p2; + double val; + double px, py; + Position p = new Position(); + maxNumPoints = 0; + for (i = 0; i < numContours.getValue(); i++) { + numPoints = contours.get(i).num; + if (numPoints > maxNumPoints) maxNumPoints = numPoints; + } + + widthL = new double[maxNumPoints]; + widthR = new double[maxNumPoints]; + gradL = new double[maxNumPoints]; + gradR = new double[maxNumPoints]; + posX = new double[maxNumPoints]; + posY = new double[maxNumPoints]; + correct = new double[maxNumPoints]; + contrast = new double[maxNumPoints]; + asymm = new double[maxNumPoints]; + + grad = new float[(width * height)]; + + length = 2.5 * sigma; + maxLine = (int) Math.ceil(length * 3); + line = new Offset[maxLine]; + for (int o = 0; o < line.length; o++) { + line[o] = new Offset(); + } + + /* Compute the gradient image. */ + for (r = 0; r < height; r++) { + for (c = 0; c < width; c++) { + l = LinesUtil.linCoor(r, c, width); + grad[l] = (float) Math.sqrt(dx[l] * dx[l] + dy[l] * dy[l]); + } + } + + for (i = 0; i < numContours.getValue(); i++) { + cont = contours.get(i); + numPoints = cont.num; + + for (j = 0; j < numPoints; j++) { + px = cont.row[j]; + py = cont.col[j]; + posX[j] = px; + posY[j] = py; + r = (int) Math.floor(px + 0.5); + c = (int) Math.floor(py + 0.5); + nx = Math.cos(cont.angle[j]); + ny = Math.sin(cont.angle[j]); + /* Compute the search line. */ + MutableInt numLineh = new MutableInt(numLine); + bresenham(nx, ny, 0.0, 0.0, length, line, numLineh); + numLine = numLineh.intValue(); + widthR[j] = widthL[j] = 0; + /* Look on both sides of the line. */ + for (dir = -1; dir <= 1; dir += 2) { + for (k = 0; k < numLine; k++) { + x = LinesUtil.mirrorRow(r + dir * line[k].x, height); + y = LinesUtil.mirrorCol(c + dir * line[k].y, width); + i1 = grad[LinesUtil.linCoor(LinesUtil.mirrorRow(x - 1, height), + LinesUtil.mirrorCol(y - 1, width), width)]; + i2 = grad[LinesUtil.linCoor(LinesUtil.mirrorRow(x - 1, height), y, + width)]; + i3 = grad[LinesUtil.linCoor(LinesUtil.mirrorRow(x - 1, height), + LinesUtil.mirrorCol(y + 1, width), width)]; + i4 = grad[LinesUtil.linCoor(x, LinesUtil.mirrorCol(y - 1, width), + width)]; + i5 = grad[LinesUtil.linCoor(x, y, width)]; + i6 = grad[LinesUtil.linCoor(x, LinesUtil.mirrorCol(y + 1, width), + width)]; + i7 = grad[LinesUtil.linCoor(LinesUtil.mirrorRow(x + 1, height), + LinesUtil.mirrorCol(y - 1, width), width)]; + i8 = grad[LinesUtil.linCoor(LinesUtil.mirrorRow(x + 1, height), y, + width)]; + i9 = grad[LinesUtil.linCoor(LinesUtil.mirrorRow(x + 1, height), + LinesUtil.mirrorCol(y + 1, width), width)]; + t1 = i1 + i2 + i3; + t2 = i4 + i5 + i6; + t3 = i7 + i8 + i9; + t4 = i1 + i4 + i7; + t5 = i2 + i5 + i8; + t6 = i3 + i6 + i9; + dr = (t3 - t1) / 6; + dc = (t6 - t4) / 6; + drr = (t1 - 2 * t2 + t3) / 6; + dcc = (t4 - 2 * t5 + t6) / 6; + drc = (i1 - i3 - i7 + i9) / 4; + p.computeEigenvals(2 * drr, drc, 2 * dcc, eigval, eigvec); + val = -eigval[0]; + if (val > 0.0) { + n1 = eigvec[0][0]; + n2 = eigvec[0][1]; + a = 2.0 * (drr * n1 * n1 + drc * n1 * n2 + dcc * n2 * n2); + b = dr * n1 + dc * n2; + MutableDouble th = new MutableDouble(t); + MutableInt numh = new MutableInt(num); + p.solveLinear(a, b, th, numh); + t = th.getValue(); + num = numh.getValue(); + if (num != 0) { + p1 = t * n1; + p2 = t * n2; + if (Math.abs(p1) <= 0.5 && Math.abs(p2) <= 0.5) { + /* + * Project the maximum point position perpendicularly onto the + * search line. + */ + a = 1; + b = nx * (px - (r + dir * line[k].x + p1)) + ny * (py - (c + + dir * line[k].y + p2)); + th = new MutableDouble(t); + numh = new MutableInt(num); + p.solveLinear(a, b, th, numh); + t = th.getValue(); + num = numh.getValue(); + d = (-i1 + 2 * i2 - i3 + 2 * i4 + 5 * i5 + 2 * i6 - i7 + 2 * + i8 - i9) / 9; + if (dir == 1) { + gradR[j] = d + p1 * dr + p2 * dc + p1 * p1 * drr + p1 * p2 * + drc + p2 * p2 * dcc; + widthR[j] = Math.abs(t); + } + else { + gradL[j] = d + p1 * dr + p2 * dc + p1 * p1 * drr + p1 * p2 * + drc + p2 * p2 * dcc; + widthL[j] = Math.abs(t); + } + break; + } + } + } + } + } + } + + fixLocations(widthL, widthR, gradL, gradR, posX, posY, correct, contrast, + asymm, sigma, mode, correctPos, cont); + } + } + +} diff --git a/scijava-ops-image-gpl/src/main/java/org/scijava/ops/image/gpl/ridge/package-info.java b/scijava-ops-image-gpl/src/main/java/org/scijava/ops/image/gpl/ridge/package-info.java new file mode 100644 index 000000000..0e9576139 --- /dev/null +++ b/scijava-ops-image-gpl/src/main/java/org/scijava/ops/image/gpl/ridge/package-info.java @@ -0,0 +1,28 @@ +/*- + * #%L + * GPL-licensed image processing operations for SciJava Ops, translated from copyleft ImageJ/Fiji plugins. + * %% + * Copyright (C) 2026 SciJava developers. + * %% + * This program 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. + * + * This program 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 this program. If not, see + * . + * #L% + */ + +/** + * Curvilinear structure (line) detection ops, translated from the Fiji Ridge + * Detection plugin by Thorsten Wagner and Mark Hiner, after the unbiased line + * detector of Carsten Steger. + */ +package org.scijava.ops.image.gpl.ridge; diff --git a/scijava-ops-image-gpl/src/main/java/org/scijava/ops/image/gpl/skeleton/CyclePruning.java b/scijava-ops-image-gpl/src/main/java/org/scijava/ops/image/gpl/skeleton/CyclePruning.java new file mode 100644 index 000000000..6ed85aecd --- /dev/null +++ b/scijava-ops-image-gpl/src/main/java/org/scijava/ops/image/gpl/skeleton/CyclePruning.java @@ -0,0 +1,53 @@ +/*- + * #%L + * GPL-licensed image processing operations for SciJava Ops, translated from copyleft ImageJ/Fiji plugins. + * %% + * Copyright (C) 2026 SciJava developers. + * %% + * This program 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. + * + * This program 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 this program. If not, see + * . + * #L% + */ + +package org.scijava.ops.image.gpl.skeleton; + +/** + * Methods for breaking the cycles (loops) of a skeleton before analysis, as in + * the Fiji AnalyzeSkeleton plugin. A ring without junctions is always broken + * by removing one of its voxels. + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @author Ignacio Arganda-Carreras + */ +public enum CyclePruning { + + /** Keep all cycles. */ + NONE, + + /** Cut each cycle in the middle of its shortest branch. */ + SHORTEST_BRANCH, + + /** + * Cut each cycle at its slab voxel with the lowest average intensity in its + * neighborhood. Requires an intensity image. + */ + LOWEST_INTENSITY_VOXEL, + + /** + * Cut each cycle at the darkest slab voxel of its branch with the lowest + * average intensity. Requires an intensity image. + */ + LOWEST_INTENSITY_BRANCH +} diff --git a/scijava-ops-image-gpl/src/main/java/org/scijava/ops/image/gpl/skeleton/DefaultSkeletonGraph.java b/scijava-ops-image-gpl/src/main/java/org/scijava/ops/image/gpl/skeleton/DefaultSkeletonGraph.java new file mode 100644 index 000000000..83ffc3c5b --- /dev/null +++ b/scijava-ops-image-gpl/src/main/java/org/scijava/ops/image/gpl/skeleton/DefaultSkeletonGraph.java @@ -0,0 +1,112 @@ +/*- + * #%L + * GPL-licensed image processing operations for SciJava Ops, translated from copyleft ImageJ/Fiji plugins. + * %% + * Copyright (C) 2026 SciJava developers. + * %% + * This program 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. + * + * This program 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 this program. If not, see + * . + * #L% + */ + +package org.scijava.ops.image.gpl.skeleton; + +import java.util.List; + +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.type.BooleanType; +import net.imglib2.type.numeric.RealType; + +import org.scijava.function.Functions; +import org.scijava.ops.spi.Nullable; + +/** + * Builds the graphs of the skeletons (trees) of a 2D or 3D skeleton image. + *

+ * Skeleton voxels are classified by their number of 26-connected neighbors + * into end points, slabs and junctions (see {@code morphology.tagSkeleton}). + * Each connected skeleton becomes a {@link SkeletonGraph}, whose vertices are + * end points and junctions (adjacent junction voxels are merged into one + * vertex), and whose edges are the branches between them, with their slab + * voxels and calibrated lengths. Optionally, end branches are pruned and + * cycles are broken first, after which the skeleton is analyzed again. + *

+ *

+ * This is a translation of the Fiji + * AnalyzeSkeleton plugin + * by Ignacio Arganda-Carreras (GPL v3+), without its display parts. Results + * match the plugin, including its visit order of neighbor voxels (which + * determines how branches around junction clusters are traced), its ring + * lengths (which omit the closing step) and its maximum branch length (which + * ignores one-voxel branches next to a junction). Deviations: + *

+ *
    + *
  • Branch intensities are measured in the given intensity image, whereas + * the plugin averages the skeleton image values.
  • + *
  • When end pruning removes the root vertex of a tree, another vertex + * becomes the root, so that cycle pruning still finds the cycles of that tree + * (the plugin then silently prunes none).
  • + *
  • An end point whose only branch loops back to itself is pruned without + * error (the plugin fails).
  • + *
+ *

+ * Reference: I. Arganda-Carreras, R. Fernández-González, A. Muñoz-Barrutia and + * C. Ortiz-de-Solórzano, "3D reconstruction of histological sections: + * Application to mammary gland tissue", Microscopy Research and Technique + * 73(11):1019-1029, 2010. + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @author Ignacio Arganda-Carreras + * @param type of the skeleton image + * @param type of the intensity image + * @implNote op names='topology.skeletonGraph' + */ +public class DefaultSkeletonGraph, T extends RealType> + implements + Functions.Arity5, double[], CyclePruning, Double, RandomAccessibleInterval, List> +{ + + /** + * Builds the skeleton graphs. + * + * @param skeleton the 2D or 3D skeleton image, e.g. the output of + * {@code morphology.skeletonize} + * @param calibration the element spacing along each dimension (defaults to 1 + * for every dimension); lengths are reported in these units + * @param cyclePruning how to break the cycles of the skeleton (defaults to + * {@link CyclePruning#NONE}) + * @param pruneEnds the maximum calibrated length of the end branches to + * remove (by default no end branches are removed). With + * {@code Double.POSITIVE_INFINITY}, all branches ending in an end + * point are removed, like the Prune ends option of the plugin. + * @param intensity an intensity image with the same dimensions as the + * skeleton, used for the intensity-based cycle pruning modes and + * for the branch intensities (by default branch intensities are + * NaN) + * @return one graph per skeleton (tree) + */ + @Override + public List apply( // + final RandomAccessibleInterval skeleton, // + @Nullable final double[] calibration, // + @Nullable final CyclePruning cyclePruning, // + @Nullable final Double pruneEnds, // + @Nullable final RandomAccessibleInterval intensity // + ) { + return SkeletonAnalyzer.analyze(skeleton, calibration, cyclePruning, + pruneEnds, intensity, false).graphs(); + } +} diff --git a/scijava-ops-image-gpl/src/main/java/org/scijava/ops/image/gpl/skeleton/DefaultSkeletonStatistics.java b/scijava-ops-image-gpl/src/main/java/org/scijava/ops/image/gpl/skeleton/DefaultSkeletonStatistics.java new file mode 100644 index 000000000..4c764fcf7 --- /dev/null +++ b/scijava-ops-image-gpl/src/main/java/org/scijava/ops/image/gpl/skeleton/DefaultSkeletonStatistics.java @@ -0,0 +1,105 @@ +/*- + * #%L + * GPL-licensed image processing operations for SciJava Ops, translated from copyleft ImageJ/Fiji plugins. + * %% + * Copyright (C) 2026 SciJava developers. + * %% + * This program 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. + * + * This program 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 this program. If not, see + * . + * #L% + */ + +package org.scijava.ops.image.gpl.skeleton; + +import java.util.List; + +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.type.BooleanType; +import net.imglib2.type.numeric.RealType; + +import org.scijava.function.Functions; +import org.scijava.ops.spi.Nullable; + +/** + * Measures the skeletons (trees) of a 2D or 3D skeleton image: for each tree, + * the number of branches, junctions, end point, junction and slab voxels, + * triple and quadruple points, the average and maximum branch length, and + * optionally the longest shortest path between two of its vertices (computed + * with the Floyd-Warshall algorithm). The result has one + * {@link SkeletonStatistics} row per tree, like the Results table of the Fiji + * AnalyzeSkeleton plugin; each row also holds the tree graph, whose edges + * provide the per-branch information. + *

+ * This is a translation of the Fiji + * AnalyzeSkeleton plugin + * by Ignacio Arganda-Carreras (GPL v3+), with the longest shortest path code + * by Huub Hovens, without its display parts. See {@code topology.skeletonGraph} + * for the graph construction, the pruning options and the deviations from the + * plugin. + *

+ *

+ * Reference: I. Arganda-Carreras, R. Fernández-González, A. Muñoz-Barrutia and + * C. Ortiz-de-Solórzano, "3D reconstruction of histological sections: + * Application to mammary gland tissue", Microscopy Research and Technique + * 73(11):1019-1029, 2010. + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @author Ignacio Arganda-Carreras + * @author Huub Hovens + * @param type of the skeleton image + * @param type of the intensity image + * @implNote op names='topology.skeletonStatistics' + */ +public class DefaultSkeletonStatistics, T extends RealType> + implements + Functions.Arity6, double[], CyclePruning, Double, RandomAccessibleInterval, Boolean, List> +{ + + /** + * Measures the skeletons. + * + * @param skeleton the 2D or 3D skeleton image, e.g. the output of + * {@code morphology.skeletonize} + * @param calibration the element spacing along each dimension (defaults to 1 + * for every dimension); lengths are reported in these units + * @param cyclePruning how to break the cycles of the skeleton (defaults to + * {@link CyclePruning#NONE}) + * @param pruneEnds the maximum calibrated length of the end branches to + * remove (by default no end branches are removed). With + * {@code Double.POSITIVE_INFINITY}, all branches ending in an end + * point are removed, like the Prune ends option of the plugin. + * @param intensity an intensity image with the same dimensions as the + * skeleton, used for the intensity-based cycle pruning modes and + * for the branch intensities (by default branch intensities are + * NaN) + * @param longestShortestPath whether to compute the longest shortest path + * of each tree (default false, in which case its length is NaN) + * @return the statistics of each skeleton (tree) + */ + @Override + public List apply( // + final RandomAccessibleInterval skeleton, // + @Nullable final double[] calibration, // + @Nullable final CyclePruning cyclePruning, // + @Nullable final Double pruneEnds, // + @Nullable final RandomAccessibleInterval intensity, // + @Nullable final Boolean longestShortestPath // + ) { + return SkeletonAnalyzer.analyze(skeleton, calibration, cyclePruning, + pruneEnds, intensity, Boolean.TRUE.equals(longestShortestPath)) + .statistics(); + } +} diff --git a/scijava-ops-image-gpl/src/main/java/org/scijava/ops/image/gpl/skeleton/SkeletonAnalyzer.java b/scijava-ops-image-gpl/src/main/java/org/scijava/ops/image/gpl/skeleton/SkeletonAnalyzer.java new file mode 100644 index 000000000..4dc484f3c --- /dev/null +++ b/scijava-ops-image-gpl/src/main/java/org/scijava/ops/image/gpl/skeleton/SkeletonAnalyzer.java @@ -0,0 +1,1257 @@ +/*- + * #%L + * GPL-licensed image processing operations for SciJava Ops, translated from copyleft ImageJ/Fiji plugins. + * %% + * Copyright (C) 2026 SciJava developers. + * %% + * This program 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. + * + * This program 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 this program. If not, see + * . + * #L% + */ + +package org.scijava.ops.image.gpl.skeleton; + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Deque; +import java.util.HashMap; +import java.util.IdentityHashMap; +import java.util.Iterator; +import java.util.List; +import java.util.ListIterator; +import java.util.Map; +import java.util.Set; + +import net.imglib2.Localizable; +import net.imglib2.Point; +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.type.BooleanType; +import net.imglib2.type.numeric.RealType; +import net.imglib2.util.Intervals; +import net.imglib2.view.Views; + +/** + * Skeleton analysis engine shared by the skeleton graph and statistics ops: a + * translation of the analysis part of the Fiji AnalyzeSkeleton plugin by + * Ignacio Arganda-Carreras (GPL v3+), including the longest shortest path + * computation by Huub Hovens. Images are handled as flat 3D arrays (a 2D image + * is a single slice). + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @author Ignacio Arganda-Carreras + * @author Huub Hovens + */ +final class SkeletonAnalyzer { + + private static final byte END_POINT = TagSkeleton.END_POINT; + private static final byte SLAB = TagSkeleton.SLAB; + private static final byte JUNCTION = TagSkeleton.JUNCTION; + + private static final int TREE = 0; + private static final int BACK = 1; + private static final int UNDEFINED = -1; + + // -- Input -- + + private final int width, height, depth; + private final long[] min; + private final int numDims; + private final byte[] image; + private final double[] intensity; + private final double pw, ph, pd; + private int xOffset = 1, yOffset = 1, zOffset = 1; + + // -- Working state -- + + private byte[] tagged; + private int[] labels; + private boolean[] visited; + + private List

listOfEndPoints; + private List

listOfJunctionVoxels; + private List

listOfSlabVoxels; + private List

listOfStartingSlabVoxels; + + private int numOfTrees; + private int[] numberOfBranches; + private int[] numberOfEndPoints; + private int[] numberOfJunctionVoxels; + private int[] numberOfSlabs; + private int[] numberOfJunctions; + private int[] numberOfTriplePoints; + private int[] numberOfQuadruplePoints; + private double[] averageBranchLength; + private double[] maximumBranchLength; + private List> endPointsTree; + private List> junctionVoxelTree; + private List> startingSlabTree; + private List>> listOfSingleJunctions; + private Vertex[][] junctionVertex; + private List> junctionVertexMap; + private Graph[] graph; + + private List

slabList; + private P auxPoint; + private Vertex auxFinalVertex; + + // -- Results -- + + private List graphs = Collections.emptyList(); + private List statistics = Collections.emptyList(); + + private SkeletonAnalyzer(final byte[] image, final long[] dims, + final long[] min, final double[] intensity, final double[] calibration) + { + numDims = dims.length; + width = (int) dims[0]; + height = (int) dims[1]; + depth = numDims == 3 ? (int) dims[2] : 1; + this.min = min; + this.image = image; + this.intensity = intensity; + pw = calibration[0]; + ph = calibration[1]; + pd = numDims == 3 ? calibration[2] : 1; + } + + /** + * Analyzes a 2D or 3D skeleton. + * + * @param input the skeleton + * @param calibration element spacing (null for 1 along each dimension) + * @param pruning the cycle pruning mode (null for none) + * @param pruneEnds maximum length of end branches to prune; null for no + * pruning, infinity to prune all end-point branches + * @param intensityImage optional intensity image + * @param shortestPath whether to compute the longest shortest paths + * @return the analyzer, with results available + */ + static , T extends RealType> SkeletonAnalyzer + analyze(final RandomAccessibleInterval input, double[] calibration, + CyclePruning pruning, final Double pruneEnds, + final RandomAccessibleInterval intensityImage, + final boolean shortestPath) + { + final int n = input.numDimensions(); + if (n != 2 && n != 3) { + throw new IllegalArgumentException( + "Only 2D and 3D images are supported"); + } + if (calibration == null) { + calibration = new double[n]; + Arrays.fill(calibration, 1); + } + if (calibration.length != n) { + throw new IllegalArgumentException("Calibration length (" + + calibration.length + ") does not match image dimensionality (" + n + + ")"); + } + for (final double c : calibration) { + if (!(c > 0) || Double.isInfinite(c)) { + throw new IllegalArgumentException( + "Calibration values must be positive and finite"); + } + } + if (pruning == null) pruning = CyclePruning.NONE; + if (intensityImage == null && (pruning == CyclePruning.LOWEST_INTENSITY_VOXEL || + pruning == CyclePruning.LOWEST_INTENSITY_BRANCH)) + { + throw new IllegalArgumentException("Cycle pruning mode " + pruning + + " requires an intensity image"); + } + if (pruneEnds != null && Double.isNaN(pruneEnds)) { + throw new IllegalArgumentException("End pruning length must not be NaN"); + } + final long[] dims = input.dimensionsAsLongArray(); + final long size = Intervals.numElements(dims); + if (size > Integer.MAX_VALUE) { + throw new IllegalArgumentException("Image is too large: " + size + + " elements"); + } + final byte[] img = new byte[(int) size]; + int i = 0; + for (final B b : Views.flatIterable(input)) + img[i++] = (byte) (b.get() ? 1 : 0); + double[] values = null; + if (intensityImage != null) { + if (!Intervals.equalDimensions(input, intensityImage)) { + throw new IllegalArgumentException( + "Intensity image dimensions do not match the skeleton"); + } + values = new double[(int) size]; + i = 0; + for (final T t : Views.flatIterable(intensityImage)) + values[i++] = t.getRealDouble(); + } + final SkeletonAnalyzer analyzer = new SkeletonAnalyzer(img, dims, input + .minAsLongArray(), values, calibration); + analyzer.run(pruning, pruneEnds, shortestPath); + return analyzer; + } + + List graphs() { + return graphs; + } + + List statistics() { + return statistics; + } + + // -- Main procedure -- + + private void run(final CyclePruning pruning, final Double pruneEnds, + final boolean shortestPath) + { + boolean pruneCycles = pruning != CyclePruning.NONE; + if (pruning == CyclePruning.LOWEST_INTENSITY_VOXEL || + pruning == CyclePruning.LOWEST_INTENSITY_BRANCH) + { + calculateNeighborhoodOffsets(); + } + + resetVisited(); + processSkeleton(); + + if (pruneEnds != null) pruneEndBranches(pruneEnds); + + if (pruneCycles && pruneCycles(pruning)) { + resetVisited(); + processSkeleton(); + } + + calculateTripleAndQuadruplePoints(); + + // Convert the graphs before the shortest path computation. + final Map vertexMap = new IdentityHashMap<>(); + final List result = new ArrayList<>(numOfTrees); + for (int t = 0; t < numOfTrees; t++) + result.add(toSkeletonGraph(graph[t], vertexMap)); + graphs = Collections.unmodifiableList(result); + + final List stats = new ArrayList<>(numOfTrees); + for (int t = 0; t < numOfTrees; t++) { + double longest = Double.NaN; + final List

path = new ArrayList<>(); + if (shortestPath) longest = warshallAlgorithm(graph[t], path); + final List pathPoints = new ArrayList<>(path.size()); + for (final P p : path) + pathPoints.add(toPoint(p)); + stats.add(new SkeletonStatistics(result.get(t), numberOfBranches[t], + numberOfJunctions[t], numberOfEndPoints[t], numberOfJunctionVoxels[t], + numberOfSlabs[t], averageBranchLength[t], numberOfTriplePoints[t], + numberOfQuadruplePoints[t], maximumBranchLength[t], longest, + pathPoints)); + } + statistics = Collections.unmodifiableList(stats); + } + + private void calculateNeighborhoodOffsets() { + double max = Math.max(pw, ph); + if (numDims == 3) max = Math.max(max, pd); + xOffset = Math.max(1, (int) Math.round(max / pw)); + yOffset = Math.max(1, (int) Math.round(max / ph)); + // Note: For a 2D image, out-of-image slices only scale the neighborhood + // averages, so they are skipped. + zOffset = numDims == 3 ? Math.max(1, (int) Math.round(max / pd)) : 0; + } + + private void processSkeleton() { + listOfEndPoints = new ArrayList<>(); + listOfJunctionVoxels = new ArrayList<>(); + listOfSlabVoxels = new ArrayList<>(); + listOfStartingSlabVoxels = new ArrayList<>(); + + tagged = tagImage(); + labels = markTrees(); + if (numOfTrees == 0) return; + + initializeTrees(); + divideVoxelsByTrees(); + groupJunctions(); + resetVisited(); + for (int i = 0; i < numOfTrees; i++) + visitSkeleton(i); + } + + // -- Pruning -- + + private void pruneEndBranches(final double length) { + final boolean all = length == Double.POSITIVE_INFINITY; + for (int t = 0; t < numOfTrees; t++) { + final ListIterator vit = graph[t].vertices.listIterator(); + while (vit.hasNext()) { + final Vertex v = vit.next(); + if (v.branches.size() != 1) continue; + final boolean prune = all ? isEndPoint(v.points.get(0)) : // + v.branches.get(0).length <= length; + if (!prune) continue; + + for (final P p : v.points) { + setPixel(image, p, (byte) 0); + setPixel(tagged, p, (byte) 0); + numberOfEndPoints[t]--; + } + final Edge branch = v.branches.get(0); + for (final P p : branch.slabs) { + setPixel(image, p, (byte) 0); + setPixel(tagged, p, (byte) 0); + numberOfSlabs[t]--; + } + removeIdentity(graph[t].edges, branch); + final Vertex opp = branch.opposite(v); + if (opp != v) removeIdentity(opp.branches, branch); + v.branches.clear(); + vit.remove(); + graph[t].vertexSet.remove(v); + } + // Note: The original plugin keeps a pruned root, from which the cycle + // search then finds nothing; here a remaining vertex becomes the root. + final Graph g = graph[t]; + if (g.root != null && !g.vertexSet.contains(g.root)) { + g.root = null; + for (final Vertex v : g.vertices) { + if (!v.branches.isEmpty()) { + g.root = v; + break; + } + } + if (g.root == null && !g.vertices.isEmpty()) g.root = g.vertices.get(0); + } + } + } + + private static void removeIdentity(final List list, final E e) { + final Iterator it = list.iterator(); + while (it.hasNext()) { + if (it.next() == e) { + it.remove(); + return; + } + } + } + + private boolean pruneCycles(final CyclePruning mode) { + boolean pruned = false; + for (int t = 0; t < numOfTrees; t++) { + if (startingSlabTree.get(t).size() == 1) { + // A ring without junctions: remove one voxel. + setPixel(image, startingSlabTree.get(t).get(0), (byte) 0); + pruned = true; + continue; + } + final List backEdges = graph[t].depthFirstSearch(); + if (backEdges.isEmpty()) continue; + for (final Edge e : backEdges) { + final List loopEdges = new ArrayList<>(); + loopEdges.add(e); + Edge minEdge = e; + final boolean v1First = e.v1.visitOrder < e.v2.visitOrder; + final Vertex finalLoopVertex = v1First ? e.v1 : e.v2; + Vertex backtrackVertex = v1First ? e.v2 : e.v1; + while (finalLoopVertex != backtrackVertex) { + final Edge pre = backtrackVertex.predecessor; + if (pre == null) break; + if (mode == CyclePruning.SHORTEST_BRANCH && pre.slabs.size() < minEdge.slabs.size()) { + minEdge = pre; + } + loopEdges.add(pre); + backtrackVertex = pre.v1 == backtrackVertex ? pre.v2 : pre.v1; + } + switch (mode) { + case SHORTEST_BRANCH: + final P removeCoords = minEdge.slabs.isEmpty() ? // + minEdge.v1.points.get(0) : // + minEdge.slabs.get(minEdge.slabs.size() / 2); + setPixel(image, removeCoords, (byte) 0); + break; + case LOWEST_INTENSITY_VOXEL: + removeLowestIntensityVoxel(loopEdges); + break; + case LOWEST_INTENSITY_BRANCH: + cutLowestIntensityBranch(loopEdges); + break; + default: + } + } + pruned = true; + } + return pruned; + } + + private void removeLowestIntensityVoxel(final List loopEdges) { + P lowestIntensityVoxel = null; + double lowestIntensityValue = Double.MAX_VALUE; + for (final Edge e : loopEdges) { + for (final P p : e.slabs) { + final double avg = averageNeighborhoodValue(p); + if (avg < lowestIntensityValue) { + lowestIntensityValue = avg; + lowestIntensityVoxel = p; + } + } + } + if (lowestIntensityVoxel != null) { + setPixel(image, lowestIntensityVoxel, (byte) 0); + } + } + + private void cutLowestIntensityBranch(final List loopEdges) { + Edge lowestIntensityEdge = null; + double lowestIntensityValue = Double.MAX_VALUE; + P cutPoint = null; + for (final Edge e : loopEdges) { + double minVal = Double.MAX_VALUE; + P darkestPoint = null; + double edgeIntensity = 0; + double nVox = 0; + for (final P p : e.slabs) { + final double avg = averageNeighborhoodValue(p); + if (avg < minVal) { + minVal = avg; + darkestPoint = p; + } + edgeIntensity += avg; + nVox++; + } + for (final P p : e.v1.points) { + edgeIntensity += averageNeighborhoodValue(p); + nVox++; + } + for (final P p : e.v2.points) { + edgeIntensity += averageNeighborhoodValue(p); + nVox++; + } + if (nVox != 0) edgeIntensity /= nVox; + if (edgeIntensity < lowestIntensityValue) { + lowestIntensityEdge = e; + lowestIntensityValue = edgeIntensity; + cutPoint = darkestPoint; + } + } + if (lowestIntensityEdge == null) return; + final P removeCoords = lowestIntensityEdge.slabs.isEmpty() ? // + lowestIntensityEdge.v1.points.get(0) : cutPoint; + setPixel(image, removeCoords, (byte) 0); + } + + private double averageNeighborhoodValue(final P p) { + double sum = 0; + int count = 0; + for (int z = p.z - zOffset; z <= p.z + zOffset; z++) { + for (int y = p.y - yOffset; y <= p.y + yOffset; y++) { + for (int x = p.x - xOffset; x <= p.x + xOffset; x++) { + if (inBounds(x, y, z)) sum += intensity[index(x, y, z)]; + count++; + } + } + } + return sum / count; + } + + // -- Tagging and tree marking -- + + private byte[] tagImage() { + final byte[] out = new byte[image.length]; + for (int z = 0; z < depth; z++) { + for (int x = 0; x < width; x++) { + for (int y = 0; y < height; y++) { + if (getPixel(image, x, y, z) == 0) continue; + final int neighbors = numberOfNeighbors(x, y, z); + final P p = new P(x, y, z); + if (neighbors < 2) { + out[index(x, y, z)] = END_POINT; + listOfEndPoints.add(p); + } + else if (neighbors > 2) { + out[index(x, y, z)] = JUNCTION; + listOfJunctionVoxels.add(p); + } + else { + out[index(x, y, z)] = SLAB; + listOfSlabVoxels.add(p); + } + } + } + } + return out; + } + + private int numberOfNeighbors(final int x, final int y, final int z) { + int n = 0; + for (int dz = -1; dz <= 1; dz++) + for (int dy = -1; dy <= 1; dy++) + for (int dx = -1; dx <= 1; dx++) + if (getPixel(image, x + dx, y + dy, z + dz) != 0) n++; + return n - 1; + } + + private int[] markTrees() { + final int[] out = new int[image.length]; + numOfTrees = 0; + int color = 0; + + for (final P p : listOfEndPoints) { + if (isVisited(p)) continue; + color++; + visitTree(p, out, color); + numOfTrees++; + } + for (final P p : listOfJunctionVoxels) { + if (isVisited(p)) continue; + color++; + if (visitTree(p, out, color) == 0) { + color--; + continue; + } + numOfTrees++; + } + // Circular trees without junctions. + for (final P p : listOfSlabVoxels) { + if (isVisited(p)) continue; + listOfStartingSlabVoxels.add(p); + color++; + if (visitTree(p, out, color) == 0) { + color--; + continue; + } + numOfTrees++; + } + resetVisited(); + return out; + } + + private int visitTree(final P start, final int[] out, final int color) { + int numOfVoxels = 0; + if (isVisited(start)) return 0; + out[index(start)] = color; + setVisited(start); + + final Deque

toRevisit = new ArrayDeque<>(); + if (isJunction(start)) toRevisit.add(start); + + P nextPoint = nextUnvisitedVoxel(start); + while (nextPoint != null || !toRevisit.isEmpty()) { + if (nextPoint != null) { + if (!isVisited(nextPoint)) { + numOfVoxels++; + out[index(nextPoint)] = color; + setVisited(nextPoint); + if (isJunction(nextPoint)) toRevisit.add(nextPoint); + nextPoint = nextUnvisitedVoxel(nextPoint); + } + } + else { + nextPoint = nextUnvisitedVoxel(toRevisit.peekFirst()); + if (nextPoint == null) toRevisit.removeFirst(); + } + } + return numOfVoxels; + } + + private void initializeTrees() { + numberOfBranches = new int[numOfTrees]; + numberOfEndPoints = new int[numOfTrees]; + numberOfJunctionVoxels = new int[numOfTrees]; + numberOfJunctions = new int[numOfTrees]; + numberOfSlabs = new int[numOfTrees]; + numberOfTriplePoints = new int[numOfTrees]; + numberOfQuadruplePoints = new int[numOfTrees]; + averageBranchLength = new double[numOfTrees]; + maximumBranchLength = new double[numOfTrees]; + endPointsTree = new ArrayList<>(); + junctionVoxelTree = new ArrayList<>(); + startingSlabTree = new ArrayList<>(); + listOfSingleJunctions = new ArrayList<>(); + junctionVertexMap = new ArrayList<>(); + for (int i = 0; i < numOfTrees; i++) { + endPointsTree.add(new ArrayList<>()); + junctionVoxelTree.add(new ArrayList<>()); + startingSlabTree.add(new ArrayList<>()); + listOfSingleJunctions.add(new ArrayList<>()); + junctionVertexMap.add(new HashMap<>()); + } + graph = new Graph[numOfTrees]; + junctionVertex = new Vertex[numOfTrees][]; + } + + private void divideVoxelsByTrees() { + for (final P p : listOfEndPoints) + endPointsTree.get(labels[index(p)] - 1).add(p); + for (final P p : listOfJunctionVoxels) + junctionVoxelTree.get(labels[index(p)] - 1).add(p); + for (final P p : listOfStartingSlabVoxels) + startingSlabTree.get(labels[index(p)] - 1).add(p); + for (int t = 0; t < numOfTrees; t++) { + numberOfEndPoints[t] = endPointsTree.get(t).size(); + numberOfJunctionVoxels[t] = junctionVoxelTree.get(t).size(); + } + } + + private void groupJunctions() { + resetVisited(); + for (int t = 0; t < numOfTrees; t++) { + for (final P p : junctionVoxelTree.get(t)) { + if (!isVisited(p)) fusionNeighborJunction(p, listOfSingleJunctions.get( + t)); + } + } + for (int t = 0; t < numOfTrees; t++) { + final List> groups = listOfSingleJunctions.get(t); + numberOfJunctions[t] = groups.size(); + junctionVertex[t] = new Vertex[groups.size()]; + for (int j = 0; j < groups.size(); j++) { + final Vertex v = new Vertex(); + for (final P p : groups.get(j)) { + v.points.add(p); + junctionVertexMap.get(t).put(p, v); + } + junctionVertex[t][j] = v; + } + } + resetVisited(); + } + + private void fusionNeighborJunction(final P start, + final List> singleJunctions) + { + final List

group = new ArrayList<>(); + group.add(start); + setVisited(start); + final Deque

toRevisit = new ArrayDeque<>(); + toRevisit.add(start); + P nextPoint = nextUnvisitedJunctionVoxel(start); + while (nextPoint != null || !toRevisit.isEmpty()) { + if (nextPoint != null && !isVisited(nextPoint)) { + group.add(nextPoint); + setVisited(nextPoint); + toRevisit.add(nextPoint); + nextPoint = nextUnvisitedJunctionVoxel(nextPoint); + } + else { + nextPoint = nextUnvisitedJunctionVoxel(toRevisit.peekFirst()); + if (nextPoint == null) toRevisit.removeFirst(); + } + } + singleJunctions.add(group); + } + + private void calculateTripleAndQuadruplePoints() { + for (int t = 0; t < numOfTrees; t++) { + for (final List

group : listOfSingleJunctions.get(t)) { + int nBranch = 0; + for (final P p : group) { + for (int dz = -1; dz <= 1; dz++) + for (int dy = -1; dy <= 1; dy++) + for (int dx = -1; dx <= 1; dx++) { + final byte tag = getPixel(tagged, p.x + dx, p.y + dy, p.z + + dz); + if (tag == SLAB || tag == END_POINT) nBranch++; + } + } + if (nBranch == 3) numberOfTriplePoints[t]++; + else if (nBranch == 4) numberOfQuadruplePoints[t]++; + } + } + } + + // -- Graph construction -- + + private void visitSkeleton(final int t) { + final Graph g = graph[t] = new Graph(); + for (final Vertex v : junctionVertex[t]) + g.addVertex(v); + + double branchLength = 0; + maximumBranchLength[t] = 0; + numberOfSlabs[t] = 0; + + // Visit branches starting at end points. + final List

endPoints = endPointsTree.get(t); + for (int i = 0; i < numberOfEndPoints[t]; i++) { + final P endPoint = endPoints.get(i); + if (isVisited(endPoint)) continue; + + final Vertex v1 = new Vertex(); + v1.points.add(endPoint); + g.addVertex(v1); + if (i == 0) g.root = v1; + + slabList = new ArrayList<>(); + final double[] properties = visitBranch(endPoint, t); + double length = properties[0]; + + if (length == 0) { + // A single voxel tree, or an end point next to a junction. + final P aux = visitedJunctionNeighbor(endPoint, v1); + if (aux != null) { + auxFinalVertex = findPointVertex(t, aux); + length += distance(endPoint, aux); + length += distance(auxFinalVertex.points.get(0), endPoint); + g.addVertex(auxFinalVertex); + g.addEdge(new Edge(v1, auxFinalVertex, slabList, length, + properties[1], properties[2], properties[3])); + numberOfBranches[t]++; + branchLength += length; + } + continue; + } + + if (isSlab(auxPoint)) { + // Connect to the neighbor junction not belonging to the initial + // vertex, unless it is a self loop. + final P aux = auxPoint; + auxPoint = visitedJunctionNeighbor(auxPoint, v1); + auxFinalVertex = findPointVertex(t, auxPoint); + if (auxPoint == null) { + auxFinalVertex = v1; + auxPoint = aux; + } + length += distance(auxPoint, aux); + length += distance(auxFinalVertex.points.get(0), auxPoint); + } + + g.addVertex(auxFinalVertex); + g.addEdge(new Edge(v1, auxFinalVertex, slabList, length, properties[1], + properties[2], properties[3])); + numberOfBranches[t]++; + branchLength += length; + if (length > maximumBranchLength[t]) maximumBranchLength[t] = length; + } + + if (numberOfEndPoints[t] == 0 && !junctionVoxelTree.get(t).isEmpty()) { + g.root = junctionVertex[t][0]; + } + + // Visit branches starting at junctions. + for (final Vertex jv : junctionVertex[t]) { + for (final P junctionCoord : jv.points) { + setVisited(junctionCoord); + P nextPoint = nextUnvisitedVoxel(junctionCoord); + while (nextPoint != null) { + if (!isJunction(nextPoint)) { + slabList = new ArrayList<>(); + slabList.add(nextPoint); + numberOfSlabs[t]++; + + double length = distance(junctionCoord, nextPoint); + auxPoint = null; + final double[] properties = visitBranch(nextPoint, t); + length += properties[0]; + + if (length != 0) { + if (auxPoint == null) auxPoint = nextPoint; + numberOfBranches[t]++; + + final Vertex initialVertex = findPointVertex(t, junctionCoord); + + if (isSlab(auxPoint)) { + final P aux = auxPoint; + auxPoint = visitedJunctionNeighbor(auxPoint, initialVertex); + auxFinalVertex = findPointVertex(t, auxPoint); + if (auxPoint == null) { + auxFinalVertex = initialVertex; + auxPoint = aux; + } + length += distance(auxPoint, aux); + } + + if (length > maximumBranchLength[t]) { + maximumBranchLength[t] = length; + } + + // Measure from the main voxel of the initial junction. + length += distance(initialVertex.points.get(0), junctionCoord); + + g.addEdge(new Edge(initialVertex, auxFinalVertex, slabList, + length, properties[1], properties[2], properties[3])); + branchLength += length; + if (length > maximumBranchLength[t]) { + maximumBranchLength[t] = length; + } + } + } + else setVisited(nextPoint); + + nextPoint = nextUnvisitedVoxel(junctionCoord); + } + } + } + + // Visit circular trees starting at a slab. + if (startingSlabTree.get(t).size() == 1) { + final P startCoord = startingSlabTree.get(t).get(0); + final Vertex v1 = new Vertex(); + v1.points.add(startCoord); + g.addVertex(v1); + + slabList = new ArrayList<>(); + slabList.add(startCoord); + numberOfSlabs[t]++; + + final double[] properties = visitBranch(startCoord, t); + final double length = properties[0]; + if (length != 0) { + numberOfBranches[t]++; + branchLength += length; + if (length > maximumBranchLength[t]) maximumBranchLength[t] = length; + } + g.addEdge(new Edge(v1, v1, slabList, length, properties[1], + properties[2], properties[3])); + } + + if (numberOfBranches[t] == 0) return; + averageBranchLength[t] = branchLength / numberOfBranches[t]; + } + + /** + * Visits a branch until reaching an end point or junction, and returns its + * length, average intensity of the inner third, average intensity and + * running average length. + */ + private double[] visitBranch(final P start, final int t) { + double length = 0; + double lengthRa = 0; + final double noIntensity = intensity == null ? Double.NaN : 0; + final double[] ret = { 0, noIntensity, noIntensity, 0 }; + + final List

pointHistory = new ArrayList<>(); + pointHistory.add(start); + setVisited(start); + + P nextPoint = nextUnvisitedVoxel(start); + if (nextPoint == null) return ret; + + P previousPoint = start; + while (nextPoint != null && isSlab(nextPoint)) { + numberOfSlabs[t]++; + slabList.add(nextPoint); + length += distance(previousPoint, nextPoint); + pointHistory.add(0, nextPoint); + lengthRa += runningAverageDistance(pointHistory); + setVisited(nextPoint); + previousPoint = nextPoint; + nextPoint = nextUnvisitedVoxel(previousPoint); + } + + if (nextPoint != null) { + length += distance(previousPoint, nextPoint); + pointHistory.add(0, nextPoint); + lengthRa += runningAverageDistance(pointHistory); + setVisited(nextPoint); + + if (isEndPoint(nextPoint)) { + auxFinalVertex = new Vertex(); + auxFinalVertex.points.add(nextPoint); + } + else if (isJunction(nextPoint)) { + auxFinalVertex = findPointVertex(t, nextPoint); + final double d = distance(auxFinalVertex.points.get(0), nextPoint); + length += d; + lengthRa += d; + } + auxPoint = nextPoint; + } + else auxPoint = previousPoint; + + ret[0] = length; + ret[3] = lengthRa; + if (intensity != null) { + // Average intensity of the whole branch, and of its inner third (which + // is less affected by the junctions at both ends). + final int size = pointHistory.size(); + final int start3rd = (int) (size / 3.0); + final int end3rd = (int) (2 * size / 3.0); + double sum = 0, sum3rd = 0; + for (int i = 0; i < size; i++) { + final double value = intensity[index(pointHistory.get(i))]; + sum += value; + if (i >= start3rd && i < end3rd) sum3rd += value; + } + ret[1] = sum3rd / (end3rd - start3rd); + ret[2] = sum / size; + } + return ret; + } + + private Vertex findPointVertex(final int t, final P p) { + return p == null ? null : junctionVertexMap.get(t).get(p); + } + + private double distance(final P p1, final P p2) { + final double dx = (p1.x - p2.x) * pw; + final double dy = (p1.y - p2.y) * ph; + final double dz = (p1.z - p2.z) * pd; + return Math.sqrt(dx * dx + dy * dy + dz * dz); + } + + /** + * Linearly corrected distance between the two most recent points (index 0 + * is the most recent), using up to the five last points. + */ + private double runningAverageDistance(final List

points) { + final int indexOfLast = points.size() - 1; + if (indexOfLast < 1) return 0; + final int poi = Math.min(5, indexOfLast); + return distance(points.get(poi), points.get(0)) / poi; + } + + // -- Longest shortest path -- + + private double warshallAlgorithm(final Graph g, final List

path) { + final List edgeList = g.edges; + final List vertexList = g.vertices; + final int nv = vertexList.size(); + if (nv == 0) return 0; + if (nv == 1) { + path.add(vertexList.get(0).points.get(0)); + return 0; + } + + final Map indices = new IdentityHashMap<>(); + for (int i = 0; i < nv; i++) + indices.putIfAbsent(vertexList.get(i), i); + + final double[][] adjacency = new double[nv][nv]; + final int[][] predecessor = new int[nv][nv]; + for (int i = 0; i < nv; i++) { + Arrays.fill(adjacency[i], Double.POSITIVE_INFINITY); + Arrays.fill(predecessor[i], -1); + } + for (final Edge edge : edgeList) { + final Integer row = indices.get(edge.v1); + final Integer column = indices.get(edge.v2); + if (row == null || column == null) continue; + adjacency[row][row] = 0; + adjacency[column][column] = 0; + adjacency[row][column] = edge.length; + adjacency[column][row] = edge.length; + predecessor[row][row] = -1; + predecessor[column][column] = -1; + predecessor[row][column] = row; + predecessor[column][row] = column; + } + + for (int k = 0; k < nv; k++) { + for (int i = 0; i < nv; i++) { + for (int j = 0; j < nv; j++) { + if (adjacency[i][k] + adjacency[k][j] < adjacency[i][j]) { + adjacency[i][j] = adjacency[i][k] + adjacency[k][j]; + predecessor[i][j] = predecessor[k][j]; + } + } + } + } + + double maxPath = 0; + int a = 0, b = 0; + for (int i = 0; i < nv; i++) { + for (int j = 0; j < nv; j++) { + if (adjacency[i][j] > maxPath && + adjacency[i][j] != Double.POSITIVE_INFINITY) + { + maxPath = adjacency[i][j]; + a = i; + b = j; + } + } + } + + reconstructPath(predecessor, a, b, edgeList, vertexList, path); + return maxPath; + } + + private static void reconstructPath(final int[][] predecessorMatrix, + final int a, int b, final List edgeList, final List vertexList, + final List

path) + { + while (b != a) { + final Vertex predecessor = vertexList.get(predecessorMatrix[a][b]); + final Vertex endVertex = vertexList.get(b); + // There may be several edges between two vertices: take the shortest. + Edge shortestEdge = null; + double shortestLength = Double.POSITIVE_INFINITY; + for (final Edge edge : edgeList) { + if ((edge.v1 == predecessor && edge.v2 == endVertex) || + (edge.v1 == endVertex && edge.v2 == predecessor)) + { + if (edge.length < shortestLength) { + shortestEdge = edge; + shortestLength = edge.length; + } + } + } + final boolean reverse = shortestEdge.v2 != predecessor; + final Vertex first = reverse ? shortestEdge.v2 : shortestEdge.v1; + final Vertex second = reverse ? shortestEdge.v1 : shortestEdge.v2; + for (final P p : first.points) + if (!path.contains(p)) path.add(p); + final List

slabs = new ArrayList<>(shortestEdge.slabs); + if (reverse) Collections.reverse(slabs); + path.addAll(slabs); + for (final P p : second.points) + if (!path.contains(p)) path.add(p); + b = predecessorMatrix[a][b]; + } + } + + // -- Conversion to the public model -- + + private SkeletonGraph toSkeletonGraph(final Graph g, + final Map vertexMap) + { + final List vertices = new ArrayList<>(g.vertices.size()); + for (final Vertex v : g.vertices) + vertices.add(toSkeletonVertex(v, vertexMap)); + final List edges = new ArrayList<>(g.edges.size()); + for (final Edge e : g.edges) { + final List slabs = new ArrayList<>(e.slabs.size()); + for (final P p : e.slabs) + slabs.add(toPoint(p)); + edges.add(new SkeletonEdge(toSkeletonVertex(e.v1, vertexMap), + toSkeletonVertex(e.v2, vertexMap), slabs, e.length, e.lengthRa, + distance(e.v1.points.get(0), e.v2.points.get(0)), e.color, + e.color3rd)); + } + final SkeletonVertex root = g.root == null ? null : toSkeletonVertex( + g.root, vertexMap); + return new SkeletonGraph(vertices, edges, root); + } + + private SkeletonVertex toSkeletonVertex(final Vertex v, + final Map vertexMap) + { + return vertexMap.computeIfAbsent(v, key -> { + final List points = new ArrayList<>(key.points.size()); + for (final P p : key.points) + points.add(toPoint(p)); + return new SkeletonVertex(points); + }); + } + + private Localizable toPoint(final P p) { + final long[] pos = new long[numDims]; + pos[0] = p.x + min[0]; + pos[1] = p.y + min[1]; + if (numDims == 3) pos[2] = p.z + min[2]; + return Point.wrap(pos); + } + + // -- Voxel helpers -- + + private interface VoxelTest { + + boolean test(int x, int y, int z); + } + + /** + * Searches the 26-neighborhood of a point. Note: Like the original plugin, + * whose loop exits only the innermost (z) loop on a match, this returns the + * first match along z of the last (x, y) column with a match; the visit + * order of the skeleton, and hence its graph, depends on it. + */ + private static P findNeighbor(final P p, final VoxelTest test) { + P result = null; + for (int x = -1; x < 2; x++) + for (int y = -1; y < 2; y++) + for (int z = -1; z < 2; z++) { + if (x == 0 && y == 0 && z == 0) continue; + if (test.test(p.x + x, p.y + y, p.z + z)) { + result = new P(p.x + x, p.y + y, p.z + z); + break; + } + } + return result; + } + + private P nextUnvisitedVoxel(final P p) { + return findNeighbor(p, (x, y, z) -> getPixel(image, x, y, z) != 0 && + !isVisited(x, y, z)); + } + + private P nextUnvisitedJunctionVoxel(final P p) { + return findNeighbor(p, (x, y, z) -> getPixel(image, x, y, z) != 0 && + !isVisited(x, y, z) && getPixel(tagged, x, y, z) == JUNCTION); + } + + private P visitedJunctionNeighbor(final P p, final Vertex exclude) { + return findNeighbor(p, (x, y, z) -> getPixel(image, x, y, z) != 0 && + isVisited(x, y, z) && getPixel(tagged, x, y, z) == JUNCTION && !exclude + .points.contains(new P(x, y, z))); + } + + private boolean isSlab(final P p) { + return getPixel(tagged, p.x, p.y, p.z) == SLAB; + } + + private boolean isJunction(final P p) { + return getPixel(tagged, p.x, p.y, p.z) == JUNCTION; + } + + private boolean isEndPoint(final P p) { + return getPixel(tagged, p.x, p.y, p.z) == END_POINT; + } + + private void resetVisited() { + visited = new boolean[image.length]; + } + + private boolean isVisited(final P p) { + return isVisited(p.x, p.y, p.z); + } + + /** Out-of-image voxels count as visited. */ + private boolean isVisited(final int x, final int y, final int z) { + return !inBounds(x, y, z) || visited[index(x, y, z)]; + } + + private void setVisited(final P p) { + if (inBounds(p.x, p.y, p.z)) visited[index(p)] = true; + } + + private boolean inBounds(final int x, final int y, final int z) { + return x >= 0 && x < width && y >= 0 && y < height && z >= 0 && z < depth; + } + + private int index(final int x, final int y, final int z) { + return x + width * (y + height * z); + } + + private int index(final P p) { + return index(p.x, p.y, p.z); + } + + private byte getPixel(final byte[] img, final int x, final int y, + final int z) + { + return inBounds(x, y, z) ? img[index(x, y, z)] : 0; + } + + private void setPixel(final byte[] img, final P p, final byte value) { + if (inBounds(p.x, p.y, p.z)) img[index(p)] = value; + } + + // -- Helper classes -- + + private static final class P { + + final int x, y, z; + + P(final int x, final int y, final int z) { + this.x = x; + this.y = y; + this.z = z; + } + + @Override + public boolean equals(final Object o) { + if (!(o instanceof P)) return false; + final P p = (P) o; + return x == p.x && y == p.y && z == p.z; + } + + @Override + public int hashCode() { + return (x * 31 + y) * 31 + z; + } + } + + private static final class Vertex { + + final List

points = new ArrayList<>(); + final List branches = new ArrayList<>(); + boolean visited; + Edge predecessor; + int visitOrder = -1; + } + + private static final class Edge { + + final Vertex v1, v2; + final List

slabs; + final double length, color3rd, color, lengthRa; + int type = UNDEFINED; + + Edge(final Vertex v1, final Vertex v2, final List

slabs, + final double length, final double color3rd, final double color, + final double lengthRa) + { + this.v1 = v1; + this.v2 = v2; + this.slabs = slabs; + this.length = length; + this.color3rd = color3rd; + this.color = color; + this.lengthRa = lengthRa; + } + + Vertex opposite(final Vertex v) { + if (v1 == v) return v2; + if (v2 == v) return v1; + return null; + } + } + + private static final class Graph { + + final List edges = new ArrayList<>(); + final List vertices = new ArrayList<>(); + final Set vertexSet = Collections.newSetFromMap( + new IdentityHashMap<>()); + Vertex root; + + void addEdge(final Edge e) { + e.v1.branches.add(e); + if (e.v1 != e.v2) e.v2.branches.add(e); + edges.add(e); + } + + void addVertex(final Vertex v) { + if (vertexSet.add(v)) vertices.add(v); + } + + /** Depth-first search from the root, returning the back edges. */ + List depthFirstSearch() { + final List backEdges = new ArrayList<>(); + if (root == null) return backEdges; + for (final Vertex v : vertices) + v.visited = false; + final Deque stack = new ArrayDeque<>(); + stack.push(root); + int visitOrder = 0; + while (!stack.isEmpty()) { + final Vertex u = stack.pop(); + if (u.visited) continue; + if (u.predecessor != null) u.predecessor.type = TREE; + u.visited = true; + u.visitOrder = visitOrder++; + for (final Edge e : u.branches) { + if (e.type != UNDEFINED) continue; + final Vertex ov = e.opposite(u); + if (!ov.visited) { + stack.push(ov); + ov.predecessor = e; + } + else { + e.type = BACK; + backEdges.add(e); + } + } + } + return backEdges; + } + } +} diff --git a/scijava-ops-image-gpl/src/main/java/org/scijava/ops/image/gpl/skeleton/SkeletonEdge.java b/scijava-ops-image-gpl/src/main/java/org/scijava/ops/image/gpl/skeleton/SkeletonEdge.java new file mode 100644 index 000000000..ef378d9bb --- /dev/null +++ b/scijava-ops-image-gpl/src/main/java/org/scijava/ops/image/gpl/skeleton/SkeletonEdge.java @@ -0,0 +1,141 @@ +/*- + * #%L + * GPL-licensed image processing operations for SciJava Ops, translated from copyleft ImageJ/Fiji plugins. + * %% + * Copyright (C) 2026 SciJava developers. + * %% + * This program 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. + * + * This program 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 this program. If not, see + * . + * #L% + */ + +package org.scijava.ops.image.gpl.skeleton; + +import java.util.Collections; +import java.util.List; + +import net.imglib2.Localizable; + +/** + * An immutable edge (branch) of a {@link SkeletonGraph}, connecting two + * vertices (the same vertex for a loop) through a path of slab voxels. The + * properties correspond to the columns of the "Branch information" table of + * the Fiji AnalyzeSkeleton plugin. + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @author Ignacio Arganda-Carreras + */ +public final class SkeletonEdge { + + private final SkeletonVertex v1; + private final SkeletonVertex v2; + private final List slabs; + private final double length; + private final double runningAverageLength; + private final double euclideanDistance; + private final double averageIntensity; + private final double averageIntensityInnerThird; + + SkeletonEdge(final SkeletonVertex v1, final SkeletonVertex v2, + final List slabs, final double length, + final double runningAverageLength, final double euclideanDistance, + final double averageIntensity, final double averageIntensityInnerThird) + { + this.v1 = v1; + this.v2 = v2; + this.slabs = Collections.unmodifiableList(slabs); + this.length = length; + this.runningAverageLength = runningAverageLength; + this.euclideanDistance = euclideanDistance; + this.averageIntensity = averageIntensity; + this.averageIntensityInnerThird = averageIntensityInnerThird; + } + + /** @return the first vertex */ + public SkeletonVertex v1() { + return v1; + } + + /** @return the second vertex */ + public SkeletonVertex v2() { + return v2; + } + + /** + * Gets the vertex at the other end of this edge. + * + * @param v one of the vertices of this edge + * @return the opposite vertex, or {@code null} if {@code v} is not a vertex + * of this edge + */ + public SkeletonVertex opposite(final SkeletonVertex v) { + if (v == v1) return v2; + if (v == v2) return v1; + return null; + } + + /** @return the slab voxels of this edge, in image coordinates */ + public List slabs() { + return slabs; + } + + /** + * @return the calibrated length of this branch, measured along its voxels + * from the first point of one vertex to the first point of the + * other + */ + public double length() { + return length; + } + + /** + * @return the calibrated branch length smoothed with a running average + * over up to five voxels, which reduces the overestimation of + * diagonal staircase paths + */ + public double runningAverageLength() { + return runningAverageLength; + } + + /** + * @return the calibrated straight-line distance between the first points + * of the two vertices + */ + public double euclideanDistance() { + return euclideanDistance; + } + + /** + * @return the average intensity image value along the branch, or NaN when + * no intensity image was given + */ + public double averageIntensity() { + return averageIntensity; + } + + /** + * @return the average intensity image value along the inner third of the + * branch, or NaN when no intensity image was given + */ + public double averageIntensityInnerThird() { + return averageIntensityInnerThird; + } + + @Override + public String toString() { + return "SkeletonEdge[" + v1.points().get(0) + " - " + v2.points().get(0) + + ", length=" + length + "]"; + } +} diff --git a/scijava-ops-image-gpl/src/main/java/org/scijava/ops/image/gpl/skeleton/SkeletonGraph.java b/scijava-ops-image-gpl/src/main/java/org/scijava/ops/image/gpl/skeleton/SkeletonGraph.java new file mode 100644 index 000000000..f430bad3c --- /dev/null +++ b/scijava-ops-image-gpl/src/main/java/org/scijava/ops/image/gpl/skeleton/SkeletonGraph.java @@ -0,0 +1,103 @@ +/*- + * #%L + * GPL-licensed image processing operations for SciJava Ops, translated from copyleft ImageJ/Fiji plugins. + * %% + * Copyright (C) 2026 SciJava developers. + * %% + * This program 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. + * + * This program 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 this program. If not, see + * . + * #L% + */ + +package org.scijava.ops.image.gpl.skeleton; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.IdentityHashMap; +import java.util.List; +import java.util.Map; + +/** + * An immutable, undirected graph of one skeleton (tree): one connected + * component of the skeleton image. Two vertices may be joined by several + * edges, and an edge may join a vertex to itself. + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @author Ignacio Arganda-Carreras + */ +public final class SkeletonGraph { + + private final List vertices; + private final List edges; + private final SkeletonVertex root; + private final Map> incidence; + + SkeletonGraph(final List vertices, + final List edges, final SkeletonVertex root) + { + this.vertices = Collections.unmodifiableList(vertices); + this.edges = Collections.unmodifiableList(edges); + this.root = root; + incidence = new IdentityHashMap<>(); + for (final SkeletonVertex v : vertices) + incidence.put(v, new ArrayList<>()); + for (final SkeletonEdge e : edges) { + incidence.computeIfAbsent(e.v1(), k -> new ArrayList<>()).add(e); + if (e.v2() != e.v1()) { + incidence.computeIfAbsent(e.v2(), k -> new ArrayList<>()).add(e); + } + } + for (final Map.Entry> entry : incidence + .entrySet()) + { + entry.setValue(Collections.unmodifiableList(entry.getValue())); + } + } + + /** @return the vertices of this graph */ + public List vertices() { + return vertices; + } + + /** @return the edges (branches) of this graph */ + public List edges() { + return edges; + } + + /** + * @return the root vertex: the first end point of the tree, or its first + * junction if it has no end points, or {@code null} for a ring + */ + public SkeletonVertex root() { + return root; + } + + /** + * Gets the edges incident to a vertex; a loop edge is listed once. + * + * @param v a vertex of this graph + * @return the incident edges (empty if {@code v} is not in this graph) + */ + public List edgesOf(final SkeletonVertex v) { + final List result = incidence.get(v); + return result == null ? Collections.emptyList() : result; + } + + @Override + public String toString() { + return "SkeletonGraph[" + vertices.size() + " vertices, " + edges.size() + + " edges]"; + } +} diff --git a/scijava-ops-image-gpl/src/main/java/org/scijava/ops/image/gpl/skeleton/SkeletonStatistics.java b/scijava-ops-image-gpl/src/main/java/org/scijava/ops/image/gpl/skeleton/SkeletonStatistics.java new file mode 100644 index 000000000..4f083c02a --- /dev/null +++ b/scijava-ops-image-gpl/src/main/java/org/scijava/ops/image/gpl/skeleton/SkeletonStatistics.java @@ -0,0 +1,140 @@ +/*- + * #%L + * GPL-licensed image processing operations for SciJava Ops, translated from copyleft ImageJ/Fiji plugins. + * %% + * Copyright (C) 2026 SciJava developers. + * %% + * This program 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. + * + * This program 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 this program. If not, see + * . + * #L% + */ + +package org.scijava.ops.image.gpl.skeleton; + +import java.util.Collections; +import java.util.List; + +import net.imglib2.Localizable; + +/** + * Immutable statistics of one skeleton (tree): one row of the "Results" table + * of the Fiji AnalyzeSkeleton plugin. + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @author Ignacio Arganda-Carreras + */ +public final class SkeletonStatistics { + + private final SkeletonGraph graph; + private final int branches; + private final int junctions; + private final int endPointVoxels; + private final int junctionVoxels; + private final int slabVoxels; + private final double averageBranchLength; + private final int triplePoints; + private final int quadruplePoints; + private final double maximumBranchLength; + private final double longestShortestPath; + private final List longestShortestPathPoints; + + SkeletonStatistics(final SkeletonGraph graph, final int branches, + final int junctions, final int endPointVoxels, final int junctionVoxels, + final int slabVoxels, final double averageBranchLength, + final int triplePoints, final int quadruplePoints, + final double maximumBranchLength, final double longestShortestPath, + final List longestShortestPathPoints) + { + this.graph = graph; + this.branches = branches; + this.junctions = junctions; + this.endPointVoxels = endPointVoxels; + this.junctionVoxels = junctionVoxels; + this.slabVoxels = slabVoxels; + this.averageBranchLength = averageBranchLength; + this.triplePoints = triplePoints; + this.quadruplePoints = quadruplePoints; + this.maximumBranchLength = maximumBranchLength; + this.longestShortestPath = longestShortestPath; + this.longestShortestPathPoints = Collections.unmodifiableList( + longestShortestPathPoints); + } + + /** @return the graph of this skeleton */ + public SkeletonGraph graph() { + return graph; + } + + /** @return the number of branches */ + public int branches() { + return branches; + } + + /** @return the number of junctions (clusters of adjacent junction voxels) */ + public int junctions() { + return junctions; + } + + /** @return the number of end point voxels */ + public int endPointVoxels() { + return endPointVoxels; + } + + /** @return the number of junction voxels */ + public int junctionVoxels() { + return junctionVoxels; + } + + /** @return the number of slab voxels */ + public int slabVoxels() { + return slabVoxels; + } + + /** @return the average calibrated branch length (0 without branches) */ + public double averageBranchLength() { + return averageBranchLength; + } + + /** @return the number of junctions with exactly three branches */ + public int triplePoints() { + return triplePoints; + } + + /** @return the number of junctions with exactly four branches */ + public int quadruplePoints() { + return quadruplePoints; + } + + /** @return the maximum calibrated branch length */ + public double maximumBranchLength() { + return maximumBranchLength; + } + + /** + * @return the calibrated length of the longest shortest path between two + * vertices of the graph, or NaN if it was not computed + */ + public double longestShortestPath() { + return longestShortestPath; + } + + /** + * @return the voxels of the longest shortest path, in image coordinates + * (empty if it was not computed); the first one is its start + */ + public List longestShortestPathPoints() { + return longestShortestPathPoints; + } +} diff --git a/scijava-ops-image-gpl/src/main/java/org/scijava/ops/image/gpl/skeleton/SkeletonVertex.java b/scijava-ops-image-gpl/src/main/java/org/scijava/ops/image/gpl/skeleton/SkeletonVertex.java new file mode 100644 index 000000000..1adb5ff98 --- /dev/null +++ b/scijava-ops-image-gpl/src/main/java/org/scijava/ops/image/gpl/skeleton/SkeletonVertex.java @@ -0,0 +1,60 @@ +/*- + * #%L + * GPL-licensed image processing operations for SciJava Ops, translated from copyleft ImageJ/Fiji plugins. + * %% + * Copyright (C) 2026 SciJava developers. + * %% + * This program 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. + * + * This program 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 this program. If not, see + * . + * #L% + */ + +package org.scijava.ops.image.gpl.skeleton; + +import java.util.Collections; +import java.util.List; + +import net.imglib2.Localizable; + +/** + * An immutable vertex of a {@link SkeletonGraph}: an end point, a junction + * (a cluster of adjacent junction voxels), or the starting voxel of a ring. + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @author Ignacio Arganda-Carreras + */ +public final class SkeletonVertex { + + private final List points; + + SkeletonVertex(final List points) { + this.points = Collections.unmodifiableList(points); + } + + /** + * Gets the voxels of this vertex, in image coordinates. + * + * @return the voxels; the first one is the representative position used + * for distances + */ + public List points() { + return points; + } + + @Override + public String toString() { + return "SkeletonVertex" + points; + } +} diff --git a/scijava-ops-image-gpl/src/main/java/org/scijava/ops/image/gpl/skeleton/Skeletonize.java b/scijava-ops-image-gpl/src/main/java/org/scijava/ops/image/gpl/skeleton/Skeletonize.java new file mode 100644 index 000000000..faae41425 --- /dev/null +++ b/scijava-ops-image-gpl/src/main/java/org/scijava/ops/image/gpl/skeleton/Skeletonize.java @@ -0,0 +1,300 @@ +/*- + * #%L + * GPL-licensed image processing operations for SciJava Ops, translated from copyleft ImageJ/Fiji plugins. + * %% + * Copyright (C) 2026 SciJava developers. + * %% + * This program 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. + * + * This program 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 this program. If not, see + * . + * #L% + */ + +package org.scijava.ops.image.gpl.skeleton; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Function; + +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.img.array.ArrayImg; +import net.imglib2.img.array.ArrayImgs; +import net.imglib2.img.basictypeaccess.array.LongArray; +import net.imglib2.type.BooleanType; +import net.imglib2.type.logic.BitType; +import net.imglib2.view.Views; + +/** + * Skeletonizes (thins) a 2D or 3D binary image with the 3D medial axis thinning + * algorithm of Lee, Kashyap & Chu. + *

+ * The image is thinned in repeated passes over the six border directions + * (north, south, east, west, up, bottom; only the first four for 2D images). + * In each pass, foreground border points that are not end points, whose + * removal preserves the Euler characteristic (26-connectivity) and which are + * simple points (their removal does not change the connectivity of their + * 3×3×3 neighborhood) are collected; they are then re-checked + * sequentially for simplicity and removed. Thinning stops when a full round of + * six passes removes nothing. Elements outside the image are background. + *

+ *

+ * This is a translation of the Fiji + * Skeletonize3D plugin by + * Ignacio Arganda-Carreras (GPL v3+), itself based on the ITK implementation + * by Hanno Homann (Insight + * Journal). For a 2D input, the result matches the plugin run on a + * single-slice image. + *

+ *

+ * Reference: T.-C. Lee, R. L. Kashyap and C.-N. Chu, "Building skeleton models + * via 3-D medial surface/axis thinning algorithms", CVGIP: Graphical Models + * and Image Processing 56(6):462-478, 1994. + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @author Ignacio Arganda-Carreras + * @param type of the binary input + * @implNote op names='morphology.skeletonize' + */ +public class Skeletonize> implements + Function, RandomAccessibleInterval> +{ + + /** + * Euler characteristic contribution of each octant configuration, for odd + * indices 1, 3, ..., 255 (even indices contribute 0), as tabulated by Lee + * et al. (1994). + */ + private static final int[] EULER_ODD = { // + 1, -1, -1, 1, -3, -1, -1, 1, -1, 1, 1, -1, 3, 1, 1, -1, // + -3, -1, 3, 1, 1, -1, 3, 1, -1, 1, 1, -1, 3, 1, 1, -1, // + -3, 3, -1, 1, 1, 3, -1, 1, -1, 1, 1, -1, 3, 1, 1, -1, // + 1, 3, 3, 1, 5, 3, 3, 1, -1, 1, 1, -1, 3, 1, 1, -1, // + -7, -1, -1, 1, -3, -1, -1, 1, -1, 1, 1, -1, 3, 1, 1, -1, // + -3, -1, 3, 1, 1, -1, 3, 1, -1, 1, 1, -1, 3, 1, 1, -1, // + -3, 3, -1, 1, 1, 3, -1, 1, -1, 1, 1, -1, 3, 1, 1, -1, // + 1, 3, 3, 1, 5, 3, 3, 1, -1, 1, 1, -1, 3, 1, 1, -1 }; + + /** + * For each of the eight octants (SWU, SEU, NWU, NEU, SWB, SEB, NWB, NEB), + * the neighborhood indices encoded in bits 128, 64, 32, 16, 8, 4 and 2 of + * the octant configuration (bit 1 is the center point). + */ + private static final int[][] OCTANTS = { // + { 24, 25, 15, 16, 21, 22, 12 }, // + { 26, 23, 17, 14, 25, 22, 16 }, // + { 18, 21, 9, 12, 19, 22, 10 }, // + { 20, 23, 19, 22, 11, 14, 10 }, // + { 6, 15, 7, 16, 3, 12, 4 }, // + { 8, 7, 17, 16, 5, 4, 14 }, // + { 0, 9, 3, 12, 1, 10, 4 }, // + { 2, 1, 11, 10, 5, 4, 14 } }; + + /** + * Octree labeling table: for each octant 1-8, pairs of (cube index, octants + * to recurse into) for the points of that octant; the cube excludes the + * center point. + */ + private static final int[][][] OCTREE = { // + { { 0 }, { 1, 2 }, { 3, 3 }, { 4, 2, 3, 4 }, { 9, 5 }, { 10, 2, 5, 6 }, { + 12, 3, 5, 7 } }, // + { { 1, 1 }, { 4, 1, 3, 4 }, { 10, 1, 5, 6 }, { 2 }, { 5, 4 }, { 11, 6 }, { + 13, 4, 6, 8 } }, // + { { 3, 1 }, { 4, 1, 2, 4 }, { 12, 1, 5, 7 }, { 6 }, { 7, 4 }, { 14, 7 }, { + 15, 4, 7, 8 } }, // + { { 4, 1, 2, 3 }, { 5, 2 }, { 13, 2, 6, 8 }, { 7, 3 }, { 15, 3, 7, 8 }, { + 8 }, { 16, 8 } }, // + { { 9, 1 }, { 10, 1, 2, 6 }, { 12, 1, 3, 7 }, { 17 }, { 18, 6 }, { 20, 7 }, + { 21, 6, 7, 8 } }, // + { { 10, 1, 2, 5 }, { 11, 2 }, { 13, 2, 4, 8 }, { 18, 5 }, { 21, 5, 7, 8 }, + { 19 }, { 22, 8 } }, // + { { 12, 1, 3, 5 }, { 14, 3 }, { 15, 3, 4, 8 }, { 20, 5 }, { 21, 5, 6, 8 }, + { 23 }, { 24, 8 } }, // + { { 13, 2, 4, 6 }, { 15, 3, 4, 7 }, { 16, 4 }, { 21, 5, 6, 7 }, { 22, 6 }, + { 24, 7 }, { 25 } } }; + + /** The octant from which to start labeling each cube index. */ + private static final int[] START_OCTANT = { 1, 1, 2, 1, 1, 2, 3, 3, 4, 1, 1, + 2, 1, 2, 3, 3, 4, 5, 5, 6, 5, 5, 6, 7, 7, 8 }; + + /** + * Skeletonizes a binary image. + * + * @param input the 2D or 3D binary image; {@code true} elements are + * foreground + * @return the skeleton, with the same interval as the input + */ + @Override + public RandomAccessibleInterval apply( + final RandomAccessibleInterval input) + { + final int n = input.numDimensions(); + if (n != 2 && n != 3) { + throw new IllegalArgumentException( + "Only 2D and 3D images are supported"); + } + final long[] dims = input.dimensionsAsLongArray(); + final long size = dims[0] * dims[1] * (n == 3 ? dims[2] : 1); + if (size > Integer.MAX_VALUE) { + throw new IllegalArgumentException("Image is too large: " + size + + " elements"); + } + final int w = (int) dims[0]; + final int h = (int) dims[1]; + final int d = n == 3 ? (int) dims[2] : 1; + + final byte[] img = new byte[(int) size]; + int i = 0; + for (final B b : Views.flatIterable(input)) + img[i++] = (byte) (b.get() ? 1 : 0); + + new Thinner(img, w, h, d).thin(); + + final ArrayImg out = ArrayImgs.bits(dims); + i = 0; + for (final BitType b : out) + b.set(img[i++] != 0); + return Views.translate(out, input.minAsLongArray()); + } + + private static final class Thinner { + + private final byte[] img; + private final int w, h, d; + private final byte[] nb = new byte[27]; + private final int[] cube = new int[26]; + + Thinner(final byte[] img, final int w, final int h, final int d) { + this.img = img; + this.w = w; + this.h = h; + this.d = d; + } + + void thin() { + final List simpleBorderPoints = new ArrayList<>(); + final int borders = d > 1 ? 6 : 4; + int unchangedBorders = 0; + while (unchangedBorders < 6) { + unchangedBorders = 0; + for (int border = 1; border <= 6; border++) { + boolean noChange = true; + if (border <= borders) { + for (int z = 0; z < d; z++) { + for (int y = 0; y < h; y++) { + for (int x = 0; x < w; x++) { + if (img[x + w * (y + h * z)] != 1) continue; + if (!isBorderPoint(border, x, y, z)) continue; + neighborhood(x, y, z); + if (isEndPoint()) continue; + if (!isEulerInvariant()) continue; + if (!isSimplePoint()) continue; + simpleBorderPoints.add(new int[] { x, y, z }); + } + } + } + // Note: Sequential re-checking preserves connectivity when the + // candidates collected above are deleted. + for (final int[] p : simpleBorderPoints) { + neighborhood(p[0], p[1], p[2]); + if (isSimplePoint()) { + img[p[0] + w * (p[1] + h * p[2])] = 0; + noChange = false; + } + } + simpleBorderPoints.clear(); + } + if (noChange) unchangedBorders++; + } + } + } + + private boolean isBorderPoint(final int border, final int x, final int y, + final int z) + { + switch (border) { + case 1: + return get(x, y - 1, z) <= 0; + case 2: + return get(x, y + 1, z) <= 0; + case 3: + return get(x + 1, y, z) <= 0; + case 4: + return get(x - 1, y, z) <= 0; + case 5: + return get(x, y, z + 1) <= 0; + default: + return get(x, y, z - 1) <= 0; + } + } + + private byte get(final int x, final int y, final int z) { + if (x < 0 || x >= w || y < 0 || y >= h || z < 0 || z >= d) return 0; + return img[x + w * (y + h * z)]; + } + + private void neighborhood(final int x, final int y, final int z) { + int k = 0; + for (int dz = -1; dz <= 1; dz++) + for (int dy = -1; dy <= 1; dy++) + for (int dx = -1; dx <= 1; dx++) + nb[k++] = get(x + dx, y + dy, z + dz); + } + + private boolean isEndPoint() { + int count = -1; + for (int k = 0; k < 27; k++) + if (nb[k] == 1) count++; + return count == 1; + } + + private boolean isEulerInvariant() { + int euler = 0; + for (final int[] octant : OCTANTS) { + int c = 1; + for (int b = 0; b < 7; b++) + if (nb[octant[b]] == 1) c |= 128 >> b; + euler += EULER_ODD[c >> 1]; + } + return euler == 0; + } + + private boolean isSimplePoint() { + for (int k = 0; k < 13; k++) + cube[k] = nb[k]; + for (int k = 14; k < 27; k++) + cube[k - 1] = nb[k]; + int label = 2; + for (int k = 0; k < 26; k++) { + if (cube[k] == 1) { + octreeLabeling(START_OCTANT[k], label); + label++; + if (label - 2 >= 2) return false; + } + } + return true; + } + + private void octreeLabeling(final int octant, final int label) { + for (final int[] entry : OCTREE[octant - 1]) { + final int idx = entry[0]; + if (cube[idx] == 1) { + cube[idx] = label; + for (int e = 1; e < entry.length; e++) + octreeLabeling(entry[e], label); + } + } + } + } +} diff --git a/scijava-ops-image-gpl/src/main/java/org/scijava/ops/image/gpl/skeleton/TagSkeleton.java b/scijava-ops-image-gpl/src/main/java/org/scijava/ops/image/gpl/skeleton/TagSkeleton.java new file mode 100644 index 000000000..35b982311 --- /dev/null +++ b/scijava-ops-image-gpl/src/main/java/org/scijava/ops/image/gpl/skeleton/TagSkeleton.java @@ -0,0 +1,108 @@ +/*- + * #%L + * GPL-licensed image processing operations for SciJava Ops, translated from copyleft ImageJ/Fiji plugins. + * %% + * Copyright (C) 2026 SciJava developers. + * %% + * This program 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. + * + * This program 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 this program. If not, see + * . + * #L% + */ + +package org.scijava.ops.image.gpl.skeleton; + +import java.util.function.Function; + +import net.imglib2.Cursor; +import net.imglib2.RandomAccess; +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.algorithm.neighborhood.Neighborhood; +import net.imglib2.algorithm.neighborhood.RectangleShape; +import net.imglib2.img.array.ArrayImgs; +import net.imglib2.type.BooleanType; +import net.imglib2.type.numeric.integer.UnsignedByteType; +import net.imglib2.view.Views; + +/** + * Classifies the elements of a skeleton by their number of foreground + * neighbors in the 3n-1 neighborhood: elements with fewer than two + * neighbors are end points ({@value #END_POINT}), elements with exactly two + * are slab (branch) elements ({@value #SLAB}) and elements with more than two + * are junctions ({@value #JUNCTION}); background is 0. Elements outside the + * image are background. + *

+ * This is the n-dimensional generalization of the voxel classification step + * of the Fiji AnalyzeSkeleton + * plugin by Ignacio Arganda-Carreras (GPL v3+), which uses the tag values 30 + * (end point), 127 (slab) and 70 (junction) instead. + *

+ *

+ * Reference: I. Arganda-Carreras, R. Fernández-González, A. Muñoz-Barrutia and + * C. Ortiz-de-Solórzano, "3D reconstruction of histological sections: + * Application to mammary gland tissue", Microscopy Research and Technique + * 73(11):1019-1029, 2010. + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @author Ignacio Arganda-Carreras + * @param type of the binary input + * @implNote op names='morphology.tagSkeleton' + */ +public class TagSkeleton> implements + Function, RandomAccessibleInterval> +{ + + /** Tag value of end point elements. */ + public static final int END_POINT = 1; + + /** Tag value of slab (branch) elements. */ + public static final int SLAB = 2; + + /** Tag value of junction elements. */ + public static final int JUNCTION = 3; + + /** + * Tags the elements of a skeleton. + * + * @param input the skeleton; {@code true} elements are foreground + * @return the tagged image, with the same interval as the input + */ + @Override + public RandomAccessibleInterval apply( + final RandomAccessibleInterval input) + { + final RandomAccessibleInterval out = Views.translate( + ArrayImgs.unsignedBytes(input.dimensionsAsLongArray()), input + .minAsLongArray()); + final B background = input.getType().createVariable(); + background.set(false); + final RandomAccess> na = new RectangleShape(1, true) + .neighborhoodsRandomAccessibleSafe(Views.extendValue(input, background)) + .randomAccess(); + final Cursor c = Views.flatIterable(input).localizingCursor(); + final RandomAccess ra = out.randomAccess(); + while (c.hasNext()) { + c.fwd(); + if (!c.get().get()) continue; + na.setPosition(c); + int count = 0; + for (final B b : na.get()) + if (b.get()) count++; + ra.setPosition(c); + ra.get().set(count < 2 ? END_POINT : count > 2 ? JUNCTION : SLAB); + } + return out; + } +} diff --git a/scijava-ops-image-gpl/src/main/java/org/scijava/ops/image/gpl/skeleton/package-info.java b/scijava-ops-image-gpl/src/main/java/org/scijava/ops/image/gpl/skeleton/package-info.java new file mode 100644 index 000000000..30cacf499 --- /dev/null +++ b/scijava-ops-image-gpl/src/main/java/org/scijava/ops/image/gpl/skeleton/package-info.java @@ -0,0 +1,27 @@ +/*- + * #%L + * GPL-licensed image processing operations for SciJava Ops, translated from copyleft ImageJ/Fiji plugins. + * %% + * Copyright (C) 2026 SciJava developers. + * %% + * This program 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. + * + * This program 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 this program. If not, see + * . + * #L% + */ + +/** + * Skeletonization and skeleton analysis ops, translated from the Fiji + * Skeletonize3D and AnalyzeSkeleton plugins by Ignacio Arganda-Carreras. + */ +package org.scijava.ops.image.gpl.skeleton; diff --git a/scijava-ops-image-gpl/src/main/java/org/scijava/ops/image/gpl/util/GaussianCurveFit.java b/scijava-ops-image-gpl/src/main/java/org/scijava/ops/image/gpl/util/GaussianCurveFit.java new file mode 100644 index 000000000..b0bef0c10 --- /dev/null +++ b/scijava-ops-image-gpl/src/main/java/org/scijava/ops/image/gpl/util/GaussianCurveFit.java @@ -0,0 +1,209 @@ +/*- + * #%L + * GPL-licensed image processing operations for SciJava Ops, translated from copyleft ImageJ/Fiji plugins. + * %% + * Copyright (C) 2026 SciJava developers. + * %% + * This program 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. + * + * This program 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 this program. If not, see + * . + * #L% + */ + +package org.scijava.ops.image.gpl.util; + +/** + * Least-squares fit of {@code y = a + (b - a) exp(-(x - c)^2 / (2 d^2))}, the + * {@code GAUSSIAN} fit of the ImageJ {@code CurveFitter}: for each candidate + * {@code (c, d)}, the linear parameters {@code a} and {@code b - a} are + * obtained by linear regression, and {@code (c, d)} is searched by a + * Nelder-Mead simplex. The callers choose the iteration limit, the convergence + * test and the restart policy, to match the plugin they port. + *

+ * This is an internal helper of the ops in this module, not a public API. + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +public final class GaussianCurveFit { + + /** Convergence test of the simplex search. */ + @FunctionalInterface + public interface Convergence { + + /** + * @param best the lowest sum of squares of the simplex vertices + * @param worst the highest sum of squares of the simplex vertices + * @param spread the L1 distance between the best and worst vertices + * @param scale the L1 norm of the best vertex + * @return whether the search has converged + */ + boolean test(double best, double worst, double spread, double scale); + } + + private final double[] x; + private final double[] y; + private final double[] g; + + /** + * @param x the abscissae + * @param y the values to fit + */ + public GaussianCurveFit(final double[] x, final double[] y) { + this.x = x; + this.y = y; + this.g = new double[x.length]; + } + + /** + * Computes the sum of squared residuals for the given {c, d}, with the offset + * and factor obtained by linear regression. + * + * @param cd the center and standard deviation + * @param abOut if not null, receives the offset {@code a} and the factor + * {@code b - a} + * @return the sum of squared residuals, or infinity if it is NaN + */ + public double sse(final double[] cd, final double[] abOut) { + final int n = x.length; + final double c = cd[0], d = cd[1]; + double gMean = 0, yMean = 0; + for (int i = 0; i < n; i++) { + g[i] = Math.exp(-(x[i] - c) * (x[i] - c) / (2 * d * d)); + gMean += g[i]; + yMean += y[i]; + } + gMean /= n; + yMean /= n; + double sgg = 0, sgy = 0; + for (int i = 0; i < n; i++) { + sgg += (g[i] - gMean) * (g[i] - gMean); + sgy += (g[i] - gMean) * (y[i] - yMean); + } + final double factor = sgg > 0 ? sgy / sgg : 0; + final double offset = yMean - factor * gMean; + double sum = 0; + for (int i = 0; i < n; i++) { + final double r = y[i] - offset - factor * g[i]; + sum += r * r; + } + if (abOut != null) { + abOut[0] = offset; + abOut[1] = factor; + } + return Double.isNaN(sum) ? Double.POSITIVE_INFINITY : sum; + } + + /** + * Minimizes {@link #sse} over {c, d} by a Nelder-Mead simplex search. + * + * @param start the initial {c, d} + * @param steps the initial simplex steps along c and d + * @param maxIterations the maximum number of iterations + * @param converged the convergence test, applied before each iteration + * @return the best vertex found + */ + public double[] simplex(final double[] start, final double[] steps, + final int maxIterations, final Convergence converged) + { + final double[][] v = { start.clone(), { start[0] + steps[0], start[1] }, { + start[0], start[1] + steps[1] } }; + final double[] f = new double[3]; + for (int i = 0; i < 3; i++) + f[i] = sse(v[i], null); + for (int iter = 0; iter < maxIterations; iter++) { + sort(v, f); + final double spread = Math.abs(v[2][0] - v[0][0]) + Math.abs(v[2][1] - + v[0][1]); + if (converged.test(f[0], f[2], spread, Math.abs(v[0][0]) + Math.abs( + v[0][1]))) + { + break; + } + final double[] centroid = { (v[0][0] + v[1][0]) / 2, (v[0][1] + v[1][1]) / + 2 }; + final double[] reflected = affine(centroid, v[2], -1); + final double fr = sse(reflected, null); + if (fr < f[0]) { + final double[] expanded = affine(centroid, v[2], -2); + final double fe = sse(expanded, null); + if (fe < fr) { + v[2] = expanded; + f[2] = fe; + } + else { + v[2] = reflected; + f[2] = fr; + } + } + else if (fr < f[1]) { + v[2] = reflected; + f[2] = fr; + } + else { + final boolean outside = fr < f[2]; + final double[] contracted = affine(centroid, outside ? reflected : v[2], + 0.5); + final double fc = sse(contracted, null); + if (fc < (outside ? fr : f[2])) { + v[2] = contracted; + f[2] = fc; + } + else { + for (int i = 1; i < 3; i++) { + v[i] = affine(v[0], v[i], 0.5); + f[i] = sse(v[i], null); + } + } + } + } + // Note: The sort keeps the first vertex among equal values. + sort(v, f); + return v[0]; + } + + /** + * Returns the fitted parameters {a, b, c, d}, with d >= 0, for the given + * {c, d}. + */ + public double[] params(final double[] cd) { + final double[] ab = new double[2]; + sse(cd, ab); + return new double[] { ab[0], ab[0] + ab[1], cd[0], Math.abs(cd[1]) }; + } + + /** Sorts the simplex vertices by increasing value. */ + private static void sort(final double[][] v, final double[] f) { + for (int i = 0; i < 2; i++) { + for (int j = i + 1; j < 3; j++) { + if (f[j] < f[i]) { + final double tf = f[i]; + f[i] = f[j]; + f[j] = tf; + final double[] tv = v[i]; + v[i] = v[j]; + v[j] = tv; + } + } + } + } + + /** Returns {@code origin + t * (point - origin)}. */ + private static double[] affine(final double[] origin, final double[] point, + final double t) + { + return new double[] { origin[0] + t * (point[0] - origin[0]), origin[1] + + t * (point[1] - origin[1]) }; + } +} diff --git a/scijava-ops-image-gpl/src/main/java/org/scijava/ops/image/gpl/util/package-info.java b/scijava-ops-image-gpl/src/main/java/org/scijava/ops/image/gpl/util/package-info.java new file mode 100644 index 000000000..e07c52d14 --- /dev/null +++ b/scijava-ops-image-gpl/src/main/java/org/scijava/ops/image/gpl/util/package-info.java @@ -0,0 +1,27 @@ +/*- + * #%L + * GPL-licensed image processing operations for SciJava Ops, translated from copyleft ImageJ/Fiji plugins. + * %% + * Copyright (C) 2026 SciJava developers. + * %% + * This program 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. + * + * This program 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 this program. If not, see + * . + * #L% + */ + +/** + * Internal helpers shared by the ops of this module. + */ + +package org.scijava.ops.image.gpl.util; diff --git a/scijava-ops-image-gpl/src/test/java/org/scijava/ops/image/gpl/AbstractOpTest.java b/scijava-ops-image-gpl/src/test/java/org/scijava/ops/image/gpl/AbstractOpTest.java new file mode 100644 index 000000000..3edc32e67 --- /dev/null +++ b/scijava-ops-image-gpl/src/test/java/org/scijava/ops/image/gpl/AbstractOpTest.java @@ -0,0 +1,44 @@ +/*- + * #%L + * GPL-licensed image processing operations for SciJava Ops, translated from copyleft ImageJ/Fiji plugins. + * %% + * Copyright (C) 2026 SciJava developers. + * %% + * This program 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. + * + * This program 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 this program. If not, see + * . + * #L% + */ + +package org.scijava.ops.image.gpl; + +import org.scijava.ops.api.Hints; +import org.scijava.ops.api.OpEnvironment; + +/** + * Base class for unit tests of the ops in this component, mirroring + * {@code org.scijava.ops.image.AbstractOpTest} (which is not published in a + * test JAR). + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +public abstract class AbstractOpTest { + + protected static final OpEnvironment ops = OpEnvironment.build(); + + static { + ops.setDefaultHints(new Hints("progress.TRACK")); + } + +} diff --git a/scijava-ops-image-gpl/src/test/java/org/scijava/ops/image/gpl/OpRegressionTest.java b/scijava-ops-image-gpl/src/test/java/org/scijava/ops/image/gpl/OpRegressionTest.java new file mode 100644 index 000000000..834a66aa6 --- /dev/null +++ b/scijava-ops-image-gpl/src/test/java/org/scijava/ops/image/gpl/OpRegressionTest.java @@ -0,0 +1,69 @@ +/*- + * #%L + * GPL-licensed image processing operations for SciJava Ops, translated from copyleft ImageJ/Fiji plugins. + * %% + * Copyright (C) 2026 SciJava developers. + * %% + * This program 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. + * + * This program 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 this program. If not, see + * . + * #L% + */ + +package org.scijava.ops.image.gpl; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import org.junit.jupiter.api.Test; +import org.scijava.ops.api.OpEnvironment; +import org.scijava.ops.api.OpInfo; + +/** + * Guards against ops silently dropped by the op indexer (e.g. after an + * apostrophe in an {@code @param} description), like + * {@code org.scijava.ops.image.OpRegressionTest}. + *

+ * Only the op infos implemented in this module are counted, i.e. those whose + * implementation name starts with this package, so the count does not change + * with the ops of the other modules on the classpath. As in the other test, + * each {@code @Nullable} parameter adds a reduced op info per signature. + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +public class OpRegressionTest { + + private static final String PREFIX = "org.scijava.ops.image.gpl."; + + protected static final OpEnvironment ops = OpEnvironment.build(); + + @Test + public void testOpDiscoveryRegression() { + final long expected = 47; + final long actual = ops.infos().stream() // + .filter(info -> info.implementationName().startsWith(PREFIX)) // + .count(); + assertEquals(expected, actual); + } + + @Test + public void testOpDescriptionRegression() { + for (final OpInfo info : ops.infos()) { + if (!info.implementationName().startsWith(PREFIX)) continue; + assertNotNull(info.description(), () -> "Info from " + info.id() + + " has a null description"); + } + } +} diff --git a/scijava-ops-image-gpl/src/test/java/org/scijava/ops/image/gpl/biovoxxel/ContainedCountTest.java b/scijava-ops-image-gpl/src/test/java/org/scijava/ops/image/gpl/biovoxxel/ContainedCountTest.java new file mode 100644 index 000000000..b3e250d96 --- /dev/null +++ b/scijava-ops-image-gpl/src/test/java/org/scijava/ops/image/gpl/biovoxxel/ContainedCountTest.java @@ -0,0 +1,192 @@ +/*- + * #%L + * GPL-licensed image processing operations for SciJava Ops, translated from copyleft ImageJ/Fiji plugins. + * %% + * Copyright (C) 2026 SciJava developers. + * %% + * This program 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. + * + * This program 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 this program. If not, see + * . + * #L% + */ + +package org.scijava.ops.image.gpl.biovoxxel; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +import net.imglib2.RandomAccess; +import net.imglib2.algorithm.labeling.ConnectedComponents.StructuringElement; +import net.imglib2.img.Img; +import net.imglib2.img.array.ArrayImgs; +import net.imglib2.roi.labeling.ImgLabeling; +import net.imglib2.roi.labeling.LabelingType; +import net.imglib2.type.logic.BitType; +import net.imglib2.type.numeric.integer.IntType; + +import org.junit.jupiter.api.Test; +import org.scijava.ops.image.gpl.AbstractOpTest; +import org.scijava.types.Nil; + +/** + * Tests {@link ContainedCount}. + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +public class ContainedCountTest extends AbstractOpTest { + + /** P = primary, S = secondary, B = both. */ + private static final String[] ART = { // + "..............................", // + "..............................", // + "..PPPPPPPP...PP.....PP...SSSS.", // + "..PPPPBBPP...PP.....PP...SSSS.", // + "..PPBPPPPBS..PP.....PP...SSSS.", // + "..PPPPPBPP..SBBSSSSSBBS.......", // + "..PPBBPPPP...PP.....PP........", // + "..PPPPPPPP...PP.....PP........", // + ".............PPPPPPPPP........", // + ".............PPPPPPPPP....P...", // + "........................PPPPP.", // + ".................S......BBPPP.", // + ".................S.....PPPBPPP", // + "..PPPPPPPPPPPPPPPPPPP...PPPPP.", // + "..PPBPPPBBBPPPBBBPPPP...PPPPP.", // + "..PPPBPPBBBPPPPPPPPPP.....P...", // + "..PPPPPPPPPPPPPPPPPPP.........", // + "..............................", // + "..............................", // + ".............................." }; + + /** A pixel of each primary object: rectangle, U shape, disk and bar. */ + private static final int[][] STARTS = { { 2, 2 }, { 13, 2 }, { 26, 9 }, { + 2, 13 } }; + + private static Img mask(final String chars) { + final Img img = ArrayImgs.bits(ART[0].length(), ART.length); + final RandomAccess ra = img.randomAccess(); + for (int y = 0; y < ART.length; y++) + for (int x = 0; x < ART[y].length(); x++) + ra.setPositionAndGet(x, y).set(chars.indexOf(ART[y].charAt(x)) >= 0); + return img; + } + + private ImgLabeling primary() { + return ops.op("labeling.cca").input(mask("PB"), + StructuringElement.EIGHT_CONNECTED).outType( + new Nil>() + {}).apply(); + } + + private static Integer labelAt(final ImgLabeling labeling, + final int[] pos) + { + return labeling.randomAccess().setPositionAndGet(pos).iterator().next(); + } + + private Map count( + final ImgLabeling labeling, final Img secondary, + final Long minSize, final Long maxSize) + { + return ops.op("labeling.countContained").input(labeling, secondary, + minSize, maxSize).outType(new Nil>() {}).apply(); + } + + /** + * Compares with the BioVoxxel Speckle_Inspector plugin (compiled from + * source), run in macro and batch mode on the same images by the harness + * in {@code .claude/fiji-plugin-ops/g2/src/SpeckleHarness.java}, without + * edge exclusion; the counts were read from the objects filled in magenta + * when the lower and upper secondary counts are equal. The plugin printed + * "0 Infinity 5 2 1 3" and "2 4 2 2 1 2" (minimum and maximum secondary + * size, then the counts of the four objects). + */ + @Test + public void testRegression() { + final ImgLabeling labeling = primary(); + final Img secondary = mask("SB"); + final int[][] expected = { { 5, 2, 1, 3 }, { 2, 2, 1, 2 } }; + final Long[][] sizes = { { null, null }, { 2L, 4L } }; + for (int s = 0; s < sizes.length; s++) { + final Map counts = count(labeling, secondary, + sizes[s][0], sizes[s][1]); + assertEquals(4, counts.size()); + for (int c = 0; c < STARTS.length; c++) { + assertEquals(expected[s][c], counts.get(labelAt(labeling, STARTS[c])), + "object " + c + ", sizes " + s); + } + } + } + + @Test + public void testFilterByContainedCount() { + final ImgLabeling labeling = primary(); + final ImgLabeling filtered = ops.op( + "labeling.filterByContainedCount").input(labeling, mask("SB"), 2, 3, + null, null).outType(new Nil>() + {}).apply(); + final Set kept = new HashSet<>(); + for (final LabelingType t : filtered) + kept.addAll(t); + // The U shape (2) and the bar (3) are kept; the rectangle (5) and the + // disk (1) are not. + assertEquals(Set.of(labelAt(labeling, STARTS[1]), labelAt(labeling, + STARTS[3])), kept); + } + + /** In 3D, diagonal neighbors are connected and clipped parts counted. */ + @Test + public void test3D() { + final Img index = ArrayImgs.ints(8, 8, 8); + final ImgLabeling labeling = new ImgLabeling<>(index); + final RandomAccess> la = labeling.randomAccess(); + final Img secondary = ArrayImgs.bits(8, 8, 8); + final RandomAccess sa = secondary.randomAccess(); + for (int z = 1; z <= 5; z++) + for (int y = 1; y <= 5; y++) + for (int x = 1; x <= 5; x++) + la.setPositionAndGet(x, y, z).add("box"); + // Two voxels touching only by a corner: one object. + sa.setPositionAndGet(2, 2, 2).set(true); + sa.setPositionAndGet(3, 3, 3).set(true); + // A single voxel. + sa.setPositionAndGet(5, 1, 5).set(true); + // A line crossing the box border: clipped to its inside part. + for (int x = 4; x <= 7; x++) + sa.setPositionAndGet(x, 5, 1).set(true); + // Outside the box. + sa.setPositionAndGet(7, 7, 7).set(true); + final Map counts = ops.op("labeling.countContained") + .input(labeling, secondary, null, null).outType( + new Nil>() + {}).apply(); + assertEquals(Map.of("box", 3), counts); + // Only the clipped line (2 voxels) and the pair are at least 2 voxels. + final Map big = ops.op("labeling.countContained").input( + labeling, secondary, 2L, null).outType(new Nil>() + {}).apply(); + assertEquals(Map.of("box", 2), big); + } + + @Test + public void testIntervalMismatch() { + final ImgLabeling labeling = primary(); + assertThrows(IllegalArgumentException.class, () -> ContainedCount + .countContained(labeling, ArrayImgs.bits(5, 5), null, null)); + } +} diff --git a/scijava-ops-image-gpl/src/test/java/org/scijava/ops/image/gpl/biovoxxel/LimitedMeanThresholdTest.java b/scijava-ops-image-gpl/src/test/java/org/scijava/ops/image/gpl/biovoxxel/LimitedMeanThresholdTest.java new file mode 100644 index 000000000..bced77316 --- /dev/null +++ b/scijava-ops-image-gpl/src/test/java/org/scijava/ops/image/gpl/biovoxxel/LimitedMeanThresholdTest.java @@ -0,0 +1,208 @@ +/*- + * #%L + * GPL-licensed image processing operations for SciJava Ops, translated from copyleft ImageJ/Fiji plugins. + * %% + * Copyright (C) 2026 SciJava developers. + * %% + * This program 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. + * + * This program 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 this program. If not, see + * . + * #L% + */ + +package org.scijava.ops.image.gpl.biovoxxel; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; + +import net.imglib2.Cursor; +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.img.Img; +import net.imglib2.img.array.ArrayImgs; +import net.imglib2.type.logic.BitType; +import net.imglib2.type.numeric.RealType; +import net.imglib2.type.numeric.integer.UnsignedByteType; +import net.imglib2.type.numeric.real.FloatType; +import net.imglib2.view.Views; + +import org.junit.jupiter.api.Test; +import org.scijava.ops.image.gpl.AbstractOpTest; + +/** + * Tests {@link LimitedMeanThreshold}. + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +public class LimitedMeanThresholdTest extends AbstractOpTest { + + private static final int W = 32, H = 24; + + /** + * Synthetic test images; must match the harness that produced + * {@code limited-mean-expected.txt}. + */ + private static int value(final int kind, final int x, final int y) { + final int n = ((x * 73856093) ^ (y * 19349663)) & 1023; + final double r = n / 1023.0; + final boolean blob = (x - 10) * (x - 10) + (y - 12) * (y - 12) < 36 || + (x - 24) * (x - 24) + (y - 8) * (y - 8) < 16; + switch (kind) { + case 0: // dark background with noise, bright blobs + return blob ? (int) (150 + 80 * r) : (int) (20 + 30 * r * r); + case 1: // bright background, dark blobs + return blob ? (int) (30 + 70 * r) : (int) (200 + 40 * r); + case 2: // background mostly exactly zero, graded blobs + return blob ? (int) (60 + 190 * r) : (r < 0.8 ? 0 : (int) (40 * r)); + case 3: // ramp plus noise + return Math.min(255, (int) (x * 6 + 60 * r)); + default: // saturated foreground + return blob ? 255 : (int) (90 + 60 * r); + } + } + + private Img threshold(final RandomAccessibleInterval img, + final boolean differential, final boolean force) + { + final Img out = ArrayImgs.bits(img.dimensionsAsLongArray()); + ops.op("threshold.limitedMean").input(img, differential, force).output(out) + .compute(); + return out; + } + + private static String bits(final Img img) { + final StringBuilder sb = new StringBuilder(); + for (final BitType b : Views.flatIterable(img)) + sb.append(b.get() ? '1' : '0'); + return sb.toString(); + } + + /** + * Compares with the BioVoxxel Limited_Mean plugin (compiled from source), + * run in macro mode on the same synthetic 8-bit images by the harness in + * {@code .claude/fiji-plugin-ops/g2/src/LimHarness.java}; each line holds + * the image kind, the differential and force options, and the plugin + * result (1 for 255). + */ + @Test + public void testRegression() throws IOException { + int cases = 0; + try (BufferedReader in = new BufferedReader(new InputStreamReader( + getClass().getResourceAsStream("limited-mean-expected.txt"), + StandardCharsets.UTF_8))) + { + for (String line; (line = in.readLine()) != null;) { + final String[] f = line.split(" "); + final int kind = Integer.parseInt(f[0]); + final Img img = ArrayImgs.unsignedBytes(W, H); + final Cursor c = img.localizingCursor(); + while (c.hasNext()) { + c.fwd(); + c.get().set(value(kind, c.getIntPosition(0), c.getIntPosition(1))); + } + assertEquals(f[3], bits(threshold(img, Boolean.parseBoolean(f[1]), + Boolean.parseBoolean(f[2]))), line.substring(0, 16)); + cases++; + } + } + assertEquals(20, cases); + } + + private static > Img bimodal(final Img img, + final double background, final double low, final double high) + { + final Cursor c = img.localizingCursor(); + while (c.hasNext()) { + c.fwd(); + final int x = c.getIntPosition(0); + final int y = c.getIntPosition(1); + c.get().setReal(y < 2 ? (x % 2 == 0 ? low : high) : background); + } + return img; + } + + /** + * With a dominant background, MoLiM removes it and thresholds at the mean + * of the remaining (low and high) values, keeping only the high ones. + */ + @Test + public void testDarkBackground() { + final Img img = bimodal(ArrayImgs.unsignedBytes(10, 10), + 10, 100, 200); + final Img out = threshold(img, false, false); + final Cursor c = out.localizingCursor(); + while (c.hasNext()) { + c.fwd(); + final boolean high = c.getIntPosition(1) < 2 && c.getIntPosition(0) % + 2 == 1; + assertEquals(high, c.get().get()); + } + // The float image gives the same partition. + final Img fimg = bimodal(ArrayImgs.floats(10, 10), 10, 100, + 200); + assertEquals(bits(out), bits(threshold(fimg, false, false))); + // Default options (differential) give the same result here. + final Img def = ArrayImgs.bits(10, 10); + ops.op("threshold.limitedMean").input(img, null, null).output(def) + .compute(); + assertEquals(bits(out), bits(def)); + } + + /** A bright background is reversed: the darkest pixels are foreground. */ + @Test + public void testBrightBackground() { + final Img img = bimodal(ArrayImgs.unsignedBytes(10, 10), + 245, 155, 55); + final Img out = threshold(img, false, false); + final Cursor c = out.localizingCursor(); + while (c.hasNext()) { + c.fwd(); + final boolean dark = c.getIntPosition(1) < 2 && c.getIntPosition(0) % + 2 == 1; + assertEquals(dark, c.get().get()); + } + // Forcing keeps the histogram orientation, so the limitation removes + // every bin up to the bright mode, the limited mean is undefined, and the + // plugin then thresholds at 0: every pixel is foreground. + for (final BitType b : threshold(img, false, true)) + assertEquals(true, b.get()); + } + + /** + * NaN pixels are excluded from the histogram and are never foreground, + * with a dark or a bright background: the other pixels are classified as + * in the same image without the NaN rows. + */ + @Test + public void testNaN() { + final double[][] cases = { { 10, 100, 200 }, { 245, 155, 55 } }; + for (final double[] v : cases) { + final Img ref = bimodal(ArrayImgs.floats(10, 7), v[0], v[1], + v[2]); + final Img img = bimodal(ArrayImgs.floats(10, 10), v[0], v[1], + v[2]); + final Cursor c = img.localizingCursor(); + while (c.hasNext()) { + c.fwd(); + if (c.getIntPosition(1) >= 7) c.get().set(Float.NaN); + } + final String expected = bits(threshold(ref, false, false)); + final String actual = bits(threshold(img, false, false)); + assertEquals(expected + "000000000000000000000000000000", actual); + } + } +} diff --git a/scijava-ops-image-gpl/src/test/java/org/scijava/ops/image/gpl/directionality/DirectionalityHistogramTest.java b/scijava-ops-image-gpl/src/test/java/org/scijava/ops/image/gpl/directionality/DirectionalityHistogramTest.java new file mode 100644 index 000000000..19fd02022 --- /dev/null +++ b/scijava-ops-image-gpl/src/test/java/org/scijava/ops/image/gpl/directionality/DirectionalityHistogramTest.java @@ -0,0 +1,261 @@ +/*- + * #%L + * GPL-licensed image processing operations for SciJava Ops, translated from copyleft ImageJ/Fiji plugins. + * %% + * Copyright (C) 2026 SciJava developers. + * %% + * This program 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. + * + * This program 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 this program. If not, see + * . + * #L% + */ + +package org.scijava.ops.image.gpl.directionality; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; + +import net.imglib2.img.Img; +import net.imglib2.img.array.ArrayImgs; +import net.imglib2.type.numeric.real.FloatType; + +import org.junit.jupiter.api.Test; +import org.scijava.ops.image.gpl.AbstractOpTest; + +/** + * Tests {@link DefaultDirectionalityHistogram}. + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +public class DirectionalityHistogramTest extends AbstractOpTest { + + /** + * Synthetic test images; must match the harness that produced + * {@code directionality-expected.txt}. + */ + private static float value(final int kind, final int x, final int y) { + final double noise = (((x * 73856093) ^ (y * 19349663)) & 1023) / 1023.0; + switch (kind) { + case 0: { // grating with structures oriented at 30 degrees + final double a = Math.toRadians(30); + final double t = -x * Math.sin(a) - y * Math.cos(a); + return (float) (100 + 60 * Math.sin(2 * Math.PI * t / 6) + 20 * noise); + } + case 1: { // non-square: -50 degree grating plus a weaker 20 degree one + final double a = Math.toRadians(-50), b = Math.toRadians(20); + final double t = -x * Math.sin(a) - y * Math.cos(a); + final double s = -x * Math.sin(b) - y * Math.cos(b); + return (float) (100 + 60 * Math.sin(2 * Math.PI * t / 7) + 25 * Math + .sin(2 * Math.PI * s / 5) + 30 * noise); + } + default: // noise only + return (float) (255 * noise); + } + } + + private static final int[][] SIZES = { { 64, 64 }, { 80, 48 }, { 40, 40 } }; + + private static Img image(final int kind) { + final int w = SIZES[kind][0], h = SIZES[kind][1]; + final float[] data = new float[w * h]; + for (int y = 0; y < h; y++) + for (int x = 0; x < w; x++) + data[x + y * w] = value(kind, x, y); + return ArrayImgs.floats(data, w, h); + } + + /** A sinusoidal grating whose stripes are oriented at the given angle. */ + private static Img grating(final double degrees, final int size, + final double period) + { + final double a = Math.toRadians(degrees); + final float[] data = new float[size * size]; + for (int y = 0; y < size; y++) { + for (int x = 0; x < size; x++) { + final double t = -x * Math.sin(a) - y * Math.cos(a); + data[x + y * size] = (float) (100 + 50 * Math.sin(2 * Math.PI * t / + period)); + } + } + return ArrayImgs.floats(data, size, size); + } + + private Directionality directionality(final Img img, + final DirectionalityMethod method, final int nBins, final double start, + final double end) + { + return ops.op("features.directionalityHistogram").input(img, method, nBins, start, + end).outType(Directionality.class).apply(); + } + + /** + * Compares with the Fiji Directionality plugin (v2.3.0, compiled from + * source), run headless on the same synthetic images by the harness in + * {@code .claude/fiji-plugin-ops/g2/src/DirHarness.java}, through its + * {@code computeHistograms}, {@code fitHistograms}, {@code getHistograms}, + * {@code getFitParameters} and {@code getFitAnalysis} methods. + */ + @Test + public void testRegression() throws IOException { + final List lines = new ArrayList<>(); + try (BufferedReader in = new BufferedReader(new InputStreamReader( + getClass().getResourceAsStream("directionality-expected.txt"), + StandardCharsets.UTF_8))) + { + for (String line; (line = in.readLine()) != null;) + lines.add(line); + } + int cases = 0; + for (int l = 0; l < lines.size(); l += 3) { + final String[] c = lines.get(l).split(" "); + final String[] fit = lines.get(l + 1).split(" "); + final String[] hist = lines.get(l + 2).split(" "); + final int kind = Integer.parseInt(c[1]); + final DirectionalityMethod method = DirectionalityMethod.valueOf(c[2]); + final int nBins = Integer.parseInt(c[3]); + final double start = Double.parseDouble(c[4]); + final double end = Double.parseDouble(c[5]); + final String what = lines.get(l); + + final Directionality d = directionality(image(kind), method, nBins, + start, end); + assertEquals(nBins, d.binCount(), what); + final double[] h = d.histogram(); + if (method == DirectionalityMethod.LOCAL_GRADIENT_ORIENTATION) { + // Note: The op bins by rounding where the plugin truncates, so the + // plugin histogram is offset by half a bin and only its fitted + // direction can be compared, shifted by that half bin. + double sum = 0; + for (final double v : h) + sum += v; + assertEquals(1, sum, 1e-9, what); + if (kind == 2) continue; + final double binWidth = (end - start) / (nBins - 1); + assertEquals(Double.parseDouble(fit[3]) + binWidth / 2, d.direction(), + binWidth / 4, what); + cases++; + continue; + } + for (int i = 0; i < nBins; i++) { + final double expected = Double.parseDouble(hist[i + 1]); + assertEquals(expected, h[i], 1e-4 * expected + 1e-9, what + + " bin " + i); + } + assertEquals(start, d.binCenters()[0], 1e-12, what); + assertEquals(end, d.binCenters()[nBins - 1], 1e-12, what); + // Note: The noise image has no clear peak, so its fit is not compared. + if (kind == 2) continue; + assertEquals(Double.parseDouble(fit[1]), d.offset(), 1e-5, what); + assertEquals(Double.parseDouble(fit[2]), d.peak(), 1e-5, what); + assertEquals(Double.parseDouble(fit[3]), d.direction(), 1e-3, what); + assertEquals(Double.parseDouble(fit[4]), d.dispersion(), 1e-3, what); + // Note: Unlike the plugin, the amount wraps around the ends of the + // range, so compare the plugin amount with the unwrapped sum. + double unwrapped = 0; + final double[] bins = d.binCenters(); + for (int i = 0; i < nBins; i++) { + if (Math.abs(bins[i] - d.direction()) <= 2 * d.dispersion()) + unwrapped += h[i]; + } + assertEquals(Double.parseDouble(fit[5]), unwrapped, 1e-6, what); + assertTrue(d.amount() >= unwrapped - 1e-12, what); + assertEquals(Double.parseDouble(fit[6]), d.goodness(), 1e-5, what); + cases++; + } + assertEquals(8, cases); + } + + /** + * The local gradient method is not biased by half a bin. Before the fix, + * each bin collected the orientations in {@code [center, center + width)}, + * so over these gratings the direction was off by -1.0 degrees on average + * with 90 bins and by -3.1 degrees with 30 bins, half a bin width each time. + */ + @Test + public void testGradientBinningUnbiased() { + for (final int nBins : new int[] { 90, 30 }) { + double sum = 0; + int n = 0; + for (double angle = -70; angle <= 70; angle += 7.3) { + final Directionality d = directionality(grating(angle, 128, 12), + DirectionalityMethod.LOCAL_GRADIENT_ORIENTATION, nBins, -90, 90); + sum += d.direction() - angle; + n++; + } + final double halfBin = 90.0 / (nBins - 1); + assertEquals(0, sum / n, halfBin / 4, "nBins=" + nBins); + } + } + + /** Gratings at known orientations give their orientation as direction. */ + @Test + public void testGratings() { + for (final DirectionalityMethod method : DirectionalityMethod.values()) { + // Note: The Fourier filters select periods near a quarter of the block + // size; the 5x5 gradient kernels prefer coarser structures. + final double period = method == DirectionalityMethod.FOURIER_COMPONENTS + ? 4.3 : 10; + final double tolerance = method == DirectionalityMethod.FOURIER_COMPONENTS + ? 1 : 3.5; + for (final double angle : new double[] { -75, -60, -20, 0, 15, 45, 70 }) { + final Directionality d = directionality(grating(angle, 64, period), + method, 90, -90, 90); + assertEquals(angle, d.direction(), tolerance, method + " " + angle); + // The histogram peaks in the bin of the grating orientation. + final double[] h = d.histogram(); + int max = 0; + double sum = 0; + for (int i = 0; i < h.length; i++) { + if (h[i] > h[max]) max = i; + sum += h[i]; + } + assertEquals(1, sum, 1e-9); + final double binWidth = 180.0 / 89; + assertEquals(angle, d.binCenters()[max], tolerance + binWidth, method + + " " + angle); + } + } + } + + /** The default parameters are the Fourier method over 90 bins. */ + @Test + public void testDefaults() { + final Img img = grating(30, 32, 4); + final Directionality d = ops.op("features.directionalityHistogram").input(img) + .outType(Directionality.class).apply(); + assertEquals(90, d.binCount()); + assertEquals(-90, d.binCenters()[0], 1e-12); + assertEquals(90, d.binCenters()[89], 1e-12); + final Directionality explicit = directionality(img, + DirectionalityMethod.FOURIER_COMPONENTS, 90, -90, 90); + assertEquals(explicit.direction(), d.direction(), 0); + assertEquals(30, d.direction(), 2.5); + } + + @Test + public void testInvalidInput() { + final Img img3D = ArrayImgs.floats(8, 8, 2); + assertThrows(IllegalArgumentException.class, () -> ops.op( + "features.directionalityHistogram").input(img3D).outType(Directionality.class) + .apply()); + } +} diff --git a/scijava-ops-image-gpl/src/test/java/org/scijava/ops/image/gpl/mcib3d/SpotsFromSeedsTest.java b/scijava-ops-image-gpl/src/test/java/org/scijava/ops/image/gpl/mcib3d/SpotsFromSeedsTest.java new file mode 100644 index 000000000..a392842f0 --- /dev/null +++ b/scijava-ops-image-gpl/src/test/java/org/scijava/ops/image/gpl/mcib3d/SpotsFromSeedsTest.java @@ -0,0 +1,331 @@ +/*- + * #%L + * GPL-licensed image processing operations for SciJava Ops, translated from copyleft ImageJ/Fiji plugins. + * %% + * Copyright (C) 2026 SciJava developers. + * %% + * This program 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. + * + * This program 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 this program. If not, see + * . + * #L% + */ + +package org.scijava.ops.image.gpl.mcib3d; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import net.imglib2.Cursor; +import net.imglib2.Point; +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.img.Img; +import net.imglib2.img.array.ArrayImgs; +import net.imglib2.roi.labeling.ImgLabeling; +import net.imglib2.roi.labeling.LabelingType; +import net.imglib2.type.numeric.integer.IntType; +import net.imglib2.type.numeric.integer.UnsignedShortType; +import net.imglib2.type.numeric.real.FloatType; +import net.imglib2.view.Views; + +import org.junit.jupiter.api.Test; +import org.scijava.ops.image.gpl.AbstractOpTest; + +/** + * Tests {@link SpotsFromSeeds}. + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +public class SpotsFromSeedsTest extends AbstractOpTest { + + // -- Synthetic 3D image; must match the harness that produced + // spots-expected.txt. -- + + private static final int W = 32, H = 28, D = 12; + private static final double[][] BLOBS = { // + { 8, 8, 5, 200, 2, 2, 1.5 }, { 20, 9, 6, 150, 1.5, 1.5, 1 }, // + { 14, 20, 6, 120, 2.5, 2, 1.5 }, { 22, 20, 5, 90, 1.8, 1.8, 1.2 }, // + { 29, 3, 2, 100, 1.5, 1.5, 1 }, { 5, 22, 9, 40, 1, 1, 1 } }; + private static final int[][] SEEDS = { { 8, 8, 5 }, { 10, 8, 5 }, { 20, 9, + 6 }, { 14, 20, 6 }, { 18, 20, 6 }, { 22, 20, 5 }, { 29, 3, 2 }, { 5, 22, + 9 }, { 3, 25, 1 } }; + + private static int value(final int x, final int y, final int z) { + final int n = ((x * 73856093) ^ (y * 19349663) ^ (z * 83492791)) & 1023; + double v = 20 + 10 * n / 1023.0; + for (final double[] b : BLOBS) { + final double dx = (x - b[0]) / b[4], dy = (y - b[1]) / b[5], dz = (z - + b[2]) / b[6]; + v += b[3] * Math.exp(-(dx * dx + dy * dy + dz * dz) / 2); + } + return (int) Math.round(v); + } + + private static Img image() { + final Img img = ArrayImgs.unsignedShorts(W, H, D); + final Cursor c = img.localizingCursor(); + while (c.hasNext()) { + c.fwd(); + c.get().set(value(c.getIntPosition(0), c.getIntPosition(1), c + .getIntPosition(2))); + } + return img; + } + + private static List points(final int[]... coords) { + final List points = new ArrayList<>(); + for (final int[] p : coords) + points.add(new Point(p)); + return points; + } + + private ImgLabeling spots( + final RandomAccessibleInterval img, final List seeds, + final String method, final Double value, final double[] radii, + final String growth, final Integer minVolume, final Integer maxVolume) + { + @SuppressWarnings("unchecked") + final ImgLabeling result = ops.op( + "segment.spotsFromSeeds").input(img, seeds, method, value, radii, growth, + minVolume, maxVolume).outType(ImgLabeling.class).apply(); + return result; + } + + /** Encodes a labeling like the harness: value x run length, flat order. */ + private static String rle(final ImgLabeling labeling) { + final StringBuilder sb = new StringBuilder("labels"); + int prev = -1, run = 0; + for (final LabelingType l : Views.flatIterable(labeling)) { + final int v = l.isEmpty() ? 0 : l.iterator().next(); + if (v == prev) run++; + else { + if (run > 0) sb.append(' ').append(prev).append('x').append(run); + prev = v; + run = 1; + } + } + sb.append(' ').append(prev).append('x').append(run); + return sb.toString(); + } + + private static Map sizes( + final ImgLabeling labeling) + { + final Map sizes = new HashMap<>(); + for (final LabelingType l : Views.flatIterable(labeling)) { + for (final Integer label : l) + sizes.merge(label, 1, Integer::sum); + } + return sizes; + } + + /** + * Compares with mcib3d {@code Segment3DSpots} (mcib3d-core 3.96.31), run on + * the same synthetic 16-bit image and seeds by the harness + * {@code SpotsHarness.java} (kept with the G4 handoff notes of the + * fiji-plugin-ops work), for every growth rule and threshold method. Each + * case lists the mcib3d segmentation method (1 classical, 3 maximum, 4 + * block), local threshold method (1 constant 60, 2 mean with radii 2, 4, 6 + * and weight 0.5, 3 gaussian with radius 10 and factor 1.285, 4 diff 50), + * minimum and maximum (0: none) volume, then the local thresholds and the + * run-length encoded label image. + */ + @Test + public void testRegression() throws IOException { + final Img img = image(); + // Note: mcib3d processes the seeds in the raster order of its seed image. + final List seeds = points(SEEDS); + seeds.sort((p, q) -> { + for (int d = 2; d >= 0; d--) { + final int cmp = Long.compare(p.getLongPosition(d), q.getLongPosition( + d)); + if (cmp != 0) return cmp; + } + return 0; + }); + int cases = 0; + try (BufferedReader r = new BufferedReader(new InputStreamReader( + getClass().getResourceAsStream("spots-expected.txt"), + StandardCharsets.UTF_8))) + { + String line; + while ((line = r.readLine()) != null) { + final String[] c = line.split(" "); + r.readLine(); // thresholds + final String expected = r.readLine(); + final String growth = c[1].equals("1") ? "classical" : c[1].equals( + "3") ? "maximum" : "block"; + final String method; + final Double value; + final double[] radii; + switch (c[2]) { + case "1": + method = "constant"; + value = 60.0; + radii = null; + break; + case "2": + method = "mean"; + value = 0.5; + radii = new double[] { 2, 4, 6 }; + break; + case "3": + method = "gaussian"; + value = 1.285; + radii = new double[] { 10 }; + break; + default: + method = "diff"; + value = 50.0; + radii = null; + } + final int maxVolume = Integer.parseInt(c[4]); + final ImgLabeling result = spots(img, seeds, method, + value, radii, growth, Integer.parseInt(c[3]), maxVolume == 0 ? null + : maxVolume); + assertEquals(expected, rle(result), line); + cases++; + } + } + assertEquals(15, cases); + } + + /** Two separated squares give two spots of exactly their size. */ + @Test + public void testSquares() { + final Img img = ArrayImgs.floats(20, 12); + fill(img, 2, 2, 5, 5, 100); + fill(img, 10, 3, 6, 4, 80); + final ImgLabeling result = spots(img, points(new int[] { + 4, 4 }, new int[] { 12, 5 }, new int[] { 0, 11 }), "constant", 50.0, null, + null, null, null); + final Map sizes = sizes(result); + assertEquals(Map.of(1, 25, 2, 24), sizes); + + // The minimum volume drops the second square; the labels stay contiguous. + final ImgLabeling big = spots(img, points(new int[] { 12, + 5 }, new int[] { 4, 4 }), "constant", 50.0, null, null, 25, null); + assertEquals(Map.of(1, 25), sizes(big)); + + // The diff method thresholds at 100 - 30 = 70, keeping both squares + // apart from the background. + final ImgLabeling diff = spots(img, points(new int[] { 4, + 4 }), "diff", 30.0, null, null, null, null); + assertEquals(Map.of(1, 25), sizes(diff)); + } + + /** + * Two touching peaks: the classical growth merges them into the spot of the + * first seed, the block growth stops at the valley between them. + */ + @Test + public void testTouchingPeaks() { + final Img img = ArrayImgs.floats(24, 9); + final Cursor c = img.localizingCursor(); + while (c.hasNext()) { + c.fwd(); + final double x = c.getDoublePosition(0), y = c.getDoublePosition(1); + final double g1 = Math.exp(-((x - 7) * (x - 7) + (y - 4) * (y - 4)) / 8); + final double g2 = Math.exp(-((x - 15) * (x - 15) + (y - 4) * (y - 4)) / + 8); + c.get().setReal(100 * Math.max(g1, g2)); + } + final List seeds = points(new int[] { 7, 4 }, new int[] { 15, 4 }); + final ImgLabeling classical = spots(img, seeds, + "constant", 5.0, null, "classical", null, null); + assertEquals(1, sizes(classical).size()); + for (final String growth : Arrays.asList("block", "maximum")) { + final ImgLabeling split = spots(img, seeds, "constant", + 5.0, null, growth, null, null); + final Map sizes = sizes(split); + assertEquals(2, sizes.size(), growth); + // Each spot stays on its side of the valley at x = 11. + for (final Cursor> lc = split.localizingCursor(); lc + .hasNext();) + { + final LabelingType l = lc.next(); + if (l.contains(1)) assertTrue(lc.getIntPosition(0) <= 11, growth); + if (l.contains(2)) assertTrue(lc.getIntPosition(0) >= 11, growth); + } + } + } + + /** A maximum volume discards spots growing too large. */ + @Test + public void testMaxVolume() { + final Img img = ArrayImgs.floats(20, 12); + fill(img, 2, 2, 5, 5, 100); + fill(img, 10, 3, 2, 2, 80); + final ImgLabeling result = spots(img, points(new int[] { + 4, 4 }, new int[] { 10, 3 }), "constant", 50.0, null, null, null, 10); + assertEquals(Map.of(1, 4), sizes(result)); + } + + /** + * NaN pixels never join a spot, are ignored by the local thresholds, and a + * seed on a NaN pixel produces no spot. + */ + @Test + public void testNaN() { + final Img img = ArrayImgs.floats(20, 12); + fill(img, 2, 2, 5, 5, 100); + fill(img, 7, 2, 2, 5, Float.NaN); + final List seed = points(new int[] { 4, 4 }); + for (final String growth : Arrays.asList("classical", "maximum", + "block")) + { + assertEquals(Map.of(1, 25), sizes(spots(img, seed, "constant", 50.0, + null, growth, null, null)), growth); + } + // A NaN pixel inside the square is left out of the spot and of the + // local mean. + img.getAt(3, 3).set(Float.NaN); + assertEquals(Map.of(1, 24), sizes(spots(img, seed, "mean", null, null, + null, null, null))); + assertEquals(Map.of(1, 24), sizes(spots(img, seed, "constant", 50.0, null, + "maximum", null, null))); + assertEquals(Map.of(), sizes(spots(img, points(new int[] { 3, 3 }), + "constant", 50.0, null, null, 1, null))); + } + + @Test + public void testInvalidArguments() { + final Img img = ArrayImgs.floats(5, 5); + final List seeds = points(new int[] { 2, 2 }); + assertThrows(IllegalArgumentException.class, () -> spots(img, seeds, + "constant", null, null, null, null, null)); + assertThrows(IllegalArgumentException.class, () -> spots(img, seeds, + "otsu", 1.0, null, null, null, null)); + assertThrows(IllegalArgumentException.class, () -> spots(img, points( + new int[] { 7, 2 }), "constant", 1.0, null, null, null, null)); + } + + private static void fill(final Img img, final int x0, + final int y0, final int w, final int h, final float v) + { + for (int y = y0; y < y0 + h; y++) + for (int x = x0; x < x0 + w; x++) + img.getAt(x, y).set(v); + } +} diff --git a/scijava-ops-image-gpl/src/test/java/org/scijava/ops/image/gpl/ridge/DetectLinesTest.java b/scijava-ops-image-gpl/src/test/java/org/scijava/ops/image/gpl/ridge/DetectLinesTest.java new file mode 100644 index 000000000..a2929d911 --- /dev/null +++ b/scijava-ops-image-gpl/src/test/java/org/scijava/ops/image/gpl/ridge/DetectLinesTest.java @@ -0,0 +1,399 @@ +/*- + * #%L + * GPL-licensed image processing operations for SciJava Ops, translated from copyleft ImageJ/Fiji plugins. + * %% + * Copyright (C) 2026 SciJava developers. + * %% + * This program 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. + * + * This program 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 this program. If not, see + * . + * #L% + */ + +package org.scijava.ops.image.gpl.ridge; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; + +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.img.Img; +import net.imglib2.img.array.ArrayImgs; +import net.imglib2.type.numeric.real.FloatType; +import net.imglib2.view.Views; + +import org.junit.jupiter.api.Test; +import org.scijava.ops.image.gpl.AbstractOpTest; + +/** + * Tests {@link DefaultDetectLines} and {@link EstimateLineParameters}. + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +public class DetectLinesTest extends AbstractOpTest { + + private static Img image(final int kind) { + return ArrayImgs.floats(RidgeTestImages.image(kind), RidgeTestImages.W, + RidgeTestImages.H); + } + + private static DetectedLines detect(final RandomAccessibleInterval img, + final RidgeTestImages.Case c) + { + return ops.op("segment.detectLines").input(img, c.sigma, c.low, c.high, + c.dark, c.width, c.correct, c.extend, c.minLength, c.maxLength, + c.slope ? OverlapResolution.SLOPE : OverlapResolution.NONE).outType( + DetectedLines.class).apply(); + } + + private static DetectedLines detect(final RidgeTestImages.Case c) { + return detect(image(c.image), c); + } + + private static double first(final DetectedLine l, final int d) { + return Math.min(l.vertex(0).getDoublePosition(d), l.vertex(l.numVertices() - + 1).getDoublePosition(d)); + } + + /** Must match the line order used by the harness for overlap resolution. */ + private static final Comparator LINE_ORDER = Comparator + . comparingInt(DetectedLine::numVertices) + .thenComparingDouble(l -> first(l, 0)).thenComparingDouble(l -> first(l, + 1)); + + /** + * Compares the op with the original plugin on all test cases. + *

+ * The expected values in {@code ridge-expected.txt} were produced by running + * the {@code LineDetector} of ij-ridgedetection 1.4.2-SNAPSHOT (compiled from + * source, with ImageJ 1.54p) on the images of {@link RidgeTestImages} with + * the parameters of {@link RidgeTestImages#CASES}, resetting the line ID + * counter before each case. With overlap resolution, whose output order + * depends on identity hash codes in the plugin, lines are sorted by number + * of points and then by start coordinates on both sides, and merged lines + * may be reversed. + *

+ */ + @Test + public void testRegression() throws IOException { + final List expected = readExpected(); + int k = 0; + for (int c = 0; c < RidgeTestImages.CASES.length; c++) { + final RidgeTestImages.Case cs = RidgeTestImages.CASES[c]; + final DetectedLines result = detect(cs); + final List lines = new ArrayList<>(result.lines()); + if (cs.slope) lines.sort(LINE_ORDER); + final String[] header = expected.get(k++).split(" "); + assertEquals("case " + c, header[0] + " " + header[1]); + assertEquals(Integer.parseInt(header[2]), lines.size(), "case " + c); + assertEquals(Integer.parseInt(header[3]), result.junctions().size(), + "case " + c); + for (final DetectedLine l : lines) { + final String[] lh = expected.get(k++).split(" "); + assertEquals(ContourClass.values()[Integer.parseInt(lh[1])], l + .contourClass(), "case " + c); + assertEquals(Integer.parseInt(lh[2]), l.numVertices(), "case " + c); + final List points = new ArrayList<>(); + for (int i = 0; i < l.numVertices(); i++) + points.add(parse(expected.get(k++))); + // Note: with overlap resolution, the direction of a merged line + // depends on the identity hash order of the plugin. + final boolean reversed = cs.slope && Math.abs(points.get(0)[0] - l + .vertex(0).getDoublePosition(0)) + Math.abs(points.get(0)[1] - l + .vertex(0).getDoublePosition(1)) > 1e-3; + for (int i = 0; i < l.numVertices(); i++) { + final double[] e = points.get(reversed ? l.numVertices() - 1 - i + : i); + final String msg = "case " + c + " point " + i; + assertEquals(e[0], l.vertex(i).getDoublePosition(0), 1e-4, msg); + assertEquals(e[1], l.vertex(i).getDoublePosition(1), 1e-4, msg); + assertEquals(e[2], l.angle(i), 1e-4, msg); + assertEquals(e[3], l.response(i), 1e-3, msg); + if (cs.width || cs.correct) { + assertEquals(e[4], l.widthLeft(i), 1e-4, msg); + assertEquals(e[5], l.widthRight(i), 1e-4, msg); + } + else assertFalse(l.hasWidth()); + if (cs.correct) { + assertEquals(e[6], l.asymmetry(i), 1e-4, msg); + assertEquals(e[7], l.intensity(i), 1e-2, msg); + } + else assertFalse(l.hasCorrection()); + } + } + for (final LineJunction j : result.junctions()) { + final String[] e = expected.get(k++).split(" "); + final String msg = "case " + c + " junction"; + assertEquals("junction", e[0]); + assertEquals(Double.parseDouble(e[1]), j.x(), 1e-4, msg); + assertEquals(Double.parseDouble(e[2]), j.y(), 1e-4, msg); + final int e1 = Integer.parseInt(e[3]), e2 = Integer.parseInt(e[4]); + final int a1 = lines.indexOf(j.line1()), a2 = lines.indexOf(j.line2()); + if (cs.slope) { + // Note: the order of the two lines of a junction between merged + // lines (and so its position) depends on the plugin hash order. + assertEquals(Math.min(e1, e2), Math.min(a1, a2), msg); + assertEquals(Math.max(e1, e2), Math.max(a1, a2), msg); + } + else { + assertEquals(e1, a1, msg); + assertEquals(e2, a2, msg); + assertEquals(Integer.parseInt(e[5]), j.position(), msg); + } + assertEquals(Boolean.parseBoolean(e[6]), j.isNonTerminal(), msg); + } + } + assertEquals(expected.size(), k); + } + + /** A Gaussian line profile is located at its center. */ + @Test + public void testGaussianLinePosition() { + final DetectedLines result = detect(RidgeTestImages.CASES[0]); + final DetectedLine main = longest(result.lines()); + assertTrue(main.length() > 60, "length " + main.length()); + final double t = 2 * Math.PI / 40; + for (int i = 0; i < main.numVertices(); i++) { + final double x = main.vertex(i).getDoublePosition(0); + final double y = main.vertex(i).getDoublePosition(1); + if (x < 4 || x > RidgeTestImages.W - 5) continue; + assertEquals(24 + 6 * Math.sin(t * x), y, 0.25, "x = " + x); + assertTrue(main.response(i) > 8); + } + } + + /** + * Dark lines are detected as the bright lines of the inverted image, with + * the opposite contrast. + */ + @Test + public void testDarkLineInvariance() { + final RidgeTestImages.Case c = RidgeTestImages.CASES[2]; + final Img dark = image(c.image); + final Img bright = dark.copy(); + bright.forEach(v -> v.set(255 - v.get())); + final DetectedLines d = detect(dark, c); + final DetectedLines b = ops.op("segment.detectLines").input(bright, c.sigma, + c.low, c.high, false, true, true).outType(DetectedLines.class).apply(); + assertEquals(d.lines().size(), b.lines().size()); + for (int n = 0; n < d.lines().size(); n++) { + final DetectedLine ld = d.lines().get(n), lb = b.lines().get(n); + assertEquals(ld.numVertices(), lb.numVertices()); + for (int i = 0; i < ld.numVertices(); i++) { + assertEquals(ld.vertex(i).getDoublePosition(0), lb.vertex(i) + .getDoublePosition(0), 1e-3); + assertEquals(ld.vertex(i).getDoublePosition(1), lb.vertex(i) + .getDoublePosition(1), 1e-3); + assertEquals(ld.widthLeft(i), lb.widthLeft(i), 1e-3); + assertEquals(ld.intensity(i), -lb.intensity(i), 1e-2); + } + } + } + + /** The width and position of a dark bar of width 5 at 30 degrees. */ + @Test + public void testDarkBarWidth() { + final DetectedLines result = detect(RidgeTestImages.CASES[2]); + final DetectedLine line = longest(result.lines()); + assertTrue(line.length() > 50, "length " + line.length()); + final double a = Math.toRadians(30); + for (int i = 2; i < line.numVertices() - 2; i++) { + final double x = line.vertex(i).getDoublePosition(0); + final double y = line.vertex(i).getDoublePosition(1); + assertEquals(0, -(x - 32) * Math.sin(a) + (y - 24) * Math.cos(a), 0.15); + // The width search line is mirrored at the image borders. + if (x < 6 || y < 6 || x > RidgeTestImages.W - 7 || y > RidgeTestImages.H - + 7) continue; + assertEquals(5, line.widthLeft(i) + line.widthRight(i), 0.5); + assertEquals(-140, line.intensity(i), 10); + } + } + + /** + * A bar of width 5 between backgrounds of different brightness: the + * uncorrected position and width are biased, the corrected ones are not + * (Steger 1998), and the asymmetry is recovered. + */ + @Test + public void testAsymmetricBiasCorrection() { + final DetectedLine biased = longest(detect(RidgeTestImages.CASES[3]) + .lines()); + final DetectedLine corrected = longest(detect(RidgeTestImages.CASES[4]) + .lines()); + final double biasedX = meanX(biased), correctedX = meanX(corrected); + assertTrue(Math.abs(biasedX - 32) > 0.3, "biased x " + biasedX); + assertEquals(32, correctedX, 0.1); + final double asymmetry = (110.0 - 40) / (200 - 40); + for (int i = 2; i < corrected.numVertices() - 2; i++) { + assertEquals(5, corrected.widthLeft(i) + corrected.widthRight(i), 0.4); + assertEquals(asymmetry, corrected.asymmetry(i), 0.05); + assertEquals(160, corrected.intensity(i), 12); + } + // The bias makes the uncorrected widths asymmetric. + final int mid = biased.numVertices() / 2; + assertTrue(Math.abs(biased.widthLeft(mid) - biased.widthRight(mid)) > 0.4); + } + + /** Crossing lines are split at the crossing, or merged straight across. */ + @Test + public void testCrossingLines() { + final DetectedLines split = detect(RidgeTestImages.CASES[5]); + assertEquals(4, split.lines().size()); + assertFalse(split.junctions().isEmpty()); + for (final LineJunction j : split.junctions()) { + assertEquals(32, j.x(), 0.5); + assertEquals(24, j.y(), 0.5); + } + for (final DetectedLine l : split.lines()) { + assertTrue(l.contourClass() == ContourClass.START_JUNCTION || l + .contourClass() == ContourClass.END_JUNCTION); + } + + final DetectedLines merged = detect(RidgeTestImages.CASES[8]); + assertEquals(2, merged.lines().size()); + assertEquals(1, merged.junctions().size()); + assertTrue(merged.junctions().get(0).isNonTerminal()); + final double[] angles = { 20, 110 }; + for (final DetectedLine l : merged.lines()) { + // Each merged line is straight through the crossing. + final double dx = l.vertex(l.numVertices() - 1).getDoublePosition(0) - l + .vertex(0).getDoublePosition(0); + final double dy = l.vertex(l.numVertices() - 1).getDoublePosition(1) - l + .vertex(0).getDoublePosition(1); + final double angle = Math.toDegrees(Math.atan2(dy, dx)); + final double a = ((angle % 180) + 180) % 180; + final double err = Math.min(Math.abs(a - angles[0]), Math.abs(a - + angles[1])); + assertTrue(err < 3, "angle " + a); + assertEquals(l.length(), Math.hypot(dx, dy), 0.05 * l.length()); + } + } + + /** Lines are pruned by length. */ + @Test + public void testLengthPruning() { + final DetectedLines all = detect(RidgeTestImages.CASES[9]); + final DetectedLines pruned = detect(RidgeTestImages.CASES[10]); + assertTrue(pruned.lines().size() < all.lines().size()); + for (final DetectedLine l : pruned.lines()) { + assertTrue(l.length() >= 10 && l.length() <= 40, "length " + l.length()); + } + boolean ring = false; + for (final DetectedLine l : all.lines()) + ring |= l.length() > 60; + assertTrue(ring); + } + + /** Lines of a translated image are translated. */ + @Test + public void testTranslatedInput() { + final RidgeTestImages.Case c = RidgeTestImages.CASES[5]; + final DetectedLines ref = detect(c); + final DetectedLines moved = detect(Views.translate(image(c.image), 10, + -5), c); + assertEquals(ref.lines().size(), moved.lines().size()); + for (int n = 0; n < ref.lines().size(); n++) { + final DetectedLine a = ref.lines().get(n), b = moved.lines().get(n); + for (int i = 0; i < a.numVertices(); i++) { + assertEquals(a.vertex(i).getDoublePosition(0) + 10, b.vertex(i) + .getDoublePosition(0), 1e-4); + assertEquals(a.vertex(i).getDoublePosition(1) - 5, b.vertex(i) + .getDoublePosition(1), 1e-4); + } + } + final LineJunction j = moved.junctions().get(0); + assertEquals(ref.junctions().get(0).x() + 10, j.x(), 1e-4); + assertEquals(ref.junctions().get(0).y() - 5, j.y(), 1e-4); + } + + @Test + public void testInvalidInput() { + final Img img3d = ArrayImgs.floats(16, 16, 3); + assertThrows(IllegalArgumentException.class, () -> ops.op( + "segment.detectLines").input(img3d, 1.5, 3.0, 8.0).outType( + DetectedLines.class).apply()); + final Img img = image(RidgeTestImages.GAUSS); + assertThrows(IllegalArgumentException.class, () -> ops.op( + "segment.detectLines").input(img, 0.3, 3.0, 8.0).outType( + DetectedLines.class).apply()); + assertThrows(IllegalArgumentException.class, () -> ops.op( + "segment.detectLines").input(img, 20.0, 3.0, 8.0).outType( + DetectedLines.class).apply()); + } + + /** The plugin dialog defaults are consistent with its estimation. */ + @Test + public void testEstimateLineParameters() { + final double[] p = ops.op("segment.estimateLineParameters").input(3.5, + 87.0, 230.0).outType(double[].class).apply(); + // Plugin defaults: line width 3.5, contrasts 87 and 230 give sigma 1.51, + // lower threshold 3.06 and upper threshold 7.99. + assertEquals(1.51, p[0], 0.005); + assertEquals(3.06, p[1], 0.005); + assertEquals(7.99, p[2], 0.005); + final double[] d = ops.op("segment.estimateLineParameters").input(3.5, + 25.0, 168.0, true).outType(double[].class).apply(); + // For dark lines, the contrasts are relative to 255: 255 - 168 = 87. + assertEquals(p[0], d[0], 1e-12); + assertEquals(p[1], d[1], 1e-12); + assertEquals(p[2], d[2], 1e-12); + } + + private static DetectedLine longest(final List lines) { + DetectedLine best = null; + for (final DetectedLine l : lines) + if (best == null || l.length() > best.length()) best = l; + return best; + } + + private static double meanX(final DetectedLine l) { + double sum = 0; + int n = 0; + for (int i = 2; i < l.numVertices() - 2; i++) { + sum += l.vertex(i).getDoublePosition(0); + n++; + } + return sum / n; + } + + private static double[] parse(final String line) { + final String[] t = line.split(" "); + final double[] v = new double[t.length]; + for (int i = 0; i < t.length; i++) + v[i] = Double.parseDouble(t[i]); + return v; + } + + private static List readExpected() throws IOException { + final List lines = new ArrayList<>(); + try (final BufferedReader r = new BufferedReader(new InputStreamReader( + DetectLinesTest.class.getResourceAsStream("ridge-expected.txt"), + StandardCharsets.UTF_8))) + { + String line; + while ((line = r.readLine()) != null) + if (!line.isEmpty()) lines.add(line); + } + return lines; + } +} diff --git a/scijava-ops-image-gpl/src/test/java/org/scijava/ops/image/gpl/ridge/RidgeTestImages.java b/scijava-ops-image-gpl/src/test/java/org/scijava/ops/image/gpl/ridge/RidgeTestImages.java new file mode 100644 index 000000000..4915ddf59 --- /dev/null +++ b/scijava-ops-image-gpl/src/test/java/org/scijava/ops/image/gpl/ridge/RidgeTestImages.java @@ -0,0 +1,157 @@ +/*- + * #%L + * GPL-licensed image processing operations for SciJava Ops, translated from copyleft ImageJ/Fiji plugins. + * %% + * Copyright (C) 2026 SciJava developers. + * %% + * This program 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. + * + * This program 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 this program. If not, see + * . + * #L% + */ + +package org.scijava.ops.image.gpl.ridge; + +/** + * Synthetic line images and detection parameters shared by + * {@link DetectLinesTest} and the harness that ran the original plugin to + * produce {@code ridge-expected.txt}; this class must not depend on anything + * but the JDK. + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +public final class RidgeTestImages { + + private RidgeTestImages() { + // Prevent instantiation of utility class. + } + + public static final int W = 64; + public static final int H = 48; + + /** Image kinds. */ + public static final int GAUSS = 0, DARK_BAR = 1, ASYMMETRIC = 2, CROSS = 3, + RING_T = 4; + + /** Detection parameters of one test case. */ + public static final class Case { + + public final int image; + public final double sigma, low, high, minLength, maxLength; + public final boolean dark, width, correct, extend, slope; + + Case(final int image, final double sigma, final double low, + final double high, final boolean dark, final boolean width, + final boolean correct, final boolean extend, final double minLength, + final double maxLength, final boolean slope) + { + this.image = image; + this.sigma = sigma; + this.low = low; + this.high = high; + this.dark = dark; + this.width = width; + this.correct = correct; + this.extend = extend; + this.minLength = minLength; + this.maxLength = maxLength; + this.slope = slope; + } + } + + public static final Case[] CASES = { // + new Case(GAUSS, 1.5, 3, 8, false, true, false, true, 0, 0, false), // + new Case(GAUSS, 1.5, 3, 8, false, false, false, false, 0, 0, false), // + new Case(DARK_BAR, 1.94, 4, 8, true, true, true, true, 0, 0, false), // + new Case(ASYMMETRIC, 1.94, 4, 8, false, true, false, true, 0, 0, false), // + new Case(ASYMMETRIC, 1.94, 4, 8, false, true, true, true, 0, 0, false), // + new Case(CROSS, 1.4, 4, 10, false, true, true, true, 0, 0, false), // + new Case(CROSS, 1.4, 4, 10, false, true, true, true, 0, 0, true), // + new Case(CROSS, 1.4, 4, 10, false, false, false, true, 0, 0, true), // + new Case(CROSS, 1.4, 4, 10, false, true, false, true, 0, 0, true), // + new Case(RING_T, 1.4, 4, 10, false, true, false, true, 0, 0, false), // + new Case(RING_T, 1.4, 4, 10, false, true, false, true, 10, 40, false), // + }; + + /** Small deterministic noise in [0, 1). */ + private static double noise(final int x, final int y) { + return (((x * 73856093) ^ (y * 19349663)) & 1023) / 1023.0; + } + + /** Anti-aliased bar profile of the given width at distance d. */ + private static double bar(final double d, final double width) { + return Math.max(0, Math.min(1, width / 2 + 0.5 - Math.abs(d))); + } + + /** Distance of (x, y) to the line through (cx, cy) at angle a. */ + private static double dist(final double x, final double y, final double cx, + final double cy, final double a) + { + return -(x - cx) * Math.sin(a) + (y - cy) * Math.cos(a); + } + + /** + * @param kind the image kind + * @return the pixels of the image, row by row + */ + public static float[] image(final int kind) { + final float[] px = new float[W * H]; + for (int y = 0; y < H; y++) { + for (int x = 0; x < W; x++) { + final double v; + switch (kind) { + case GAUSS: { + // Gaussian profile (s = 1.2) along a sine curve. + final double t = 2 * Math.PI / 40; + final double cy = 24 + 6 * Math.sin(t * x); + final double slope = 6 * t * Math.cos(t * x); + final double d = (y - cy) / Math.sqrt(1 + slope * slope); + v = 30 + 120 * Math.exp(-d * d / (2 * 1.2 * 1.2)); + break; + } + case DARK_BAR: { + // Dark bar of width 5 at 30 degrees on a bright background. + final double d = dist(x, y, 32, 24, Math.toRadians(30)); + v = 200 - 140 * bar(d, 5); + break; + } + case ASYMMETRIC: { + // Vertical bright bar of width 5 between backgrounds 40 and 110. + final double d = x - 32.0; + final double bg = d < 0 ? 40 : 110; + v = bg + (200 - bg) * bar(d, 5); + break; + } + case CROSS: { + // Two bright bars of width 3 crossing at (32, 24). + final double b1 = bar(dist(x, y, 32, 24, Math.toRadians(20)), 3); + final double b2 = bar(dist(x, y, 32, 24, Math.toRadians(110)), 3); + v = 20 + 150 * Math.max(b1, b2); + break; + } + default: { + // A ring (radius 12) and a horizontal bar touching it (T). + final double r = Math.hypot(x - 22, y - 24); + final double ring = bar(r - 12, 3); + final double stem = x >= 34 && x <= 58 ? bar(y - 24, 3) : 0; + v = 20 + 150 * Math.max(ring, stem); + break; + } + } + px[y * W + x] = (float) (v + 3 * noise(x, y)); + } + } + return px; + } +} diff --git a/scijava-ops-image-gpl/src/test/java/org/scijava/ops/image/gpl/skeleton/SkeletonAnalysisTest.java b/scijava-ops-image-gpl/src/test/java/org/scijava/ops/image/gpl/skeleton/SkeletonAnalysisTest.java new file mode 100644 index 000000000..2f431c6cc --- /dev/null +++ b/scijava-ops-image-gpl/src/test/java/org/scijava/ops/image/gpl/skeleton/SkeletonAnalysisTest.java @@ -0,0 +1,314 @@ +/*- + * #%L + * GPL-licensed image processing operations for SciJava Ops, translated from copyleft ImageJ/Fiji plugins. + * %% + * Copyright (C) 2026 SciJava developers. + * %% + * This program 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. + * + * This program 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 this program. If not, see + * . + * #L% + */ + +package org.scijava.ops.image.gpl.skeleton; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; + +import net.imglib2.Localizable; +import net.imglib2.RandomAccess; +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.img.Img; +import net.imglib2.img.array.ArrayImgs; +import net.imglib2.type.logic.BitType; +import net.imglib2.type.numeric.integer.UnsignedByteType; +import net.imglib2.type.numeric.real.DoubleType; +import net.imglib2.view.Views; + +import org.junit.jupiter.api.Test; +import org.scijava.ops.image.gpl.AbstractOpTest; +import org.scijava.types.Nil; + +/** + * Tests {@link TagSkeleton}, {@link DefaultSkeletonGraph} and + * {@link DefaultSkeletonStatistics} on synthetic skeletons with known + * structure. + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +public class SkeletonAnalysisTest extends AbstractOpTest { + + private static final double EPS = 1e-12; + + private static Img image2D(final int[][] points) { + final Img img = ArrayImgs.bits(20, 20); + final RandomAccess ra = img.randomAccess(); + for (final int[] p : points) + ra.setPositionAndGet(p).set(true); + return img; + } + + private static int[][] line2D() { + final int[][] points = new int[11][]; + for (int i = 0; i <= 10; i++) + points[i] = new int[] { 2 + i, 2 }; + return points; + } + + /** A plus with arms of length 5 around (10, 10). */ + private static int[][] cross2D() { + final int[][] points = new int[21][]; + int k = 0; + for (int i = 5; i <= 15; i++) + points[k++] = new int[] { i, 10 }; + for (int i = 5; i <= 15; i++) + if (i != 10) points[k++] = new int[] { 10, i }; + return points; + } + + /** A diamond-shaped ring of 16 pixels. */ + private static int[][] ring2D() { + final int[][] points = new int[16][]; + for (int i = 0; i < 4; i++) { + points[i] = new int[] { 5 + i, 5 + i }; + points[4 + i] = new int[] { 9 - i, 9 + i }; + points[8 + i] = new int[] { 5 - i, 13 - i }; + points[12 + i] = new int[] { 1 + i, 9 - i }; + } + return points; + } + + private List statistics( + final RandomAccessibleInterval img) + { + return ops.op("topology.skeletonStatistics").input(img).outType( + new Nil>() + {}).apply(); + } + + private List graphs( + final RandomAccessibleInterval img, final double[] calibration, + final RandomAccessibleInterval intensity) + { + return ops.op("topology.skeletonGraph").input(img, calibration, null, null, + intensity).outType(new Nil>() + {}).apply(); + } + + private RandomAccessibleInterval tag( + final RandomAccessibleInterval img) + { + return ops.op("morphology.tagSkeleton").input(img).outType( + new Nil>() + {}).apply(); + } + + @Test + public void testTagLine() { + final RandomAccess ra = tag(image2D(line2D())) + .randomAccess(); + assertEquals(TagSkeleton.END_POINT, ra.setPositionAndGet(2, 2).get()); + assertEquals(TagSkeleton.END_POINT, ra.setPositionAndGet(12, 2).get()); + for (int x = 3; x < 12; x++) + assertEquals(TagSkeleton.SLAB, ra.setPositionAndGet(x, 2).get()); + assertEquals(0, ra.setPositionAndGet(2, 3).get()); + } + + @Test + public void testTagCross() { + final RandomAccess ra = tag(image2D(cross2D())) + .randomAccess(); + // The center and its four direct neighbors each touch three or more + // foreground pixels. + assertEquals(TagSkeleton.JUNCTION, ra.setPositionAndGet(10, 10).get()); + assertEquals(TagSkeleton.JUNCTION, ra.setPositionAndGet(11, 10).get()); + assertEquals(TagSkeleton.JUNCTION, ra.setPositionAndGet(10, 9).get()); + assertEquals(TagSkeleton.SLAB, ra.setPositionAndGet(12, 10).get()); + assertEquals(TagSkeleton.END_POINT, ra.setPositionAndGet(10, 15).get()); + } + + @Test + public void testTagND() { + final Img img = ArrayImgs.bits(4, 4, 4, 4); + final RandomAccess in = img.randomAccess(); + for (int i = 0; i < 4; i++) + in.setPositionAndGet(i, i, i, i).set(true); + in.setPositionAndGet(0, 3, 0, 3).set(true); + final RandomAccess ra = tag(img).randomAccess(); + assertEquals(TagSkeleton.END_POINT, ra.setPositionAndGet(0, 0, 0, 0) + .get()); + assertEquals(TagSkeleton.SLAB, ra.setPositionAndGet(1, 1, 1, 1).get()); + assertEquals(TagSkeleton.END_POINT, ra.setPositionAndGet(3, 3, 3, 3) + .get()); + assertEquals(TagSkeleton.END_POINT, ra.setPositionAndGet(0, 3, 0, 3) + .get()); + } + + @Test + public void testLine() { + final List stats = statistics(image2D(line2D())); + assertEquals(1, stats.size()); + final SkeletonStatistics s = stats.get(0); + assertEquals(1, s.branches()); + assertEquals(0, s.junctions()); + assertEquals(2, s.endPointVoxels()); + assertEquals(9, s.slabVoxels()); + assertEquals(10, s.averageBranchLength(), EPS); + assertEquals(10, s.maximumBranchLength(), EPS); + assertTrue(Double.isNaN(s.longestShortestPath())); + assertTrue(s.longestShortestPathPoints().isEmpty()); + + final SkeletonGraph g = s.graph(); + assertEquals(2, g.vertices().size()); + assertEquals(1, g.edges().size()); + final SkeletonEdge e = g.edges().get(0); + assertEquals(9, e.slabs().size()); + assertEquals(10, e.euclideanDistance(), EPS); + assertTrue(Double.isNaN(e.averageIntensity())); + assertSame(g.root(), e.v1()); + assertSame(e.v2(), e.opposite(e.v1())); + assertEquals(1, g.edgesOf(e.v2()).size()); + final Localizable p = e.v1().points().get(0); + assertEquals(2, p.numDimensions()); + assertEquals(2, p.getLongPosition(0)); + } + + @Test + public void testLongestShortestPath() { + final List stats = ops.op( + "topology.skeletonStatistics").input(image2D(cross2D()), null, null, + null, null, true).outType(new Nil>() + {}).apply(); + final SkeletonStatistics s = stats.get(0); + assertEquals(4, s.branches()); + assertEquals(1, s.junctions()); + assertEquals(5, s.junctionVoxels()); + assertEquals(4, s.endPointVoxels()); + assertEquals(1, s.quadruplePoints()); + assertEquals(0, s.triplePoints()); + // Branch lengths are measured to the first voxel of the junction cluster + // (10, 9), so the arms measure 4, 4 + sqrt(2), 4 + sqrt(2) and 6, as in + // the original plugin. + assertEquals(6, s.maximumBranchLength(), EPS); + assertEquals(10 + Math.sqrt(2), s.longestShortestPath(), EPS); + assertEquals(5, s.graph().vertices().size()); + assertEquals(4, s.graph().edgesOf(s.graph().edges().get(0).v2()).size()); + final List path = s.longestShortestPathPoints(); + assertTrue(path.size() >= 11); + } + + @Test + public void testCalibrationAndTranslation() { + final RandomAccessibleInterval img = Views.translate(image2D( + line2D()), 100, 200); + final List g = graphs(img, new double[] { 0.5, 2 }, null); + final SkeletonEdge e = g.get(0).edges().get(0); + assertEquals(5, e.length(), EPS); + assertEquals(5, e.euclideanDistance(), EPS); + final Localizable p = e.v1().points().get(0); + assertEquals(102, p.getLongPosition(0)); + assertEquals(202, p.getLongPosition(1)); + assertEquals(103, e.slabs().get(0).getLongPosition(0)); + } + + @Test + public void testIntensity() { + final Img intensity = ArrayImgs.doubles(20, 20); + for (final DoubleType t : intensity) + t.set(7); + final SkeletonEdge e = graphs(image2D(line2D()), null, intensity).get(0) + .edges().get(0); + assertEquals(7, e.averageIntensity(), EPS); + assertEquals(7, e.averageIntensityInnerThird(), EPS); + } + + @Test + public void testRing() { + final List stats = statistics(image2D(ring2D())); + assertEquals(1, stats.size()); + final SkeletonStatistics s = stats.get(0); + assertEquals(1, s.branches()); + assertEquals(0, s.junctions()); + assertEquals(0, s.endPointVoxels()); + assertEquals(16, s.slabVoxels()); + // The closing step back to the start voxel is not counted. + assertEquals(15 * Math.sqrt(2), s.averageBranchLength(), EPS); + final SkeletonGraph g = s.graph(); + assertNull(g.root()); + assertEquals(1, g.vertices().size()); + final SkeletonEdge e = g.edges().get(0); + assertSame(e.v1(), e.v2()); + assertEquals(1, g.edgesOf(e.v1()).size()); + + // Cycle pruning breaks the ring into a line. + final List pruned = ops.op( + "topology.skeletonStatistics").input(image2D(ring2D()), null, + CyclePruning.SHORTEST_BRANCH).outType( + new Nil>() + {}).apply(); + assertEquals(2, pruned.get(0).endPointVoxels()); + assertEquals(1, pruned.get(0).branches()); + } + + @Test + public void testSeveralTrees() { + final int[][] line = line2D(); + final int[][] points = new int[line.length + 1][]; + System.arraycopy(line, 0, points, 0, line.length); + points[line.length] = new int[] { 15, 15 }; + final List stats = statistics(image2D(points)); + assertEquals(2, stats.size()); + final SkeletonStatistics single = stats.get(1); + assertEquals(0, single.branches()); + assertEquals(1, single.endPointVoxels()); + assertEquals(1, single.graph().vertices().size()); + assertEquals(0, single.graph().edges().size()); + assertTrue(statistics(ArrayImgs.bits(5, 5)).isEmpty()); + } + + @Test + public void testTripod3D() { + final Img img = SkeletonRegressionTest.draw(12, 12, 12, + new int[][] { { 5, 5, 5, 10, 5, 5 }, { 5, 5, 5, 5, 0, 5 }, { 5, 5, 5, 5, + 5, 11 } }); + final SkeletonStatistics s = statistics(img).get(0); + assertEquals(3, s.branches()); + assertEquals(1, s.junctions()); + assertEquals(3, s.endPointVoxels()); + assertEquals(1, s.triplePoints()); + assertEquals(3, s.graph().edgesOf(s.graph().edges().get(0).v2()).size()); + // The arms have 5, 5 and 6 steps from the center; branches are measured + // along voxels from the first voxel of the junction cluster. + final double total = s.averageBranchLength() * s.branches(); + assertEquals(16, total, 0.5); + } + + @Test + public void testInvalidArguments() { + assertThrows(RuntimeException.class, () -> ops.op( + "topology.skeletonGraph").input(image2D(ring2D()), null, + CyclePruning.LOWEST_INTENSITY_VOXEL).outType( + new Nil>() + {}).apply()); + assertThrows(RuntimeException.class, () -> graphs(image2D(line2D()), + new double[] { 1, 1, 1 }, null)); + assertThrows(RuntimeException.class, () -> statistics(ArrayImgs.bits(2, 2, + 2, 2))); + } +} diff --git a/scijava-ops-image-gpl/src/test/java/org/scijava/ops/image/gpl/skeleton/SkeletonRegressionTest.java b/scijava-ops-image-gpl/src/test/java/org/scijava/ops/image/gpl/skeleton/SkeletonRegressionTest.java new file mode 100644 index 000000000..db4f209ef --- /dev/null +++ b/scijava-ops-image-gpl/src/test/java/org/scijava/ops/image/gpl/skeleton/SkeletonRegressionTest.java @@ -0,0 +1,392 @@ +/*- + * #%L + * GPL-licensed image processing operations for SciJava Ops, translated from copyleft ImageJ/Fiji plugins. + * %% + * Copyright (C) 2026 SciJava developers. + * %% + * This program 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. + * + * This program 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 this program. If not, see + * . + * #L% + */ + + +package org.scijava.ops.image.gpl.skeleton; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import net.imglib2.Cursor; +import net.imglib2.Localizable; +import net.imglib2.RandomAccess; +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.img.Img; +import net.imglib2.img.array.ArrayImgs; +import net.imglib2.type.logic.BitType; +import net.imglib2.type.numeric.integer.UnsignedByteType; + +import org.junit.jupiter.api.Test; +import org.scijava.ops.image.gpl.AbstractOpTest; +import org.scijava.types.Nil; + +/** + * Regression tests of {@code topology.skeletonStatistics} and + * {@code topology.skeletonGraph} against the original Fiji AnalyzeSkeleton + * plugin. + *

+ * The expected values were produced by running the original plugin (source + * of version 3.4.3-SNAPSHOT, with ImageJ 1.54p) from a throwaway harness on + * the same synthetic skeleton, drawn into an 8-bit stack with the same + * {@link #draw} code, and printing its SkeletonResult and graphs in the + * format of {@link #describe}. The skeleton holds a straight line, a 3D + * tripod, a 2D cross, a square loop with two tails and a diamond ring. + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +public class SkeletonRegressionTest extends AbstractOpTest { + + static final int[][] SEGMENTS = { // + { 2, 2, 1, 12, 2, 1 }, // + { 20, 6, 1, 30, 6, 1 }, { 25, 1, 1, 25, 11, 1 }, // + { 5, 15, 1, 9, 19, 1 }, { 9, 19, 1, 5, 23, 1 }, { 5, 23, 1, 1, 19, 1 }, { + 1, 19, 1, 5, 15, 1 }, // + { 20, 20, 4, 14, 14, 1 }, { 20, 20, 4, 27, 20, 7 }, { 20, 20, 4, 20, 27, + 4 }, // + { 30, 14, 1, 36, 14, 1 }, { 36, 14, 1, 36, 20, 1 }, { 36, 20, 1, 30, 20, + 1 }, { 30, 20, 1, 30, 14, 1 }, { 36, 17, 1, 39, 17, 1 }, { 30, 17, 1, + 27, 17, 1 } }; + + static final String[][] EXPECTED = { + // A none + { "tree 0: branches=1 junctions=0 endPoints=2 junctionVoxels=0 slabs=9 avg=10.0 triples=0 quads=0 max=10.0 lsp=10.0", // + " vertices=2 edges=1", // + " edge (2, 2, 1) (12, 2, 1) len=10.0 ra=10.0 slabs=9", // + "tree 1: branches=3 junctions=1 endPoints=3 junctionVoxels=3 slabs=15 avg=8.227144598981733 triples=1 quads=0 max=9.438793109825918 lsp=17.6814337969452", // + " vertices=4 edges=3", // + " edge (14, 14, 1) (20, 20, 4) len=9.438793109825918 ra=9.301445722753648 slabs=5", // + " edge (20, 27, 4) (20, 20, 4) len=7.0 ra=7.0 slabs=5", // + " edge (27, 20, 7) (20, 20, 4) len=8.242640687119284 ra=7.53338387128521 slabs=5", // + "tree 2: branches=4 junctions=1 endPoints=4 junctionVoxels=5 slabs=12 avg=5.207106781186548 triples=0 quads=1 max=6.0 lsp=11.414213562373096", // + " vertices=5 edges=4", // + " edge (20, 6, 1) (24, 6, 1) len=4.0 ra=4.0 slabs=3", // + " edge (25, 1, 1) (24, 6, 1) len=5.414213562373095 ra=5.414213562373095 slabs=3", // + " edge (25, 11, 1) (24, 6, 1) len=5.414213562373095 ra=5.414213562373095 slabs=3", // + " edge (30, 6, 1) (24, 6, 1) len=6.0 ra=6.0 slabs=3", // + "tree 3: branches=8 junctions=2 endPoints=2 junctionVoxels=16 slabs=12 avg=5.298476161873042 triples=0 quads=0 max=13.60555127546399 lsp=15.21110255092798", // + " vertices=4 edges=8", // + " edge (27, 17, 1) (29, 17, 1) len=2.0 ra=2.0 slabs=1", // + " edge (39, 17, 1) (35, 14, 1) len=5.60555127546399 ra=5.60555127546399 slabs=1", // + " edge (29, 17, 1) (29, 17, 1) len=3.23606797749979 ra=0.0 slabs=1", // + " edge (29, 17, 1) (35, 14, 1) len=13.60555127546399 ra=9.0 slabs=3", // + " edge (29, 17, 1) (29, 17, 1) len=3.23606797749979 ra=0.0 slabs=1", // + " edge (29, 17, 1) (35, 14, 1) len=7.60555127546399 ra=3.0 slabs=3", // + " edge (35, 14, 1) (35, 14, 1) len=1.0 ra=0.0 slabs=1", // + " edge (35, 14, 1) (35, 14, 1) len=6.0990195135927845 ra=0.0 slabs=1", // + "tree 4: branches=1 junctions=0 endPoints=0 junctionVoxels=0 slabs=16 avg=21.213203435596434 triples=0 quads=0 max=21.213203435596434 lsp=0.0", // + " vertices=1 edges=1", // + " edge (1, 19, 1) (1, 19, 1) len=21.213203435596434 ra=17.606629560649026 slabs=16" }, + // A calibrated + { "tree 0: branches=1 junctions=0 endPoints=2 junctionVoxels=0 slabs=9 avg=5.0 triples=0 quads=0 max=5.0 lsp=5.0", // + " vertices=2 edges=1", // + " edge (2, 2, 1) (12, 2, 1) len=5.0 ra=5.0 slabs=9", // + "tree 1: branches=3 junctions=1 endPoints=3 junctionVoxels=3 slabs=15 avg=6.723313270888354 triples=1 quads=0 max=8.48528137423857 lsp=16.66993981266506", // + " vertices=4 edges=3", // + " edge (14, 14, 1) (20, 20, 4) len=8.48528137423857 ra=8.5405875956206 slabs=5", // + " edge (20, 27, 4) (20, 20, 4) len=3.5 ra=3.5 slabs=5", // + " edge (27, 20, 7) (20, 20, 4) len=8.18465843842649 ra=6.312799424038784 slabs=5", // + "tree 2: branches=4 junctions=1 endPoints=4 junctionVoxels=5 slabs=12 avg=2.603553390593274 triples=0 quads=1 max=3.0 lsp=5.707106781186548", // + " vertices=5 edges=4", // + " edge (20, 6, 1) (24, 6, 1) len=2.0 ra=2.0 slabs=3", // + " edge (25, 1, 1) (24, 6, 1) len=2.7071067811865475 ra=2.7071067811865475 slabs=3", // + " edge (25, 11, 1) (24, 6, 1) len=2.7071067811865475 ra=2.7071067811865475 slabs=3", // + " edge (30, 6, 1) (24, 6, 1) len=3.0 ra=3.0 slabs=3", // + "tree 3: branches=8 junctions=2 endPoints=2 junctionVoxels=16 slabs=12 avg=2.649238080936521 triples=0 quads=0 max=6.802775637731995 lsp=7.60555127546399", // + " vertices=4 edges=8", // + " edge (27, 17, 1) (29, 17, 1) len=1.0 ra=1.0 slabs=1", // + " edge (39, 17, 1) (35, 14, 1) len=2.802775637731995 ra=2.802775637731995 slabs=1", // + " edge (29, 17, 1) (29, 17, 1) len=1.618033988749895 ra=0.0 slabs=1", // + " edge (29, 17, 1) (35, 14, 1) len=6.802775637731995 ra=4.5 slabs=3", // + " edge (29, 17, 1) (29, 17, 1) len=1.618033988749895 ra=0.0 slabs=1", // + " edge (29, 17, 1) (35, 14, 1) len=3.802775637731995 ra=1.5 slabs=3", // + " edge (35, 14, 1) (35, 14, 1) len=0.5 ra=0.0 slabs=1", // + " edge (35, 14, 1) (35, 14, 1) len=3.0495097567963922 ra=0.0 slabs=1", // + "tree 4: branches=1 junctions=0 endPoints=0 junctionVoxels=0 slabs=16 avg=10.606601717798217 triples=0 quads=0 max=10.606601717798217 lsp=0.0", // + " vertices=1 edges=1", // + " edge (1, 19, 1) (1, 19, 1) len=10.606601717798217 ra=8.803314780324513 slabs=16" }, + // A shortest + { "tree 0: branches=1 junctions=0 endPoints=2 junctionVoxels=0 slabs=9 avg=10.0 triples=0 quads=0 max=10.0 lsp=-", // + " vertices=2 edges=1", // + " edge (2, 2, 1) (12, 2, 1) len=10.0 ra=10.0 slabs=9", // + "tree 1: branches=1 junctions=0 endPoints=2 junctionVoxels=0 slabs=13 avg=19.798989873223338 triples=0 quads=0 max=19.798989873223338 lsp=-", // + " vertices=2 edges=1", // + " edge (2, 18, 1) (2, 20, 1) len=19.798989873223338 ra=16.144259608056768 slabs=13", // + "tree 2: branches=3 junctions=1 endPoints=3 junctionVoxels=3 slabs=15 avg=8.227144598981733 triples=1 quads=0 max=9.438793109825918 lsp=-", // + " vertices=4 edges=3", // + " edge (14, 14, 1) (20, 20, 4) len=9.438793109825918 ra=9.301445722753648 slabs=5", // + " edge (20, 27, 4) (20, 20, 4) len=7.0 ra=7.0 slabs=5", // + " edge (27, 20, 7) (20, 20, 4) len=8.242640687119284 ra=7.53338387128521 slabs=5", // + "tree 3: branches=4 junctions=1 endPoints=4 junctionVoxels=5 slabs=12 avg=5.207106781186548 triples=0 quads=1 max=6.0 lsp=-", // + " vertices=5 edges=4", // + " edge (20, 6, 1) (24, 6, 1) len=4.0 ra=4.0 slabs=3", // + " edge (25, 1, 1) (24, 6, 1) len=5.414213562373095 ra=5.414213562373095 slabs=3", // + " edge (25, 11, 1) (24, 6, 1) len=5.414213562373095 ra=5.414213562373095 slabs=3", // + " edge (30, 6, 1) (24, 6, 1) len=6.0 ra=6.0 slabs=3", // + "tree 4: branches=5 junctions=2 endPoints=4 junctionVoxels=8 slabs=13 avg=5.179898987322334 triples=2 quads=0 max=10.242640687119286 lsp=-", // + " vertices=6 edges=5", // + " edge (27, 17, 1) (29, 17, 1) len=2.0 ra=2.0 slabs=1", // + " edge (32, 20, 1) (29, 17, 1) len=4.82842712474619 ra=4.475056592705053 slabs=2", // + " edge (34, 20, 1) (36, 16, 1) len=5.414213562373095 ra=5.0608430303319585 slabs=2", // + " edge (39, 17, 1) (36, 16, 1) len=3.414213562373095 ra=3.414213562373095 slabs=1", // + " edge (29, 17, 1) (36, 16, 1) len=10.242640687119286 ra=7.551151507353894 slabs=7" }, + // A pruneEnds + { "tree 0: branches=1 junctions=0 endPoints=1 junctionVoxels=0 slabs=0 avg=10.0 triples=0 quads=0 max=10.0 lsp=-", // + " vertices=1 edges=0", // + "tree 1: branches=3 junctions=1 endPoints=0 junctionVoxels=3 slabs=0 avg=8.227144598981733 triples=0 quads=0 max=9.438793109825918 lsp=-", // + " vertices=1 edges=0", // + "tree 2: branches=4 junctions=1 endPoints=0 junctionVoxels=5 slabs=0 avg=5.207106781186548 triples=0 quads=0 max=6.0 lsp=-", // + " vertices=1 edges=0", // + "tree 3: branches=8 junctions=2 endPoints=0 junctionVoxels=16 slabs=10 avg=5.298476161873042 triples=0 quads=0 max=13.60555127546399 lsp=-", // + " vertices=2 edges=6", // + " edge (29, 17, 1) (29, 17, 1) len=3.23606797749979 ra=0.0 slabs=1", // + " edge (29, 17, 1) (35, 14, 1) len=13.60555127546399 ra=9.0 slabs=3", // + " edge (29, 17, 1) (29, 17, 1) len=3.23606797749979 ra=0.0 slabs=1", // + " edge (29, 17, 1) (35, 14, 1) len=7.60555127546399 ra=3.0 slabs=3", // + " edge (35, 14, 1) (35, 14, 1) len=1.0 ra=0.0 slabs=1", // + " edge (35, 14, 1) (35, 14, 1) len=6.0990195135927845 ra=0.0 slabs=1", // + "tree 4: branches=1 junctions=0 endPoints=0 junctionVoxels=0 slabs=16 avg=21.213203435596434 triples=0 quads=0 max=21.213203435596434 lsp=-", // + " vertices=1 edges=1", // + " edge (1, 19, 1) (1, 19, 1) len=21.213203435596434 ra=17.606629560649026 slabs=16" }, + // A threshold3 + { "tree 0: branches=1 junctions=0 endPoints=2 junctionVoxels=0 slabs=9 avg=10.0 triples=0 quads=0 max=10.0 lsp=-", // + " vertices=2 edges=1", // + " edge (2, 2, 1) (12, 2, 1) len=10.0 ra=10.0 slabs=9", // + "tree 1: branches=3 junctions=1 endPoints=3 junctionVoxels=3 slabs=15 avg=8.227144598981733 triples=1 quads=0 max=9.438793109825918 lsp=-", // + " vertices=4 edges=3", // + " edge (14, 14, 1) (20, 20, 4) len=9.438793109825918 ra=9.301445722753648 slabs=5", // + " edge (20, 27, 4) (20, 20, 4) len=7.0 ra=7.0 slabs=5", // + " edge (27, 20, 7) (20, 20, 4) len=8.242640687119284 ra=7.53338387128521 slabs=5", // + "tree 2: branches=4 junctions=1 endPoints=4 junctionVoxels=5 slabs=12 avg=5.207106781186548 triples=0 quads=1 max=6.0 lsp=-", // + " vertices=5 edges=4", // + " edge (20, 6, 1) (24, 6, 1) len=4.0 ra=4.0 slabs=3", // + " edge (25, 1, 1) (24, 6, 1) len=5.414213562373095 ra=5.414213562373095 slabs=3", // + " edge (25, 11, 1) (24, 6, 1) len=5.414213562373095 ra=5.414213562373095 slabs=3", // + " edge (30, 6, 1) (24, 6, 1) len=6.0 ra=6.0 slabs=3", // + "tree 3: branches=8 junctions=2 endPoints=1 junctionVoxels=16 slabs=11 avg=5.298476161873042 triples=0 quads=0 max=13.60555127546399 lsp=-", // + " vertices=3 edges=7", // + " edge (39, 17, 1) (35, 14, 1) len=5.60555127546399 ra=5.60555127546399 slabs=1", // + " edge (29, 17, 1) (29, 17, 1) len=3.23606797749979 ra=0.0 slabs=1", // + " edge (29, 17, 1) (35, 14, 1) len=13.60555127546399 ra=9.0 slabs=3", // + " edge (29, 17, 1) (29, 17, 1) len=3.23606797749979 ra=0.0 slabs=1", // + " edge (29, 17, 1) (35, 14, 1) len=7.60555127546399 ra=3.0 slabs=3", // + " edge (35, 14, 1) (35, 14, 1) len=1.0 ra=0.0 slabs=1", // + " edge (35, 14, 1) (35, 14, 1) len=6.0990195135927845 ra=0.0 slabs=1", // + "tree 4: branches=1 junctions=0 endPoints=0 junctionVoxels=0 slabs=16 avg=21.213203435596434 triples=0 quads=0 max=21.213203435596434 lsp=-", // + " vertices=1 edges=1", // + " edge (1, 19, 1) (1, 19, 1) len=21.213203435596434 ra=17.606629560649026 slabs=16" }, + // A lowVoxel + { "tree 0: branches=1 junctions=0 endPoints=2 junctionVoxels=0 slabs=9 avg=10.0 triples=0 quads=0 max=10.0 lsp=-", // + " vertices=2 edges=1", // + " edge (2, 2, 1) (12, 2, 1) len=10.0 ra=10.0 slabs=9", // + "tree 1: branches=1 junctions=0 endPoints=2 junctionVoxels=0 slabs=13 avg=19.798989873223338 triples=0 quads=0 max=19.798989873223338 lsp=-", // + " vertices=2 edges=1", // + " edge (2, 18, 1) (2, 20, 1) len=19.798989873223338 ra=16.144259608056768 slabs=13", // + "tree 2: branches=3 junctions=1 endPoints=3 junctionVoxels=3 slabs=15 avg=8.227144598981733 triples=1 quads=0 max=9.438793109825918 lsp=-", // + " vertices=4 edges=3", // + " edge (14, 14, 1) (20, 20, 4) len=9.438793109825918 ra=9.301445722753648 slabs=5", // + " edge (20, 27, 4) (20, 20, 4) len=7.0 ra=7.0 slabs=5", // + " edge (27, 20, 7) (20, 20, 4) len=8.242640687119284 ra=7.53338387128521 slabs=5", // + "tree 3: branches=4 junctions=1 endPoints=4 junctionVoxels=5 slabs=12 avg=5.207106781186548 triples=0 quads=1 max=6.0 lsp=-", // + " vertices=5 edges=4", // + " edge (20, 6, 1) (24, 6, 1) len=4.0 ra=4.0 slabs=3", // + " edge (25, 1, 1) (24, 6, 1) len=5.414213562373095 ra=5.414213562373095 slabs=3", // + " edge (25, 11, 1) (24, 6, 1) len=5.414213562373095 ra=5.414213562373095 slabs=3", // + " edge (30, 6, 1) (24, 6, 1) len=6.0 ra=6.0 slabs=3", // + "tree 4: branches=5 junctions=2 endPoints=4 junctionVoxels=8 slabs=13 avg=5.179898987322334 triples=2 quads=0 max=10.242640687119286 lsp=-", // + " vertices=6 edges=5", // + " edge (27, 17, 1) (29, 17, 1) len=2.0 ra=2.0 slabs=1", // + " edge (33, 20, 1) (29, 17, 1) len=5.82842712474619 ra=5.369693934628552 slabs=3", // + " edge (35, 20, 1) (36, 16, 1) len=4.414213562373095 ra=4.53224755112299 slabs=1", // + " edge (39, 17, 1) (36, 16, 1) len=3.414213562373095 ra=3.414213562373095 slabs=1", // + " edge (29, 17, 1) (36, 16, 1) len=10.242640687119286 ra=7.551151507353894 slabs=7" }, + // A lowBranch + { "tree 0: branches=1 junctions=0 endPoints=2 junctionVoxels=0 slabs=9 avg=10.0 triples=0 quads=0 max=10.0 lsp=-", // + " vertices=2 edges=1", // + " edge (2, 2, 1) (12, 2, 1) len=10.0 ra=10.0 slabs=9", // + "tree 1: branches=1 junctions=0 endPoints=2 junctionVoxels=0 slabs=13 avg=19.798989873223338 triples=0 quads=0 max=19.798989873223338 lsp=-", // + " vertices=2 edges=1", // + " edge (2, 18, 1) (2, 20, 1) len=19.798989873223338 ra=16.144259608056768 slabs=13", // + "tree 2: branches=3 junctions=1 endPoints=3 junctionVoxels=3 slabs=15 avg=8.227144598981733 triples=1 quads=0 max=9.438793109825918 lsp=-", // + " vertices=4 edges=3", // + " edge (14, 14, 1) (20, 20, 4) len=9.438793109825918 ra=9.301445722753648 slabs=5", // + " edge (20, 27, 4) (20, 20, 4) len=7.0 ra=7.0 slabs=5", // + " edge (27, 20, 7) (20, 20, 4) len=8.242640687119284 ra=7.53338387128521 slabs=5", // + "tree 3: branches=4 junctions=1 endPoints=4 junctionVoxels=5 slabs=12 avg=5.207106781186548 triples=0 quads=1 max=6.0 lsp=-", // + " vertices=5 edges=4", // + " edge (20, 6, 1) (24, 6, 1) len=4.0 ra=4.0 slabs=3", // + " edge (25, 1, 1) (24, 6, 1) len=5.414213562373095 ra=5.414213562373095 slabs=3", // + " edge (25, 11, 1) (24, 6, 1) len=5.414213562373095 ra=5.414213562373095 slabs=3", // + " edge (30, 6, 1) (24, 6, 1) len=6.0 ra=6.0 slabs=3", // + "tree 4: branches=5 junctions=2 endPoints=4 junctionVoxels=8 slabs=13 avg=5.179898987322334 triples=2 quads=0 max=10.242640687119286 lsp=-", // + " vertices=6 edges=5", // + " edge (27, 17, 1) (29, 17, 1) len=2.0 ra=2.0 slabs=1", // + " edge (33, 20, 1) (29, 17, 1) len=5.82842712474619 ra=5.369693934628552 slabs=3", // + " edge (35, 20, 1) (36, 16, 1) len=4.414213562373095 ra=4.53224755112299 slabs=1", // + " edge (39, 17, 1) (36, 16, 1) len=3.414213562373095 ra=3.414213562373095 slabs=1", // + " edge (29, 17, 1) (36, 16, 1) len=10.242640687119286 ra=7.551151507353894 slabs=7" }, + }; + + /** Draws line segments (x0, y0, z0, x1, y1, z1) into a 3D image. */ + static Img draw(final int w, final int h, final int d, + final int[][] segments) + { + final Img img = ArrayImgs.bits(w, h, d); + final RandomAccess ra = img.randomAccess(); + for (final int[] s : segments) { + final int n = Math.max(Math.abs(s[3] - s[0]), Math.max(Math.abs(s[4] - + s[1]), Math.abs(s[5] - s[2]))); + for (int i = 0; i <= n; i++) { + final int x = (int) Math.round(s[0] + (double) i * (s[3] - s[0]) / n); + final int y = (int) Math.round(s[1] + (double) i * (s[4] - s[1]) / n); + final int z = (int) Math.round(s[2] + (double) i * (s[5] - s[2]) / n); + ra.setPositionAndGet(x, y, z).set(true); + } + } + return img; + } + + static Img intensity() { + final Img img = ArrayImgs.unsignedBytes(40, 30, 9); + final Cursor c = img.localizingCursor(); + while (c.hasNext()) { + c.fwd(); + c.get().set((c.getIntPosition(0) * 7 + c.getIntPosition(1) * 13 + c + .getIntPosition(2) * 29) % 251); + } + return img; + } + + private static String pt(final Localizable l) { + return "(" + l.getLongPosition(0) + ", " + l.getLongPosition(1) + ", " + l + .getLongPosition(2) + ")"; + } + + private List describe(final double[] calibration, + final CyclePruning pruning, final Double pruneEnds, + final RandomAccessibleInterval intensity, + final boolean longestShortestPath) + { + final List stats = ops.op( + "topology.skeletonStatistics").input(draw(40, 30, 9, SEGMENTS), + calibration, pruning, pruneEnds, intensity, longestShortestPath) + .outType(new Nil>() + {}).apply(); + final List lines = new ArrayList<>(); + for (int t = 0; t < stats.size(); t++) { + final SkeletonStatistics s = stats.get(t); + lines.add(String.format("tree %d: branches=%d junctions=%d " + + "endPoints=%d junctionVoxels=%d slabs=%d avg=%s triples=%d " + + "quads=%d max=%s lsp=%s", t, s.branches(), s.junctions(), s + .endPointVoxels(), s.junctionVoxels(), s.slabVoxels(), Double + .toString(s.averageBranchLength()), s.triplePoints(), s + .quadruplePoints(), Double.toString(s.maximumBranchLength()), + longestShortestPath ? Double.toString(s.longestShortestPath()) : "-")); + final SkeletonGraph g = s.graph(); + lines.add(" vertices=" + g.vertices().size() + " edges=" + g.edges() + .size()); + for (final SkeletonEdge e : g.edges()) { + lines.add(" edge " + pt(e.v1().points().get(0)) + " " + pt(e.v2() + .points().get(0)) + " len=" + e.length() + " ra=" + e + .runningAverageLength() + " slabs=" + e.slabs().size()); + } + } + return lines; + } + + @Test + public void testNoPruning() { + assertEquals(Arrays.asList(EXPECTED[0]), describe(null, null, null, null, + true)); + } + + @Test + public void testCalibrated() { + assertEquals(Arrays.asList(EXPECTED[1]), describe(new double[] { 0.5, 0.5, + 2 }, null, null, null, true)); + } + + @Test + public void testShortestBranchPruning() { + assertEquals(Arrays.asList(EXPECTED[2]), describe(null, + CyclePruning.SHORTEST_BRANCH, null, null, false)); + } + + @Test + public void testPruneEnds() { + assertEquals(Arrays.asList(EXPECTED[3]), describe(null, null, + Double.POSITIVE_INFINITY, null, false)); + } + + @Test + public void testPruneEndsThreshold() { + assertEquals(Arrays.asList(EXPECTED[4]), describe(null, null, 3.5, null, + false)); + } + + @Test + public void testPruneEndsThenCycles() { + // Note: The original plugin prunes no cycles of the trees with end points + // here, because pruning the end branches removes the root vertex of its + // cycle search, so only the ring is cut and the re-analyzed loop tree + // still has 6 cycles; this port picks a new root. After end pruning alone, + // the loop tree has 5 independent cycles (6 edges between 2 vertices) + // and the ring has 1. + assertEquals(6, cycles(graphs(null, Double.POSITIVE_INFINITY))); + final int remaining = cycles(graphs(CyclePruning.SHORTEST_BRANCH, + Double.POSITIVE_INFINITY)); + assertTrue(remaining < 5, "remaining cycles: " + remaining); + } + + private List graphs(final CyclePruning pruning, + final Double pruneEnds) + { + return ops.op("topology.skeletonGraph").input(draw(40, 30, 9, SEGMENTS), + null, pruning, pruneEnds).outType(new Nil>() + {}).apply(); + } + + /** Counts the independent cycles (edges - vertices + 1) of all trees. */ + private static int cycles(final List graphs) { + int count = 0; + for (final SkeletonGraph g : graphs) + count += g.edges().size() - g.vertices().size() + 1; + return count; + } + + @Test + public void testLowestIntensityVoxelPruning() { + assertEquals(Arrays.asList(EXPECTED[5]), describe(null, + CyclePruning.LOWEST_INTENSITY_VOXEL, null, intensity(), false)); + } + + @Test + public void testLowestIntensityBranchPruning() { + assertEquals(Arrays.asList(EXPECTED[6]), describe(null, + CyclePruning.LOWEST_INTENSITY_BRANCH, null, intensity(), false)); + } +} diff --git a/scijava-ops-image-gpl/src/test/java/org/scijava/ops/image/gpl/skeleton/SkeletonizeTest.java b/scijava-ops-image-gpl/src/test/java/org/scijava/ops/image/gpl/skeleton/SkeletonizeTest.java new file mode 100644 index 000000000..818cc3bf2 --- /dev/null +++ b/scijava-ops-image-gpl/src/test/java/org/scijava/ops/image/gpl/skeleton/SkeletonizeTest.java @@ -0,0 +1,177 @@ +/*- + * #%L + * GPL-licensed image processing operations for SciJava Ops, translated from copyleft ImageJ/Fiji plugins. + * %% + * Copyright (C) 2026 SciJava developers. + * %% + * This program 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. + * + * This program 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 this program. If not, see + * . + * #L% + */ + +package org.scijava.ops.image.gpl.skeleton; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Arrays; +import java.util.Set; +import java.util.TreeSet; + +import net.imglib2.Cursor; +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.img.Img; +import net.imglib2.img.array.ArrayImgs; +import net.imglib2.type.logic.BitType; +import net.imglib2.view.Views; + +import org.junit.jupiter.api.Test; +import org.scijava.ops.image.gpl.AbstractOpTest; +import org.scijava.types.Nil; + +/** + * Tests {@link Skeletonize}. + *

+ * Expected skeletons were produced by running the original Fiji Skeletonize3D + * plugin (source of version 2.1.2-SNAPSHOT, with ImageJ 1.54p) on the same + * synthetic images, from a throwaway harness. + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +public class SkeletonizeTest extends AbstractOpTest { + + /** A cross of two 25x5x5 bars, centered at (15, 15, 5). */ + static Img bars() { + final Img img = ArrayImgs.bits(32, 32, 12); + final Cursor c = img.localizingCursor(); + while (c.hasNext()) { + c.fwd(); + final int x = c.getIntPosition(0), y = c.getIntPosition(1), z = c + .getIntPosition(2); + c.get().set(z >= 3 && z < 8 && ((x >= 3 && x < 28 && y >= 13 && + y < 18) || (y >= 3 && y < 28 && x >= 13 && x < 18))); + } + return img; + } + + /** A disk of radius 9 with a rectangular tail. */ + static Img blob2D() { + final Img img = ArrayImgs.bits(30, 30); + final Cursor c = img.localizingCursor(); + while (c.hasNext()) { + c.fwd(); + final int x = c.getIntPosition(0), y = c.getIntPosition(1); + final boolean disk = (x - 12) * (x - 12) + (y - 12) * (y - 12) <= 81; + final boolean tail = x >= 18 && x < 28 && y >= 20 && y < 25; + c.get().set(disk || tail); + } + return img; + } + + private RandomAccessibleInterval skeletonize( + final RandomAccessibleInterval in) + { + return ops.op("morphology.skeletonize").input(in).outType( + new Nil>() + {}).apply(); + } + + private static Set foreground( + final RandomAccessibleInterval img) + { + final Set set = new TreeSet<>(); + final Cursor c = Views.flatIterable(img).localizingCursor(); + while (c.hasNext()) { + c.fwd(); + if (c.get().get()) set.add(Arrays.toString(c.positionAsLongArray())); + } + return set; + } + + @Test + public void testBars3D() { + // The skeleton of the cross is its center line, shortened by two voxels + // at each end; the original plugin gives the same result. + final Set expected = new TreeSet<>(); + for (int i = 5; i <= 25; i++) { + expected.add(Arrays.toString(new long[] { i, 15, 5 })); + expected.add(Arrays.toString(new long[] { 15, i, 5 })); + } + final RandomAccessibleInterval out = skeletonize(bars()); + assertArrayEquals(new long[] { 32, 32, 12 }, out.dimensionsAsLongArray()); + assertEquals(expected, foreground(out)); + } + + @Test + public void testBlob2D() { + final int[][] expectedPoints = { { 12, 12 }, { 12, 13 }, { 12, 14 }, { 13, + 15 }, { 14, 16 }, { 15, 17 }, { 16, 18 }, { 17, 19 }, { 18, 20 }, { 19, + 21 }, { 20, 22 }, { 21, 22 }, { 22, 22 }, { 23, 22 }, { 24, 22 }, { 25, + 22 }, { 26, 22 } }; + final Set expected = new TreeSet<>(); + for (final int[] p : expectedPoints) + expected.add(Arrays.toString(new long[] { p[0], p[1] })); + final RandomAccessibleInterval out = skeletonize(blob2D()); + assertEquals(2, out.numDimensions()); + assertEquals(expected, foreground(out)); + } + + @Test + public void testIdempotentAndTranslated() { + final RandomAccessibleInterval translated = Views.translate( + blob2D(), 100, -50); + final RandomAccessibleInterval once = skeletonize(translated); + assertArrayEquals(new long[] { 100, -50 }, once.minAsLongArray()); + final RandomAccessibleInterval twice = skeletonize(once); + assertEquals(17, foreground(once).size()); + assertEquals(foreground(once), foreground(twice)); + assertEquals(foreground(Views.translate(skeletonize(blob2D()), 100, -50)), + foreground(once)); + } + + @Test + public void testSolidBallShrinksToPoint() { + final Img img = ArrayImgs.bits(11, 11, 11); + final Cursor c = img.localizingCursor(); + while (c.hasNext()) { + c.fwd(); + double r2 = 0; + for (int d = 0; d < 3; d++) + r2 += Math.pow(c.getIntPosition(d) - 5, 2); + c.get().set(r2 <= 16); + } + // Note: The ball thins down to (at most) two voxels next to its center. + final RandomAccessibleInterval out = skeletonize(img); + final Set skeleton = foreground(out); + assertTrue(skeleton.size() >= 1 && skeleton.size() <= 2, skeleton + .toString()); + final Cursor sc = Views.flatIterable(out).localizingCursor(); + while (sc.hasNext()) { + if (!sc.next().get()) continue; + for (int d = 0; d < 3; d++) + assertTrue(Math.abs(sc.getIntPosition(d) - 5) <= 1); + } + } + + @Test + public void testRejectsOtherDimensionalities() { + assertThrows(RuntimeException.class, () -> skeletonize(ArrayImgs.bits(4))); + assertThrows(RuntimeException.class, () -> skeletonize(ArrayImgs.bits(3, 3, + 3, 3))); + } +} diff --git a/scijava-ops-image-gpl/src/test/resources/org/scijava/ops/image/gpl/biovoxxel/limited-mean-expected.txt b/scijava-ops-image-gpl/src/test/resources/org/scijava/ops/image/gpl/biovoxxel/limited-mean-expected.txt new file mode 100644 index 000000000..eb05fed3e --- /dev/null +++ b/scijava-ops-image-gpl/src/test/resources/org/scijava/ops/image/gpl/biovoxxel/limited-mean-expected.txt @@ -0,0 +1,20 @@ +0 true false 000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001111100000000000000000000000000111111100000000000111111100000001111111000000000011111111100000011111110000000001111111111100000111111100000000011111111111000001111111000000000111111111110000001111100000000001111111111100000000000000000000011111111111000000000000000000000111111111110000000000000000000001111111111100000000000000000000001111111110000000000000000000000001111111000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +0 false false 000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001111100000000000000000000000000111111100000000000111111100000001111111000000000011111111100000011111110000000001111111111100000111111100000000011111111111000001111111000000000111111111110000001111100000000001111111111100000000000000000000011111111111000000000000000000000111111111110000000000000000000001111111111100000000000000000000001111111110000000000000000000000001111111000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +0 true true 111110000000111100000001111000001110010000001100100000011001000011011100000010111000000101110000001111000000011110000000111100000000001111000000011110000000111100000010011100000100110000001001000000011111000000111000000001111111100000000011000000000000000011100100000000001000000000000000001110000000000010000000000000000000000000000000011110000000111100000000000000000111010000001110000000000000000001001110000010011111100000000000000000011110000011100000000000001000000110000000110110000000000010000001011100000011110000000001100000001111000000000010000000000111011000001110000000100111000001001110000010010000000111110000001111100000011111101000000011010000000110100000110111000000101110000001011100000011110000000111100000001111000000000011110000000111100000001111 +0 false true 111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111 +1 true false 000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001111100000000000000000000000000111111100000000000111111100000001111111000000000011111111100000011111110000000001111111111100000111111100000000011111111111000001111111000000000111111111110000001111100000000001111111111100000000000000000000011111111111000000000000000000000111111111110000000000000000000001111111111100000000000000000000001111111110000000000000000000000001111111000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +1 false false 000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001111100000000000000000000000000111111100000000000111111100000001111111000000000011111111100000011111110000000001111111111100000111111100000000011111111111000001111111000000000111111111110000001111100000000001111111111100000000000000000000011111111111000000000000000000000111111111110000000000000000000001111111111100000000000000000000001111111110000000000000000000000001111111000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +1 true true 000000001111000000011110000000110000000001110000000011100000100100000011100100000111001000001110000000111000000001110000000011100011110000000111100000001111000011100100000011001000000000010000111010000000110100000000000000000000000000000000001110000000011100000000000000000100100000001001000000000000000001111000000011110001100000000000100000000000000010011000000000001000000000010000111000000000000000000001100100000000000000000000000111100000001100000000000000000000111000000001000000000000000001110100000011100000000000000000011110000000111101011100000000111000000101110000111001000000110010000001100100001111100000001111000000011110000000000001011100000010111000000101000000111001000001110010000011100000001111000000011110000000111100111100000001111000000011110000 +1 false true 000000001111000000011110000000110000000001110000000011100000000100000011100000000111000000001110000000111000000001110000000011100001110000000011100000000111000011100000000011000000000000010000111010000000110000000000000000000000000000000000001010000000010100000000000000000100100000001001000000000000000001111000000011110001100000000000100000000000000010011000000000001000000000010000111000000000000000000001100000000000000000000000000111100000001100000000000000000000111000000001000000000000000001110100000011100000000000000000011100000000111001011100000000111000000101110000111001000000110010000001100100001111000000001110000000011100000000000001011100000010111000000101000000111001000001110010000011100000001111000000011110000000111100111100000001111000000011110000 +2 true false 000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000110000000000000000000000000000001111000000000000111110000000001100000000000000010011100000000011000000000000000111100000000000000000000000000010000000111000000001111000000000100000000110000000101100000000001000000110000000000000000000000000111110000000000000000000000000010011100000000000000000000000000111011000000000000000000000000001111000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +2 false false 000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000111100000000000000000000000000001111100000000000111110000000001100111000000000011111100100000011001110000000000111111110000000111100000000000010001111111000001111111000000000110011111110000001111100000000001111000111100000000000000000000011111110011000000000000000000000111111100110000000000000000000000111111110000000000000000000000001111111100000000000000000000000000011111000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +2 true true 000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000110000000000000000000000000000001111000000000000111110000000001100000000000000010011100000000011000000000000000111100000000000000000000000000010000000111000000001111000000000100000000110000000101100000000001000000110000000000000000000000000111110000000000000000000000000010011100000000000000000000000000111011000000000000000000000000001111000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +2 false true 000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000111100000000000000000000000000001111100000000000111110000000001100111000000000011111100100000011001110000000000111111110000000111100000000000010001111111000001111111000000000110011111110000001111100000000001111000111100000000000000000000011111110011000000000000000000000111111100110000000000000000000000111111110000000000000000000000001111111100000000000000000000000000011111000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +3 true false 000000000000000000011110011111110000000000000000000011100111111100000000000000000011111110111111000000000000000001111111111111110000000000000000000000011111111100000000000000000000000111111111000000000000000000000001111111110000000000000000000111101111111100000000000000000000111001111111000000000000000001011111111111110000000000000000000000111111111100000000000000000000000111111111000000000000000000000001111111110000000000000000000111100111111100000000000000000000111001111111000000000000000000110110101111110000000000000000011111111111111100000000000000000000000111111111000000000000000000000001111111110000000000000000000000011111111100000000000000000000111011111111000000000000000000110111101111110000000000000000000111111111111100000000000000000000001111111111 +3 false false 000000000000000000011110011111110000000000000000000011100111111100000000000000000011111110111111000000000000000001111111111111110000000000000000000000011111111100000000000000000000000111111111000000000000000000000001111111110000000000000000000111101111111100000000000000000000111001111111000000000000000001011111111111110000000000000000000000111111111100000000000000000000000111111111000000000000000000000001111111110000000000000000000111100111111100000000000000000000111001111111000000000000000000110110101111110000000000000000011111111111111100000000000000000000000111111111000000000000000000000001111111110000000000000000000000011111111100000000000000000000111011111111000000000000000000110111101111110000000000000000000111111111111100000000000000000000001111111111 +3 true true 111110000000000000000000000000001111110000000000000000000000000011111100000000000000000000000000111111000000000000000000000000001100001000000000000000000000000010010000000000000000000000000000000000000000000000000000000000001111110000000000000000000000000011111100000000000000000000000000111111000000000000000000000000001110001000000000000000000000000011100011100000000000000000000000100000000000000000000000000000001111100000000000000000000000000011111100000000000000000000000000111111000000000000000000000000001111110000000000000000000000000011100011000000000000000000000000100100100000000000000000000000000000000000000000000000000000000011111000000000000000000000000000111111000000000000000000000000001111110000000000000000000000000011000010000000000000000000000000 +3 false true 111110000000000000000000000000001111110000000000000000000000000011111100000000000000000000000000111111000000000000000000000000001100001000000000000000000000000010010000000000000000000000000000000000000000000000000000000000001111110000000000000000000000000011111100000000000000000000000000111111000000000000000000000000001110001000000000000000000000000011100011100000000000000000000000100000000000000000000000000000001111100000000000000000000000000011111100000000000000000000000000111111000000000000000000000000001111110000000000000000000000000011100011000000000000000000000000100100100000000000000000000000000000000000000000000000000000000011111000000000000000000000000000111111000000000000000000000000001111110000000000000000000000000011000010000000000000000000000000 +4 true false 111100000000111000000001110000001110000000001100000000011000000000011100000000111000000001110000000111000000001110000000011100000000001110000000011100000000111000000000011100000000110000000001000000010111000000101000000000011110100000000001000000000000000011100100000000001000000000000000001110000000000010000000000000000000000000000000011100000000111000000000000000000111000000001110000000000000000000001110000000011111000000000000000000011100000011100000000000000000000110000000010110000000000010000001011100000001110000000001100000000111000000000010000000000111010000001110000000100111000001001110000010010000000011110000000111100000001111101000000011010000000110100000100111000000001110000000011100000011110000000111100000001111000000000011110000000111100000001111 +4 false false 111111000000111110000001111100001111110000001111100000011111000011111100000011111000000111110000111111000000111110000001111100000000001111110000011111100000111100000011111100000111110000001111000000111111000001111000000011111111110000000011100000000000000011111100000000011000000000000000111110000000000010000000000000000000000000000000011110000000111100000000000000000111110000001111000000000000000001111110000011111111100000000000100000011111000011111000000000001000000111110000111110000000000010000001111100001111110000000001100000011111000000000010000000000111111000001111000000111111000001111110000011110000001111110000011111100000111111111100000011111000000111110000111111000000111110000001111100001111110000001111100000011111000000000011111100000111111000001111 +4 true true 111100000000111000000001110000001110000000001100000000011000000000011100000000111000000001110000000111000000001110000000011100000000001110000000011100000000111000000000011100000000110000000001000000010111000000101000000000011110100000000001000000000000000011100100000000001000000000000000001110000000000010000000000000000000000000000000011100000000111000000000000000000111000000001110000000000000000000001110000000011111000000000000000000011100000011100000000000000000000110000000010110000000000010000001011100000001110000000001100000000111000000000010000000000111010000001110000000100111000001001110000010010000000011110000000111100000001111101000000011010000000110100000100111000000001110000000011100000011110000000111100000001111000000000011110000000111100000001111 +4 false true 111111000000111110000001111100001111110000001111100000011111000011111100000011111000000111110000111111000000111110000001111100000000001111110000011111100000111100000011111100000111110000001111000000111111000001111000000011111111110000000011100000000000000011111100000000011000000000000000111110000000000010000000000000000000000000000000011110000000111100000000000000000111110000001111000000000000000001111110000011111111100000000000100000011111000011111000000000001000000111110000111110000000000010000001111100001111110000000001100000011111000000000010000000000111111000001111000000111111000001111110000011110000001111110000011111100000111111111100000011111000000111110000111111000000111110000001111100001111110000001111100000011111000000000011111100000111111000001111 diff --git a/scijava-ops-image-gpl/src/test/resources/org/scijava/ops/image/gpl/directionality/directionality-expected.txt b/scijava-ops-image-gpl/src/test/resources/org/scijava/ops/image/gpl/directionality/directionality-expected.txt new file mode 100644 index 000000000..daf45805f --- /dev/null +++ b/scijava-ops-image-gpl/src/test/resources/org/scijava/ops/image/gpl/directionality/directionality-expected.txt @@ -0,0 +1,36 @@ +case 0 FOURIER_COMPONENTS 90 -90.0 90.0 +fit 1.7535679053986625E-4 0.26360609331146323 30.04238909184276 3.014514467768242 0.9293962856577002 0.8648870069374747 +hist 7.98879077645662E-6 1.7790007771649287E-6 3.7897783168233535E-6 2.1423972136445315E-6 5.827174967897287E-6 1.4274761719660015E-5 8.117701827862921E-6 8.585121519168163E-6 2.3131675071627206E-5 7.801947924120981E-5 4.808964399700005E-5 5.6707742171225577E-5 4.793317171694368E-5 9.271008784089199E-5 2.33668092171152E-4 3.2871665555695307E-4 7.892101082826871E-5 2.287554823553208E-5 2.3006074243391206E-5 3.350709049709211E-5 7.410686977104391E-5 6.215561893975401E-5 4.185745805309348E-5 1.8390002816555954E-5 2.265690807107172E-5 4.4197807634886896E-5 1.2836696498739417E-4 4.034994435239601E-4 8.800899205408425E-4 0.0027047393982342527 0.0012251240822991692 9.427605062988629E-4 4.5965312560039475E-4 2.1514788216429282E-4 8.233884160627604E-5 7.931355697644328E-5 3.56901827954648E-5 3.307812547519309E-5 5.18351850736953E-5 4.184751050911954E-5 9.659697183451726E-6 3.7174224883734357E-6 9.25811942553392E-6 6.7919957270897844E-6 5.1787691362752506E-5 5.1857085165251325E-5 7.161396402079491E-6 9.362004019454064E-6 5.09785222560758E-6 8.129751431512631E-6 5.0417657263992196E-5 7.381239947250781E-5 8.5342732392764E-5 3.24728819336096E-4 3.581915778450101E-4 0.012591471961865386 0.022462875705609578 0.06357336995245162 0.14848587799502824 0.33418998417001294 0.13755973304613694 0.22576180634544515 0.019825514148625295 0.02308351589156497 6.07753427188879E-4 6.465417513066516E-4 3.642246440113057E-5 1.0152126923931511E-4 1.576182015384336E-4 1.2729288081143314E-4 6.799303097063294E-5 3.862520572432669E-5 1.9225773510012728E-5 5.818805371278933E-5 2.8746069959738915E-4 2.0118203204456833E-4 8.072087039638986E-5 6.177551719562492E-5 7.814820512699103E-5 5.2819603350713375E-5 5.9260094340685266E-5 1.3710979744319017E-5 8.53969026643355E-6 7.512163341684454E-6 1.231903902594995E-5 3.5510525354808984E-6 1.998215408306572E-6 3.958924004585103E-6 1.7662558974244147E-6 7.98879077645662E-6 +case 0 FOURIER_COMPONENTS 30 -45.0 135.0 +fit 3.199838842186701E-4 0.6316212469544701 30.189291042354256 3.8821142696499136 0.9828770514804934 0.9999200532528988 +hist 1.1306742943184213E-4 3.6776233804272044E-4 0.004013732395359586 0.002523122245537929 2.935171302407075E-4 1.2854568180973325E-4 3.6406086030670364E-5 9.876473036162985E-5 3.5255777204585114E-5 1.241303935620784E-4 0.002749136689729363 0.12975754950576515 0.6211552951853375 0.23196420678939075 0.003913725265999029 3.570984361116171E-4 1.5048162542602734E-4 5.437951713004254E-4 2.1520992234506353E-4 1.0196229146381602E-4 2.4869973049249162E-5 1.0061059478335477E-5 1.2069668717820714E-5 2.066466841592192E-5 5.020649114750485E-5 1.7041181308203736E-4 4.156849434067232E-4 3.9722491969960104E-4 1.429739431210025E-4 1.1306742943184213E-4 +case 0 LOCAL_GRADIENT_ORIENTATION 90 -90.0 90.0 +fit 4.4758268557683426E-4 0.21413782474455878 25.222180893943964 3.623692304386373 0.9069920521112377 0.918068749610776 +hist 7.616226437156621E-6 3.936808975827309E-6 3.586849856386972E-6 2.745053760064082E-5 7.011322571234206E-7 1.2101304547522898E-6 1.5938332738819515E-5 2.560168032657575E-7 6.0364761817818E-6 1.282870663532089E-5 0.0 0.0 4.380432998784473E-6 1.5396707673763315E-5 1.4496713889483274E-5 1.111405494949817E-6 3.1661345532688786E-5 6.700796055326151E-5 0.0 6.906131585944919E-6 7.725040062647654E-6 6.875623168871036E-7 2.2237613832860772E-5 1.5369327843693783E-5 0.0 1.2470633944030575E-5 9.755127930742904E-6 2.063984910573845E-5 1.727833893551486E-5 7.3611086310966916E-6 1.0101780201205469E-5 9.31896795037623E-6 1.0097939089453347E-5 9.164484470484804E-6 7.5594336285132965E-6 0.0 2.7726493296997242E-6 1.5144955835803469E-5 9.034291295220848E-6 1.3167350965814526E-5 1.171788677316874E-5 5.420201471826983E-5 2.5450233563451777E-5 1.6777165024572118E-5 1.0810841164941341E-4 2.603354283756306E-5 1.4244348072209217E-4 4.533062154236298E-4 4.552714777227451E-4 0.0023125662640801964 0.011133336898549666 0.014754958379139238 0.005447762142281816 0.006920431412969671 0.02744146885495508 0.1116133861372042 0.251444996285406 0.14569661229707145 0.19637656405710385 0.13788055392811016 0.03653847055138689 0.014364401728048194 0.0076019804543929065 0.002896156672276821 0.0019954647036088072 0.0012727677480461022 0.003857940903143757 0.003085852018923874 0.001985539429993943 0.004498067862475189 0.0019107082320690225 0.002289502238143334 0.0024121979218546174 8.456906991644126E-5 8.107675518193842E-4 5.755469534785168E-4 2.1664139667234406E-4 6.629956704234574E-4 1.3982283254487698E-4 2.403102885761129E-5 6.8789230571016605E-6 2.9044087457312363E-6 9.244902694350526E-6 5.137607321087371E-6 1.1346413175902716E-5 3.1894826430245104E-7 4.388372319446439E-6 0.0 0.0 0.0 +case 0 LOCAL_GRADIENT_ORIENTATION 30 -45.0 135.0 +fit 0.002273869925660188 0.602271067698766 23.498491279498396 3.8415846134291307 0.9301464287787022 0.9971609458655809 +hist 4.034578520042474E-5 4.300674947291413E-5 2.451697730397922E-5 3.6140825138827677E-5 6.522208964875735E-6 4.0982430654617316E-5 8.96602017384061E-5 2.697846874748768E-4 0.0025379393728740857 0.031943045222101174 0.1485490932044956 0.6017220707930921 0.17987526478111446 0.011009125879075547 0.00792561233097432 0.0099427386480717 0.0037543834205397 0.0012142006057186945 7.005562091147271E-4 1.83838920551461E-5 2.0346988326692575E-5 5.675808686711055E-6 3.6914614183300666E-5 1.7849595450695226E-5 1.9121199620368448E-5 4.380432998784473E-6 3.320086035898443E-5 9.647327278516228E-5 2.2663002413045088E-5 0.0 +case 1 FOURIER_COMPONENTS 90 -90.0 90.0 +fit 0.0032329125026806 0.12719143601455637 -50.10615419232742 4.61514769726927 0.7082579459623959 0.5319245882326303 +hist 6.427316647783893E-5 2.4729722888998087E-6 7.408690943200083E-6 1.2313264501141183E-5 2.747311031633967E-5 2.5816134874066927E-5 3.32237080330051E-5 6.15252339053091E-5 7.085104008005362E-5 1.5027861587443335E-4 1.625047503836288E-4 2.0354244840618802E-4 4.4878179980544777E-4 0.00434910888471781 0.004798761871539476 0.011462345271763846 0.051866402464849734 0.04349488978090351 0.1470950112819732 0.02391094933767229 0.24127347115179942 0.015993731940430345 0.13618985971953967 0.02341235177948898 0.02502127850573888 0.016618813588998263 0.002127124778709493 0.003300649760167479 0.002030437156768915 0.0038716278046169923 0.002842958534416071 0.002163089597465524 0.001339246886558586 7.191238825013875E-4 3.45341342409208E-4 2.683577535483557E-4 1.0702178158267708E-4 1.1466299330590782E-4 1.1184491502362013E-4 9.141244856662105E-5 3.634977077992245E-5 2.044408975134742E-5 2.985271063964793E-5 2.2317858586475042E-5 1.183680551577133E-4 1.1834766319635028E-4 2.7851188693027584E-5 5.62087458397946E-5 2.913884335705054E-5 2.7481319224932215E-4 5.145350718956985E-4 0.014391731164678808 0.01155166429683993 0.04377913370510327 0.04888847310289508 0.038313786963363544 0.03917665255332621 0.01527171722228109 0.006869594893066472 0.0038367870906276646 0.0033831861006311495 0.0020474695213958906 0.0012462160618344398 5.582104503363888E-4 1.5186535161017644E-4 1.099565446179027E-4 6.804114603322125E-5 1.4942623419324648E-4 2.070533697813458E-4 1.7494923187145138E-4 1.1014143913229337E-4 1.0540647043162984E-4 1.0265080127134545E-4 2.5837813758185804E-4 4.8567501196153005E-4 4.052406831228915E-4 2.2148059144055197E-4 1.1481225238061817E-4 1.1388112170496959E-4 9.997749002677374E-5 1.1968396737436784E-4 5.1445434657239135E-5 4.4906993138327083E-5 2.3481142772777688E-5 3.228947176955759E-5 1.3117200781994502E-5 8.668386456520953E-6 7.013842046241378E-6 2.994049891674542E-6 6.427316647783893E-5 +case 1 FOURIER_COMPONENTS 30 -45.0 135.0 +fit 0.01559994744500041 0.2860216868311682 129.92687263890465 5.090935490803582 0.5737052243145853 0.6569938647877087 +hist 0.18088024863684232 0.030144336201026835 0.007221720026103028 0.004807910742399707 8.681837647942968E-4 2.7132695266902403E-4 9.36659405730854E-5 1.9362229092835414E-4 1.4502690291440476E-4 0.010690266076652275 0.08579888484230348 0.07703726879984515 0.01182447970115823 0.003065298837866498 3.71328275800421E-4 4.066964924851904E-4 3.4251272181961087E-4 9.168611330314185E-4 3.362372552341163E-4 1.972713675344928E-4 6.811928458710318E-5 2.6731952717737772E-5 5.320891498197153E-5 5.318801369951853E-5 1.4468342017306962E-4 4.771682074950649E-4 0.009858528929778322 0.11470547821125672 0.27811949746648623 0.18088024863684232 +case 1 LOCAL_GRADIENT_ORIENTATION 90 -90.0 90.0 +fit 0.00505821497271145 0.05612770558806927 -37.55704754108112 8.60670207002682 0.5926183592459674 0.7458810480363738 +hist 0.008254693191457949 0.00672997370455364 0.00959741171569973 0.015148215681836828 0.010735062984277302 0.01707738620411564 0.01850054891943495 0.01658062245861711 0.022995921496853085 0.021966799375092754 0.026316677789378302 0.024685528963160147 0.02384593745378588 0.02075033172013146 0.015776676656960375 0.020464928150856373 0.020067995511701724 0.02375916615253637 0.022393697426925808 0.028236535386992515 0.025364250893707366 0.025205144560351528 0.0331155699916865 0.03317483400856314 0.037110486908578356 0.05772940273577119 0.05797778711935677 0.06528887388077148 0.06358845079568895 0.0519130034107511 0.03546235629052432 0.02390319396008913 0.011498321413517696 0.011328194484396587 0.009328255978295043 0.010490741841648887 0.004937636448923185 0.004330275149076236 0.004411996985502171 0.005276130740439414 0.0020774421313378786 0.0016086103234023857 0.001009659874371814 0.0012413363547447201 0.0018301987591605867 0.0017781290382157182 0.0011513483878304467 7.95377017120999E-4 0.001101209869495398 9.14544904479414E-4 8.364418257705004E-4 9.829482415115707E-4 6.953729867357418E-4 3.440276182117985E-4 6.974789175960084E-4 5.11545882330798E-4 6.964438549344106E-4 4.261574197464195E-4 3.4656530039494385E-4 2.3435196951976955E-4 2.9494484999255524E-4 2.4449366942838656E-4 2.4171973442116133E-4 4.5742505985545747E-4 4.8375117888550606E-4 5.54948954889371E-4 5.407720907752481E-4 6.264357726030086E-4 6.784742194184975E-4 9.185663599139629E-4 6.183648246744236E-4 9.918642557373558E-4 8.510456728188884E-4 6.896815101183448E-4 6.832935124720679E-4 5.057946063907186E-4 8.908369664502483E-4 0.0010424883657239852 4.8408022519134643E-4 7.620333094773816E-4 0.0014272598985190755 0.0011375615325119998 0.0015646977252379106 0.0018194192291484904 0.0021403647994960697 0.0030606115826062098 0.0025415825127715194 0.003429601663957454 0.005719676697595036 0.0 +case 1 LOCAL_GRADIENT_ORIENTATION 30 -45.0 135.0 +fit 0.019823495587524595 0.19500850056531593 -38.055211087945324 5.958303790876533 0.4790214397000413 0.6717816392290459 +hist 0.11067266688963602 0.19054660208070762 0.13953636613475537 0.038265804594942304 0.023046480496384567 0.013168636615314203 0.003939368201884639 0.00479652666067191 0.0028734434729940166 0.0026381901381179974 0.0015530524181386048 0.001536333290245689 7.066237737707963E-4 0.001328508665648912 0.0019526494860162133 0.0024737961444889645 0.0020538729912448132 0.0023738567992399027 0.002651778462935102 0.004435445807482611 0.007866582255794018 0.015884116273864088 0.03064455496963155 0.048064050818510805 0.06206060871634124 0.07660138566598684 0.058493291036807664 0.06759406298426036 0.08224134415418316 0.0 +case 2 FOURIER_COMPONENTS 90 -90.0 90.0 +fit 0.00755123604629448 0.07557116575118003 30.23828976598442 3.800437996201162 0.3742170763684795 0.4383632921750752 +hist 1.0772699667721035E-4 6.272823130258107E-5 1.5086738978785152E-4 2.2892696337273882E-4 3.4664645742568114E-4 6.263850667229397E-4 6.712732343337402E-4 0.0021026271012963864 0.0022259057955325377 0.003868914207720245 0.0036016169938067203 0.0036790504493411755 0.003648019200647098 0.006037343667233683 0.009830180101611876 0.01112999597943908 0.007349839808653395 0.003411909780893881 0.002177687273556024 0.0021926624241393965 0.004345507819654611 0.005862163140232759 0.005733732607470851 0.0027124797234200798 0.006512728032327208 0.006362929634498302 0.02015391504466831 0.03337426643377689 0.050440522874078256 0.07678890445862283 0.0682855452033393 0.05399581503115782 0.037007990178680596 0.023226252845340935 0.014753269844801539 0.009951712916195352 0.004902525899287016 0.0040889434798707346 0.003050442909508844 0.0019173803933919913 7.133916297982625E-4 5.30081947059837E-4 8.900814510133514E-4 5.851322561315677E-4 0.001872326119672092 0.0018719678234731083 5.908758201759774E-4 8.936241956250552E-4 5.481588472611299E-4 8.098417865908753E-4 0.001965822741122657 0.0026465794327599408 0.0028790655218274112 0.003047335020609393 0.008599315269951911 0.0137412867802659 0.02317245471236217 0.037516774389580454 0.055456327721636424 0.07068801141613469 0.07929015734892182 0.05232525860042373 0.03446135349475033 0.02130673868466986 0.006707432493154349 0.006548136684836875 0.0020248908868717294 0.00414438334106682 0.003646658540416641 0.0033164528515412296 0.0020473258387344864 0.0025361045159311836 0.0039251405359364945 0.007716535696151686 0.011449150962125608 0.010158963022747181 0.0064325756730315925 0.0033717946968584442 0.0023554634196675337 0.0022760366699740645 0.0027856775780200336 0.0017770938404716497 0.0015173760200690655 5.993573178636438E-4 6.010290644832332E-4 3.098779356395075E-4 1.8109409666054754E-4 9.412220517618614E-5 5.030051025649899E-5 1.0772699667721035E-4 +case 2 FOURIER_COMPONENTS 30 -45.0 135.0 +fit 0.022785170519656374 0.19866778768593876 29.99176083828929 4.454825849275437 0.38044078609796056 0.38216667561669815 +hist 0.014805910528807904 0.04282474086955541 0.16711219368890126 0.1462293910771242 0.03913333214323054 0.010339555115555897 0.002640594537166154 0.004066234144446673 0.0023986040501420055 0.0051087050078436795 0.01481429177357367 0.08160700094852869 0.19620257144219733 0.10263121370723455 0.018273219573744923 0.009799302667445947 0.011335032271028762 0.028184572093353892 0.010499709686535571 0.006322778775359853 0.0020475206142451623 4.378987124817161E-4 3.037980692299113E-4 0.0011233969166062857 0.005144861812993051 0.010778172783099447 0.02029096649849448 0.02062613982889023 0.010112380133374914 0.014805910528807904 +case 2 LOCAL_GRADIENT_ORIENTATION 90 -90.0 90.0 +fit 0.003973932861831426 0.05578525678936643 -1.1768075483699827 10.00315336039871 0.6870680652218175 0.6612262048387479 +hist 0.018087967772046576 0.005818297928237364 0.02300745197818128 0.003712523953418234 0.004747968368552586 0.00795408016088617 0.004991412867187765 0.0012585020848685036 0.0027027412041950682 3.976394626185738E-4 0.006301402988088469 8.63788496948805E-4 7.595625047831015E-5 0.0016424007843528023 0.004208935032774186 0.002062315679141378 0.00548851935300063 0.005227085227098669 0.005743065609374629 0.008085719645416422 0.0066340648761231006 0.0018748367001072672 0.002950410380287322 0.003679584751954379 0.004279895559350992 0.00407352967644577 0.0013236221284331124 0.002712178490763304 0.004799771214685211 0.001681089817716168 0.0010149770631066868 3.4480212345448303E-4 6.380373203775249E-4 4.4719480187695816E-4 0.0029000933788832298 0.009324769758072038 0.01044621085868637 0.005623493434341756 0.020235363865629147 0.0616860530545255 0.08658740162649997 0.0584006676441897 0.035807610037237265 0.044167803875151665 0.03266342637910356 0.037903048080375173 0.038886087402364884 0.05278937895155738 0.07212438536169348 0.06639366796268957 0.02755929891476446 0.011035400413985626 0.002264789094192359 0.013169208506757561 0.0020188803531656957 3.751677331813754E-4 8.039630327045136E-4 8.690690489087615E-4 0.0026732900547899894 0.0019190808925211354 0.005306463949782663 0.002631658725629034 6.732902766978111E-4 0.004637655973616564 0.003270227465637998 0.0033355926918072587 0.0031338649463574925 0.0021029580157336247 0.0028925143924614 0.0034342056664168775 0.007513798509215367 0.007898723432544566 0.0067864983703454914 0.005672043455427599 0.002608507195943985 2.1126241007492395E-4 0.0012549109305048226 4.0489883470978436E-4 0.0076517333419963696 0.0027007417491278197 0.002399650066705318 0.0011865934046952685 0.010820215623708997 0.005449143740258064 0.005710470681365185 0.006222121312328709 0.028637127535341346 0.0030378086732884605 0.014955939160756323 0.0 +case 2 LOCAL_GRADIENT_ORIENTATION 30 -45.0 135.0 +fit 0.012156445930986545 0.1693056878276902 -2.9990138339391645 10.010521353755554 0.6753514722379129 0.8069530182257211 +hist 0.011894619494946121 0.00858111453048678 0.005674714262461349 0.0025465654706445656 0.025436526265405345 0.13621528005912034 0.15338096780412605 0.11828234620877007 0.188339065606487 0.05369728629400404 0.015727659937094227 0.004181918792413672 0.009857203567932833 0.009522151544215018 0.008869241354144559 0.015886481575126506 0.018616302324076454 0.0029367757699330314 0.012717074030443058 0.01653136593108576 0.0334770454574636 0.04272539068228537 0.03213059995058336 0.019509772266220696 0.004358882751682146 0.007241147735515584 0.00835091373516657 0.017383384235614096 0.01592820236255181 0.0 diff --git a/scijava-ops-image-gpl/src/test/resources/org/scijava/ops/image/gpl/mcib3d/spots-expected.txt b/scijava-ops-image-gpl/src/test/resources/org/scijava/ops/image/gpl/mcib3d/spots-expected.txt new file mode 100644 index 000000000..163daede5 --- /dev/null +++ b/scijava-ops-image-gpl/src/test/resources/org/scijava/ops/image/gpl/mcib3d/spots-expected.txt @@ -0,0 +1,45 @@ +case 1 1 2 0 +thresholds 60.0 60.0 60.0 60.0 60.0 60.0 60.0 60.0 60.0 +labels 0x988 1x3 0x29 1x3 0x29 1x3 0x798 1x1 0x29 1x4 0x28 1x5 0x28 1x3 0x30 1x1 0x799 1x2 0x29 1x3 0x29 1x3 0x40 2x3 0x28 2x5 0x27 2x5 0x27 2x5 0x28 2x3 0x732 2x4 0x28 2x5 0x26 2x7 0x25 2x7 0x25 2x7 0x26 2x5 0x28 2x4 0x235 3x1 0x22 3x3 0x5 3x3 0x20 3x5 0x3 3x4 0x20 3x4 0x5 3x3 0x366 2x5 0x26 2x7 0x25 2x7 0x8 4x1 0x16 2x7 0x7 4x3 0x15 2x7 0x6 4x5 0x14 2x7 0x7 4x3 0x16 2x5 0x9 4x2 0x214 3x5 0x4 3x3 0x19 3x14 0x18 3x7 0x2 3x5 0x18 3x7 0x2 3x5 0x19 3x5 0x4 3x3 0x334 2x4 0x28 2x5 0x26 2x7 0x7 4x3 0x15 2x7 0x6 4x5 0x14 2x7 0x6 4x5 0x15 2x6 0x6 4x5 0x16 2x4 0x8 4x3 0x184 3x2 0x27 3x7 0x4 3x1 0x20 3x8 0x1 3x4 0x19 3x8 0x1 3x5 0x18 3x7 0x3 3x3 0x20 3x6 0x28 3x2 0x343 2x3 0x28 2x5 0x9 4x1 0x17 2x5 0x8 4x3 0x16 2x5 0x7 4x4 0x17 2x3 0x9 4x3 0x30 4x1 0x215 3x5 0x26 3x7 0x25 3x7 0x25 3x7 0x26 3x5 0x796 3x4 0x27 3x4 0x29 3x3 0x2896 +case 3 1 2 0 +thresholds 60.0 60.0 60.0 60.0 60.0 60.0 60.0 60.0 60.0 +labels 0x988 1x3 0x29 1x3 0x29 1x3 0x798 1x1 0x29 1x4 0x28 1x5 0x28 1x3 0x30 1x1 0x799 1x2 0x29 1x3 0x29 1x3 0x40 2x3 0x28 2x5 0x27 2x5 0x27 2x5 0x28 2x3 0x732 2x4 0x28 2x5 0x26 2x7 0x25 2x7 0x25 2x7 0x26 2x5 0x28 2x4 0x235 3x1 0x22 5x3 0x5 3x3 0x20 5x5 0x3 3x4 0x20 5x4 0x5 3x3 0x366 2x5 0x26 2x7 0x25 2x7 0x8 4x1 0x16 2x7 0x7 4x3 0x15 2x7 0x6 4x5 0x14 2x7 0x7 4x3 0x16 2x5 0x9 4x2 0x214 5x5 0x4 3x3 0x19 5x7 3x7 0x18 5x7 0x2 3x5 0x18 5x7 0x2 3x5 0x19 5x5 0x4 3x3 0x334 2x4 0x28 2x5 0x26 2x7 0x7 4x3 0x15 2x7 0x6 4x5 0x14 2x7 0x6 4x5 0x15 2x6 0x6 4x5 0x16 2x4 0x8 4x3 0x184 5x2 0x27 5x7 0x4 3x1 0x20 5x8 0x1 3x4 0x19 5x7 3x1 0x1 3x5 0x18 5x7 0x3 3x3 0x20 5x6 0x28 5x2 0x343 2x3 0x28 2x5 0x9 4x1 0x17 2x5 0x8 4x3 0x16 2x5 0x7 4x4 0x17 2x3 0x9 4x3 0x30 4x1 0x215 5x5 0x26 5x7 0x25 5x7 0x25 5x7 0x26 5x5 0x796 5x4 0x27 5x4 0x29 5x3 0x2896 +case 4 1 2 0 +thresholds 60.0 60.0 60.0 60.0 60.0 60.0 60.0 60.0 60.0 +labels 0x988 1x3 0x29 1x3 0x29 1x3 0x798 1x1 0x30 1x3 0x28 1x5 0x28 1x3 0x30 1x1 0x799 1x2 0x29 1x3 0x29 1x3 0x40 2x3 0x28 2x5 0x27 2x5 0x27 2x5 0x28 2x3 0x732 2x4 0x28 2x5 0x26 2x7 0x25 2x7 0x25 2x7 0x26 2x5 0x28 2x4 0x258 5x3 0x5 3x3 0x20 5x5 0x3 3x4 0x20 5x4 0x5 3x3 0x366 2x5 0x26 2x7 0x25 2x7 0x8 4x1 0x16 2x7 0x7 4x3 0x15 2x7 0x6 4x5 0x14 2x7 0x7 4x3 0x16 2x5 0x9 4x2 0x214 5x5 0x4 3x3 0x19 5x8 0x1 3x5 0x18 5x7 0x2 3x5 0x18 5x7 0x2 3x5 0x19 5x5 0x4 3x2 0x335 2x4 0x28 2x5 0x26 2x7 0x7 4x3 0x15 2x7 0x6 4x5 0x14 2x7 0x6 4x5 0x15 2x6 0x6 4x5 0x16 2x4 0x8 4x3 0x184 5x2 0x27 5x7 0x4 3x1 0x20 5x8 0x1 3x4 0x19 5x8 0x1 3x5 0x18 5x7 0x3 3x3 0x20 5x6 0x28 5x2 0x343 2x3 0x28 2x5 0x9 4x1 0x17 2x5 0x8 4x3 0x16 2x5 0x7 4x4 0x17 2x3 0x9 4x3 0x30 4x1 0x215 5x5 0x26 5x7 0x25 5x7 0x25 5x7 0x26 5x5 0x796 5x4 0x27 5x4 0x29 5x3 0x2896 +case 1 4 2 0 +thresholds 171.0 96.0 121.0 98.0 10.0 63.0 71.0 13.0 1.0 +labels 1x10752 +case 3 4 2 0 +thresholds 171.0 96.0 121.0 98.0 10.0 63.0 71.0 13.0 1.0 +labels 8x10 0x2 8x9 0x2 8x5 0x4 8x9 0x1 8x10 0x1 8x7 0x4 8x7 0x2 8x9 0x2 8x7 0x5 8x6 0x1 8x10 0x1 8x9 0x5 8x3 0x1 8x1 0x1 8x19 0x7 8x2 0x1 8x21 0x1 8x2 0x1 8x1 0x2 8x1 0x2 8x10 0x1 8x10 0x1 8x15 0x1 8x1 0x3 8x9 0x1 8x16 0x1 8x2 0x3 8x26 0x4 8x27 0x1 8x10 0x1 8x10 0x1 8x7 0x2 8x9 0x2 8x9 0x2 8x7 0x1 8x10 0x1 8x10 0x1 8x17 0x2 8x10 0x1 8x11 1x1 8x6 0x1 8x3 1x1 8x6 0x1 8x10 0x1 8x3 1x3 8x1 0x1 8x6 1x3 8x1 0x1 8x10 0x1 8x6 1x2 0x1 8x8 1x2 0x1 8x10 0x1 8x7 0x1 8x2 1x2 8x6 0x1 8x2 1x1 8x7 0x1 8x14 1x2 8x21 0x1 8x8 1x3 8x20 1x3 8x9 1x1 8x19 1x2 8x1 1x3 8x2 0x1 8x10 0x1 8x10 0x1 8x3 1x4 8x32 1x3 8x26 0x1 8x2 1x3 8x5 0x1 8x10 0x1 8x6 0x2 8x5 1x1 8x1 1x3 0x1 8x10 0x1 8x8 1x5 8x6 0x1 1x1 8x9 0x1 8x10 1x2 8x2 1x1 8x6 1x2 8x17 0x3 8x10 0x1 8x10 0x1 8x3 0x5 8x1 0x2 8x23 0x6 8x3 0x2 8x10 0x1 8x9 0x7 8x3 0x1 8x1 0x1 8x19 0x4 2x1 0x2 8x6 0x3 8x8 0x1 8x8 0x3 2x1 0x2 8x8 0x4 8x8 0x1 8x5 0x6 8x7 0x5 8x10 0x1 8x4 0x7 8x5 0x2 8x3 0x2 8x20 0x1 8x2 0x1 8x1 0x2 8x3 0x2 8x10 0x1 8x11 0x2 8x3 0x1 8x6 0x1 8x10 0x1 8x10 0x4 8x28 0x3 8x9 0x1 8x10 0x1 8x11 0x2 8x22 0x1 8x10 0x1 8x10 0x1 8x43 0x1 8x3 1x2 8x9 1x2 8x18 1x1 8x10 1x1 8x17 1x2 8x9 1x2 8x14 0x1 8x1 1x3 8x10 1x1 8x17 0x1 1x3 8x9 1x1 8x19 0x1 8x8 1x3 8x26 1x3 8x29 1x3 8x25 1x4 8x26 1x2 8x1 1x3 8x8 1x2 8x16 1x5 8x6 0x1 1x2 8x8 0x1 8x9 0x2 8x5 1x1 8x1 1x3 0x1 8x10 0x1 8x8 0x1 8x1 0x1 8x3 1x1 8x2 1x3 8x1 0x1 8x10 0x1 8x7 0x1 8x1 0x1 8x10 0x1 8x10 0x1 8x2 0x7 8x24 0x6 8x5 0x1 8x19 0x3 2x3 0x1 8x3 0x3 8x6 0x1 8x12 0x3 2x3 0x1 8x6 0x6 8x7 0x1 8x6 0x2 2x3 0x1 8x6 0x6 8x9 0x1 8x4 0x6 8x5 0x7 8x15 0x5 8x1 0x2 8x1 0x10 8x13 0x1 8x1 0x2 8x1 0x2 8x2 0x10 8x9 0x1 8x11 0x10 8x1 0x1 8x10 0x1 8x12 0x6 8x6 0x1 8x10 0x1 8x8 0x6 8x27 0x5 8x10 0x1 8x41 0x1 8x36 0x1 1x1 8x10 1x1 8x18 0x1 8x2 1x2 8x9 1x2 8x15 1x3 0x1 8x9 1x1 8x11 0x1 8x3 1x3 8x29 1x3 8x28 0x1 8x9 1x1 8x28 1x3 8x26 0x1 8x2 1x3 8x23 1x1 8x2 1x3 8x28 1x4 8x26 1x3 8x2 1x1 8x6 1x2 8x20 0x1 8x3 1x1 8x2 1x3 8x1 0x1 8x10 0x1 8x7 0x2 8x5 1x5 0x1 8x10 0x1 8x9 0x2 8x24 0x4 8x1 0x2 8x10 0x1 8x10 0x1 8x2 0x6 8x4 0x2 8x20 0x6 8x3 0x3 8x3 0x1 8x4 0x1 8x10 0x3 2x2 0x2 8x6 0x6 8x6 0x1 8x8 0x2 2x1 0x2 8x5 0x7 8x15 0x5 8x4 0x8 8x8 0x1 8x7 0x5 8x1 0x6 4x1 0x5 8x15 0x1 8x2 0x7 4x2 0x5 8x21 0x10 8x1 0x2 8x21 0x8 8x5 0x2 8x10 0x1 8x6 0x7 8x5 0x1 8x10 0x1 8x9 0x6 8x9 0x1 8x80 1x1 8x32 1x2 8x10 1x1 8x15 1x3 8x26 1x3 8x29 1x3 8x31 0x1 8x6 1x1 8x3 0x1 8x23 0x1 1x2 8x28 0x1 8x1 1x3 8x24 1x1 8x1 1x3 8x23 0x1 8x5 1x3 8x22 0x1 8x3 1x3 8x9 1x2 8x17 0x1 8x2 0x1 8x3 1x1 8x2 1x3 8x12 0x1 8x7 0x2 8x6 1x4 8x11 0x1 8x11 0x2 8x20 0x1 8x11 0x1 8x21 0x1 8x1 0x1 8x2 0x2 8x10 0x1 8x14 0x4 8x3 0x1 8x2 0x1 8x1 0x3 8x2 0x2 8x13 0x4 8x6 0x7 8x8 0x1 8x7 0x4 8x4 0x9 8x4 0x1 8x10 0x3 8x5 0x5 4x1 0x3 8x5 0x2 8x8 0x3 8x4 0x7 4x1 0x5 8x4 0x2 8x4 0x1 8x8 0x5 3x1 0x1 4x1 0x4 8x1 0x1 8x2 0x4 8x4 0x1 8x6 0x4 4x1 0x2 4x2 0x3 8x4 0x7 8x11 0x3 4x3 0x3 8x5 0x5 8x8 0x1 8x4 0x8 8x6 0x5 8x15 0x6 8x7 0x2 8x14 0x3 8x3 0x2 8x26 0x1 8x27 0x1 8x62 1x3 8x8 0x1 8x23 1x3 8x5 0x4 8x7 0x3 8x10 1x3 8x4 0x6 8x5 5x3 0x1 8x13 1x2 8x2 0x7 8x3 0x1 5x3 0x2 8x6 0x1 8x10 0x6 8x4 5x3 0x1 8x9 0x1 8x9 0x4 8x6 0x3 8x7 1x3 8x28 0x1 1x3 8x32 1x3 8x23 0x1 8x5 0x1 8x2 1x3 8x2 1x1 8x5 1x3 8x5 0x1 8x12 1x4 8x7 1x2 8x8 0x1 8x7 0x2 8x10 0x1 8x10 0x1 8x9 0x2 8x10 0x1 8x10 0x1 8x4 0x2 8x10 0x1 8x10 0x1 8x10 0x1 8x2 0x6 8x1 0x2 8x10 0x1 8x12 0x7 8x8 0x1 8x10 0x1 8x4 0x9 8x6 0x2 8x15 0x6 4x1 0x2 8x5 0x5 8x7 0x1 8x4 0x4 3x3 4x1 0x5 8x2 0x6 8x1 0x1 8x9 0x4 3x3 4x1 0x4 8x1 0x8 8x10 0x5 3x3 4x1 0x3 8x2 0x9 8x11 0x2 4x1 0x2 4x2 0x2 8x4 0x7 8x7 0x1 8x4 0x9 8x4 0x6 8x15 0x6 8x5 0x6 8x13 0x2 8x1 0x4 8x12 0x1 8x13 0x1 8x60 0x1 8x9 0x2 8x18 1x3 8x7 0x6 8x19 1x3 8x4 0x7 8x4 5x3 0x1 8x10 1x3 8x3 0x4 7x3 0x2 8x2 5x5 0x1 8x9 9x1 8x5 0x4 7x4 0x1 8x2 5x5 0x1 8x4 1x1 8x11 0x3 7x3 0x2 8x2 5x5 0x1 8x16 0x7 8x4 5x3 0x1 8x7 1x3 8x7 0x6 8x16 1x3 8x32 1x3 8x24 0x1 8x7 1x3 8x1 1x1 8x6 1x3 8x18 1x4 8x7 1x3 8x17 0x1 8x29 0x2 8x27 0x1 8x1 0x1 8x10 0x1 8x10 0x1 8x7 0x2 8x4 0x4 8x2 0x1 8x10 0x1 8x13 0x7 8x10 0x1 8x14 0x8 8x5 0x2 8x1 0x1 8x7 0x1 8x6 0x5 4x1 0x3 8x4 0x6 8x5 0x1 8x6 0x7 4x1 0x3 8x2 0x1 8x1 0x6 8x3 0x1 8x7 0x5 3x1 0x1 4x1 0x9 6x1 0x3 8x1 0x1 8x9 0x3 4x1 0x2 4x2 0x3 8x3 0x2 6x3 0x3 8x11 0x3 4x3 0x3 8x4 0x2 6x3 0x2 8x12 0x9 8x4 0x7 8x7 0x1 8x6 0x6 8x6 0x5 8x7 0x1 8x6 0x1 8x1 0x4 8x10 0x1 8x13 0x1 8x61 0x1 8x10 0x3 8x18 1x2 8x7 0x6 8x19 1x3 8x4 0x8 8x3 0x4 8x10 1x3 8x3 0x3 7x5 0x2 8x1 0x1 5x3 0x1 8x13 1x1 8x2 0x3 7x5 0x1 8x2 5x4 0x2 8x2 1x1 8x5 9x1 8x7 0x2 7x5 0x1 8x3 5x3 0x1 8x6 1x1 9x1 8x9 0x4 7x1 0x3 8x4 0x3 8x7 1x3 8x7 0x7 8x15 1x3 8x8 0x1 8x2 0x1 8x16 0x1 8x3 1x3 9x1 8x21 0x1 8x9 1x4 8x7 1x1 8x1 1x1 8x16 9x1 8x1 1x3 8x1 1x1 8x6 1x3 8x6 0x1 8x9 0x2 8x10 0x1 8x10 0x1 8x7 0x2 8x10 0x1 8x10 0x1 8x7 0x1 8x29 0x3 8x35 0x6 8x9 0x1 8x15 0x7 8x6 0x2 8x10 0x1 8x5 0x9 8x4 0x6 8x12 0x11 8x2 0x1 8x1 0x6 8x11 0x5 4x2 0x3 8x1 0x1 8x1 0x8 8x1 0x1 8x10 0x4 4x1 0x5 8x3 0x8 8x12 0x8 8x4 0x7 8x13 0x7 8x5 0x6 8x15 0x6 8x6 0x5 8x6 0x1 8x7 0x1 8x21 0x1 8x8 0x1 8x29 0x1 8x29 0x1 8x31 1x1 8x1 1x1 8x8 0x5 8x15 0x1 8x3 1x2 8x5 0x8 8x17 1x3 8x3 0x4 7x3 0x2 8x9 1x1 8x14 0x3 7x4 0x1 8x4 0x1 8x5 1x1 8x1 1x1 8x6 9x1 8x4 0x3 7x3 0x2 8x13 1x1 8x1 9x1 8x2 9x2 8x5 0x6 8x15 1x3 8x7 0x6 8x16 1x3 8x28 0x1 8x3 1x3 8x31 9x1 1x3 8x8 1x3 8x7 0x1 8x7 9x1 8x2 1x3 8x2 1x1 8x5 1x3 8x5 0x1 8x12 0x1 8x21 0x1 8x10 0x2 8x20 0x2 8x10 0x1 8x21 0x1 8x9 0x2 8x21 0x3 8x30 0x1 8x1 0x3 8x1 0x4 8x4 0x1 8x10 0x1 8x9 0x8 8x4 0x1 8x10 0x1 8x9 0x7 8x5 0x6 8x5 0x1 8x8 0x7 8x5 0x6 8x14 0x7 8x6 0x5 8x14 0x6 8x1 0x2 8x4 0x4 8x1 0x1 8x15 0x2 8x3 0x2 8x5 0x3 8x1 0x2 8x21 0x1 8x10 0x2 8x40 1x2 8x14 0x1 8x10 0x1 8x3 1x1 8x29 1x2 8x43 0x2 8x16 1x1 8x4 0x1 8x7 0x5 8x9 0x1 8x2 1x2 8x12 0x6 8x11 1x1 8x1 1x1 8x4 1x1 8x2 9x1 8x3 0x8 8x9 1x1 8x4 1x3 8x1 9x3 8x3 0x7 8x7 1x1 8x6 0x1 1x3 8x1 9x3 8x3 0x6 8x16 9x1 8x3 9x3 8x5 0x3 8x6 1x2 8x11 9x1 8x2 9x1 8x3 1x1 8x10 1x1 8x1 1x1 8x14 9x1 1x3 8x8 1x3 8x7 0x1 8x7 1x3 8x7 1x2 8x1 1x1 8x18 1x3 8x1 0x1 8x4 1x1 8x1 1x3 8x12 0x1 8x9 0x2 8x9 0x1 8x10 0x2 8x7 0x2 8x9 0x1 8x10 0x2 8x12 0x1 8x10 0x1 8x19 0x1 8x10 0x1 8x10 0x4 8x32 0x1 8x1 0x1 8x29 0x3 8x32 0x6 8x6 0x2 8x10 0x1 8x7 0x5 8x6 0x1 8x1 0x1 8x18 0x6 8x8 0x2 8x23 0x1 8x10 0x1 8x19 0x1 8x10 0x1 8x24 0x1 8x9 0x2 8x7 1x2 8x13 0x1 8x10 0x1 8x7 1x1 8x11 0x1 8x10 0x1 8x3 1x3 8x31 1x1 8x28 0x2 8x59 1x1 8x7 0x1 8x9 0x2 8x10 0x1 8x1 1x3 8x4 1x1 8x9 0x1 8x11 1x2 8x3 0x1 8x1 1x3 8x1 9x3 8x4 0x1 8x2 0x1 8x8 1x2 8x7 1x3 9x4 8x16 1x2 8x11 9x3 8x2 1x1 8x10 1x3 8x10 9x1 8x2 9x2 8x3 1x3 8x8 1x3 8x12 9x1 8x2 1x3 8x1 1x2 8x5 1x3 8x15 1x3 8x6 1x1 8x1 1x3 8x18 1x3 8x7 1x4 8x22 0x2 8x20 0x2 8x7 0x1 8x21 0x1 8x14 0x1 8x10 0x1 8x40 0x4 8x10 0x1 8x10 0x1 8x12 0x1 8x10 0x1 8x10 0x1 8x7 0x2 8x10 0x1 8x10 0x1 8x11 0x3 8x9 0x2 8x9 0x1 8x2 0x1 8x5 0x3 8x8 0x1 8x10 0x1 8x12 0x1 8x10 0x1 8x10 0x1 8x13 0x1 8x10 0x1 8x19 0x1 8x10 0x1 8x35 0x1 8x7 1x2 8x14 0x1 8x10 0x1 8x4 1x1 8x1 1x1 8x26 1x2 8x31 1x2 8x30 0x1 8x21 0x1 8x13 0x1 8x22 1x2 8x5 0x1 8x21 0x1 8x3 1x2 8x4 1x1 8x21 1x3 8x4 1x3 8x1 9x3 8x16 1x2 8x7 1x3 8x1 9x3 8x16 1x2 8x6 0x1 8x2 9x1 8x1 9x3 8x2 1x3 8x8 1x3 8x9 9x1 8x8 1x3 8x8 1x3 8x13 9x1 8x1 1x3 8x1 1x2 8x5 1x3 8x15 1x3 8x2 0x1 8x3 1x1 8x1 1x3 8x2 0x1 8x10 0x1 8x4 1x3 0x1 8x6 1x4 0x1 8x10 0x1 8x34 0x1 8x10 0x1 8x10 0x1 8x16 0x2 8x9 0x1 8x10 0x2 8x8 0x3 8x10 0x1 8x8 0x3 8x1 0x2 8x1 0x3 8x9 0x2 8x9 0x2 8x4 0x3 8x29 0x3 8x10 0x1 8x10 0x1 8x16 0x1 8x1 0x1 8x8 0x1 8x1 0x1 8x18 0x3 8x9 0x2 8x15 0x3 8x8 0x3 8x15 0x1 8x10 0x1 8x10 0x2 8x10 0x1 8x10 0x1 8x9 0x2 8x16 0x1 8x10 0x1 8x9 1x2 8x29 1x2 8x31 0x1 8x3 1x1 8x29 1x2 8x55 1x1 8x3 0x1 8x22 0x1 8x32 0x1 8x7 1x3 8x21 1x1 8x6 0x2 8x2 1x1 8x2 9x1 8x20 1x1 0x1 8x6 1x1 8x1 9x3 8x17 1x2 8x2 0x1 8x6 9x2 8x1 1x2 8x9 1x2 8x17 9x1 1x3 8x8 1x1 8x16 0x1 8x6 1x3 8x1 0x1 8x6 1x1 8x3 0x1 8x7 1x3 8x6 0x1 8x2 1x2 8x6 0x1 8x10 0x1 1x3 8x7 0x2 1x3 8x6 0x2 8x9 +case 4 4 2 0 +thresholds 171.0 96.0 121.0 98.0 10.0 63.0 71.0 13.0 1.0 +labels 0x1021 1x1 0x31 1x1 0x830 1x3 0x29 1x3 0x29 1x3 0x861 1x2 0x31 1x1 0x678 6x3 0x29 6x3 0x289 2x1 0x345 6x5 0x14 3x3 0x10 6x5 0x14 3x3 0x10 6x5 0x14 3x3 0x11 6x4 0x27 6x4 0x26 6x5 0x27 6x5 0x27 6x3 0x1 6x5 0x23 6x3 0x1 6x5 0x254 2x3 0x29 2x3 0x29 2x3 0x299 3x3 0x10 6x5 0x6 5x3 0x5 3x4 0x8 6x6 0x6 5x4 0x3 3x5 0x7 6x7 0x6 5x3 0x4 3x5 0x7 6x7 0x14 3x2 0x9 6x6 0x26 6x8 0x24 6x8 0x24 6x9 0x23 6x9 0x287 2x1 0x11 4x1 0x30 4x3 0x29 4x3 0x266 6x7 0x6 5x4 0x4 3x3 0x8 6x7 0x5 5x5 0x3 3x4 0x8 6x8 0x4 5x5 0x4 3x3 0x8 6x8 0x6 5x1 0x17 6x8 0x24 6x8 0x24 6x8 0x24 6x9 0x23 6x9 0x631 6x8 0x5 5x3 0x16 6x8 0x5 5x4 0x15 6x9 0x4 5x3 0x16 6x9 0x23 6x9 0x23 6x8 0x24 6x8 0x24 6x8 0x24 6x8 0x632 6x9 0x23 6x9 0x23 6x9 0x23 6x9 0x23 6x9 0x23 6x9 0x23 6x9 0x23 6x8 0x24 6x7 0x638 6x4 0x25 6x7 0x23 6x9 0x23 6x9 0x23 6x9 0x23 6x9 0x23 6x9 0x23 6x7 0x25 6x6 0x639 6x4 0x24 6x8 0x23 6x9 0x23 6x9 0x23 6x9 0x23 6x9 0x23 6x9 0x23 6x7 0x25 6x6 0x639 6x4 0x24 6x8 0x24 6x8 0x23 6x9 0x23 6x9 0x23 6x9 0x23 6x2 0x1 6x6 0x23 6x6 0x26 6x6 0x26 +case 1 2 2 0 +thresholds 96.7138151703369 75.20522774327122 63.20769809900245 72.00966967814794 48.32020515716168 54.76086956521739 50.32953147713295 32.47232788132369 24.947916666666664 +labels 0x4 1x8 0x3 1x8 0x3 1x6 0x3 1x2 0x1 1x6 0x2 1x2 0x1 1x6 0x2 1x7 0x2 1x1 0x3 1x6 0x1 1x1 0x3 1x6 0x1 1x1 0x2 1x8 0x3 1x7 0x4 1x8 0x1 1x12 0x4 1x7 0x4 1x17 0x1 1x2 0x3 1x5 0x1 1x2 0x3 1x8 0x1 1x7 0x1 1x1 0x3 1x6 0x5 1x8 0x6 1x7 0x4 1x7 0x4 1x5 0x3 1x2 0x1 1x6 0x2 1x2 0x1 1x6 0x2 1x2 0x1 1x6 0x4 1x7 0x4 1x7 0x4 1x10 0x3 1x8 0x3 1x8 0x3 1x7 0x3 1x2 0x1 1x5 0x3 1x2 0x1 1x5 0x3 1x7 0x1 1x2 0x3 1x5 0x1 1x2 0x3 1x5 0x1 1x2 0x5 1x8 0x4 1x7 0x4 1x5 0x3 1x2 0x1 1x6 0x2 1x2 0x1 1x6 0x2 1x7 0x2 1x1 0x3 1x6 0x1 1x1 0x3 1x6 0x1 1x1 0x3 1x6 0x4 1x7 0x4 1x7 0x4 1x10 0x3 1x1 0x1 1x6 0x3 1x1 0x1 1x6 0x3 1x7 0x1 1x2 0x3 1x5 0x1 1x2 0x3 1x5 0x1 1x2 0x1 1x7 0x5 1x6 0x5 1x6 0x6 1x1 0x1 1x7 0x2 1x1 0x1 1x7 0x2 1x1 0x1 1x5 0x2 1x1 0x3 1x6 0x1 1x1 0x3 1x6 0x1 1x1 0x3 1x6 0x4 1x7 0x4 1x7 0x4 1x10 0x4 1x7 0x4 1x7 0x4 1x6 0x3 1x1 0x2 1x5 0x3 1x1 0x2 1x5 0x3 1x8 0x1 1x1 0x3 1x6 0x5 1x6 0x8 1x7 0x4 1x7 0x4 1x5 0x3 1x2 0x1 1x6 0x2 1x2 0x1 1x6 0x2 1x2 0x1 1x11 0x5 1x6 0x5 1x15 0x1 1x2 0x3 1x5 0x1 1x2 0x3 1x15 0x3 1x1 0x1 1x6 0x3 1x1 0x1 1x16 0x4 1x7 0x4 1x14 0x3 1x8 0x3 1x15 0x3 1x9 0x2 1x9 0x2 1x7 0x4 1x8 0x3 1x8 0x3 1x17 0x1 1x6 0x5 1x8 0x1 1x17 0x1 1x2 0x3 1x8 0x1 1x6 0x1 1x10 0x4 1x7 0x4 1x2 0x4 1x7 0x4 1x7 0x4 1x4 0x2 1x1 0x3 1x6 0x1 1x1 0x3 1x6 0x1 1x1 0x3 1x4 0x3 1x2 0x1 1x6 0x2 1x2 0x1 1x6 0x2 1x2 0x1 1x11 0x5 1x6 0x5 1x6 0x3 1x6 0x1 1x2 0x3 1x5 0x1 1x2 0x3 1x5 0x1 1x2 0x1 1x6 0x3 1x1 0x2 1x5 0x3 1x1 0x2 1x5 0x3 1x7 0x4 1x7 0x4 1x7 0x6 1x1 0x3 1x8 0x3 1x8 0x3 1x4 0x3 1x2 0x1 1x6 0x2 1x2 0x1 1x6 0x2 1x2 0x1 1x4 0x3 1x1 0x1 1x7 0x2 1x1 0x1 1x7 0x2 1x1 0x1 1x12 0x5 1x6 0x5 1x6 0x3 1x6 0x3 1x1 0x2 1x5 0x3 1x1 0x2 1x5 0x3 1x7 0x4 1x7 0x4 1x7 0x4 1x2 0x4 1x7 0x4 1x7 0x4 1x4 0x2 1x1 0x3 1x6 0x1 1x1 0x3 1x6 0x1 1x1 0x3 1x4 0x3 1x1 0x1 1x7 0x4 1x7 0x4 1x12 0x1 1x1 0x3 1x6 0x1 1x1 0x3 1x6 0x1 1x1 0x1 1x6 0x1 1x2 0x3 1x5 0x1 1x2 0x3 1x5 0x1 1x2 0x1 1x6 0x1 1x2 0x3 1x5 0x1 1x2 0x3 1x17 0x4 1x7 0x4 1x15 0x4 1x7 0x4 1x17 0x3 1x1 0x1 1x6 0x3 1x1 0x2 1x9 0x2 1x1 0x3 1x8 0x3 1x6 0x1 1x1 0x1 1x6 0x3 1x9 0x2 1x1 0x1 1x7 0x2 1x7 0x3 1x10 0x1 1x2 0x1 1x6 0x2 1x24 0x1 1x2 0x3 1x8 0x1 1x18 0x1 1x1 0x3 1x6 0x1 1x1 0x1 1x17 0x3 1x1 0x2 1x5 0x3 1x1 0x2 1x1 0x2 1x9 0x3 1x6 0x1 1x1 0x3 1x6 0x3 1x8 0x4 1x7 0x4 1x4 0x4 1x8 0x3 1x8 0x3 1x15 0x3 1x5 0x1 1x2 0x3 1x5 0x1 1x2 0x1 1x8 0x4 1x6 0x5 1x6 0x3 1x6 0x3 1x8 0x3 1x8 0x3 1x7 0x3 1x1 0x1 1x6 0x3 1x1 0x1 1x6 0x3 1x3 0x4 1x9 0x2 1x8 0x3 1x4 0x5 1x7 0x1 1x10 0x1 1x2 0x1 1x5 0x3 1x10 0x1 1x10 0x1 1x13 0x1 1x2 0x2 1x9 0x1 1x7 0x1 1x2 0x1 1x6 0x4 1x7 0x3 1x8 0x4 1x6 0x3 1x8 0x3 1x8 0x3 1x1 0x1 1x2 0x3 1x8 0x3 1x8 0x3 1x6 0x4 1x8 0x3 1x7 0x4 1x4 0x3 1x2 0x1 1x6 0x2 1x2 0x1 1x6 0x2 1x2 0x1 1x10 0x1 1x2 0x3 1x8 0x3 1x5 0x1 1x2 0x1 1x7 0x5 1x6 0x5 1x6 0x3 1x6 0x1 1x2 0x3 1x5 0x1 1x2 0x3 1x16 0x5 1x6 0x5 1x15 0x3 1x8 0x4 1x28 0x3 1x1 0x2 1x9 0x2 1x1 0x2 1x9 0x3 1x6 0x1 1x1 0x1 1x6 0x3 1x10 0x3 1x7 0x2 1x7 0x3 1x10 0x1 1x2 0x1 1x6 0x2 1x24 0x1 1x2 0x3 1x8 0x1 1x20 0x3 1x7 0x2 1x17 0x3 1x8 0x3 1x1 0x2 1x1 0x1 1x10 0x3 1x6 0x1 1x1 0x3 1x7 0x1 1x10 0x3 1x7 0x4 1x4 0x3 1x9 0x4 1x7 0x4 1x16 0x1 1x5 0x1 1x2 0x3 1x5 0x1 1x2 0x1 1x9 0x3 1x6 0x1 1x1 0x3 1x6 0x1 1x1 0x1 1x6 0x4 1x7 0x4 1x7 0x4 1x6 0x3 1x9 0x2 1x8 0x3 1x3 0x4 1x19 0x3 1x4 0x4 1x28 0x3 1x2 0x1 1x21 0x1 1x13 0x1 1x21 0x1 1x6 0x3 1x19 0x3 1x7 0x3 1x19 0x3 1x2 0x1 1x1 0x3 1x19 0x3 1x7 0x3 1x10 0x1 1x8 0x3 1x4 0x3 1x9 0x2 1x9 0x2 1x13 0x1 1x2 0x3 1x5 0x1 1x2 0x3 1x5 0x1 1x2 0x1 1x8 0x4 1x7 0x4 1x7 0x2 1x6 0x3 1x2 0x1 1x5 0x3 1x2 0x1 1x6 0x2 1x7 0x4 1x7 0x4 1x20 0x3 1x7 0x4 1x26 0x1 1x2 0x3 1x9 0x3 1x10 0x1 1x9 0x2 1x21 0x3 1x8 0x3 1x5 0x1 1x12 0x3 1x6 0x1 1x1 0x3 1x21 0x1 1x10 0x3 1x18 0x1 1x10 0x3 1x32 0x4 1x21 0x1 1x2 0x1 1x4 0x3 1x11 0x1 1x8 0x3 1x8 0x2 1x10 0x3 1x7 0x4 1x21 0x3 1x2 0x1 1x5 0x3 1x7 0x1 1x1 0x1 1x8 0x4 1x7 0x4 1x7 0x1 1x1 0x3 1x6 0x1 1x1 0x3 1x6 0x1 1x1 0x1 1x6 0x1 1x2 0x2 1x17 0x1 1x2 0x6 1x29 0x4 1x21 0x1 1x4 0x2 1x1 0x3 1x21 0x1 1x10 0x2 1x20 0x3 1x30 0x3 1x31 0x4 1x2 0x1 1x26 0x5 1x27 0x2 1x1 0x3 1x8 0x3 1x8 0x3 1x10 0x3 1x2 0x1 1x5 0x3 1x2 0x1 1x5 0x3 1x7 0x4 1x7 0x4 1x7 0x4 1x6 0x3 1x1 0x1 1x6 0x3 1x1 0x1 1x6 0x3 1x7 0x3 1x8 0x4 1x7 0x4 1x10 0x2 1x6 0x5 1x8 0x1 1x17 0x1 1x2 0x3 1x8 0x4 1x10 0x1 1x2 0x1 1x6 0x2 1x2 0x1 1x18 0x3 1x7 0x4 1x4 0x1 1x13 0x3 1x8 0x3 1x32 0x3 1x29 0x4 1x28 0x1 1x2 0x4 1x21 0x1 1x7 0x3 1x12 0x1 1x9 0x2 1x19 0x3 1x8 0x3 1x21 0x3 1x8 0x3 1x7 0x1 1x10 0x3 1x8 0x4 1x9 0x3 1x8 0x3 1x6 0x3 1x6 0x1 1x2 0x1 1x18 0x1 1x2 0x5 1x30 0x4 1x21 0x1 1x4 0x2 1x1 0x3 1x21 0x1 1x10 0x2 1x20 0x3 1x30 0x1 1x1 0x1 1x6 0x1 1x21 0x1 1x2 0x4 1x2 0x1 1x21 0x1 1x4 0x3 1x1 0x1 1x21 0x1 1x5 0x2 1x1 0x3 1x19 0x3 1x10 0x3 1x1 0x1 1x6 0x3 1x1 0x1 1x6 0x3 1x7 0x4 1x7 0x4 1x7 0x4 1x6 0x4 1x7 0x4 1x7 0x4 1x6 0x3 1x2 0x1 1x5 0x3 1x2 0x1 1x5 0x3 1x10 0x3 1x5 0x1 1x2 0x3 1x5 0x1 1x2 0x1 1x11 0x1 1x6 0x1 1x1 0x3 1x6 0x1 1x1 0x4 1x10 0x3 1x7 0x4 1x5 0x2 1x1 0x1 1x10 0x3 1x6 0x1 1x1 0x3 1x18 0x3 1x8 0x3 1x32 0x4 1x28 0x3 1x30 0x6 1x12 0x1 1x10 0x1 1x5 0x3 1x10 0x1 1x10 0x1 1x2 0x1 1x4 0x1 1x2 0x1 1x10 0x3 1x8 0x3 1x21 0x1 1x10 0x4 1x6 0x3 1x8 0x3 1x2 0x1 1x5 0x3 1x7 0x1 1x2 0x2 1x9 0x2 1x6 0x1 1x2 0x1 1x7 0x1 1x21 0x6 1x2 0x1 1x21 0x1 1x4 0x2 1x1 0x3 1x20 0x2 1x6 0x4 1x21 0x1 1x10 0x1 1x21 0x4 1x31 0x1 1x30 0x6 1x28 0x3 1x21 0x1 1x10 0x3 1x19 0x3 1x10 0x4 1x7 0x4 1x7 0x4 1x6 0x3 1x1 0x1 1x6 0x3 1x1 0x1 1x6 0x3 1x7 0x4 1x7 0x4 1x7 0x4 1x6 0x3 1x1 0x1 1x6 0x3 1x1 0x2 1x5 0x3 1x7 0x1 1x2 0x3 1x5 0x1 1x2 0x3 1x5 0x1 1x2 0x1 1x10 0x2 1x6 0x5 1x6 0x6 1x9 0x2 1x1 0x1 1x7 0x2 1x1 0x1 1x5 0x2 1x1 0x1 1x10 0x3 1x6 0x1 1x1 0x3 1x6 0x1 1x11 0x3 1x7 0x4 1x32 0x4 1x28 0x3 1x30 0x1 1x1 0x4 1x11 0x1 1x9 0x3 1x5 0x3 1x10 0x1 1x10 0x1 1x2 0x1 1x5 0x1 1x1 0x2 1x9 0x3 1x8 0x3 1x10 0x1 1x10 0x2 1x9 0x4 1x6 0x3 1x8 0x3 1x1 0x2 1x5 0x3 1x10 0x1 1x10 0x3 1x8 0x1 1x21 0x2 1x7 0x5 1x2 0x1 1x21 0x1 1x4 0x1 1x2 0x3 1x20 0x2 1x7 0x3 1x20 0x2 1x10 0x1 1x21 0x4 1x28 0x1 1x2 0x1 1x29 0x6 1x29 0x3 1x20 0x2 1x2 0x1 1x6 0x4 1x18 0x4 1x10 0x3 1x8 0x3 1x8 0x4 1x6 0x3 1x2 0x1 1x5 0x3 1x2 0x1 1x5 0x3 1x3 0x4 1x7 0x4 1x7 0x4 1x4 0x1 1x2 0x3 1x8 0x3 1x8 0x3 1x4 0x3 1x2 0x1 1x6 0x2 1x2 0x1 1x6 0x2 1x2 0x1 1x4 0x3 1x1 0x1 1x7 0x2 1x1 0x1 1x7 0x2 1x1 0x1 1x15 0x2 1x6 0x5 1x6 0x3 1x17 0x3 1x1 0x1 1x6 0x3 1x18 0x2 1x9 0x4 1x3 0x1 1x10 0x3 1x8 0x3 1x4 0x2 1x1 0x1 1x10 0x3 1x8 0x3 1x4 0x3 1x10 0x2 1x9 0x3 1x34 0x1 1x1 0x1 1x28 0x1 1x2 0x1 1x17 0x3 1x8 0x3 1x3 0x4 1x7 0x4 1x7 0x4 1x4 0x2 1x1 0x3 1x8 0x3 1x6 0x1 1x1 0x3 1x4 0x3 1x2 0x1 1x6 0x2 1x9 0x2 1x2 0x1 1x4 0x5 1x18 0x4 1x11 0x1 1x2 0x1 1x10 0x3 1x5 0x1 1x2 0x1 1x6 0x3 1x19 0x3 1x7 0x2 1x20 0x4 1x3 0x1 1x1 0x1 1x19 0x3 1x4 0x3 1x21 0x1 1x7 0x3 1x20 0x3 1x26 0x3 1x7 0x2 1x9 0x1 1x10 0x3 1x8 0x1 1x6 0x3 1x8 0x3 1x8 0x3 1x3 0x4 1x7 0x4 1x7 0x4 1x5 0x1 1x1 0x3 1x6 0x1 1x1 0x3 1x6 0x1 1x1 0x3 1x5 0x1 1x1 0x3 1x6 0x1 1x1 0x3 1x6 0x1 1x1 0x3 1x6 0x4 1x7 0x4 1x7 0x4 1x4 0x5 1x7 0x4 1x7 0x4 1x5 0x3 1x9 0x2 1x2 0x1 1x6 0x2 1x16 0x3 1x5 0x1 1x2 0x3 1x5 0x1 1x2 0x1 1x6 0x1 1x1 0x1 1x8 0x4 1x7 0x4 1x17 0x3 1x2 0x1 1x5 0x3 1x1 0x1 1x2 0x2 1x9 0x3 1x8 0x3 1x6 0x4 1x7 0x4 1x7 0x4 1x4 0x3 1x9 0x2 1x2 0x1 1x6 0x2 1x2 0x1 1x13 0x1 1x1 0x1 1x8 0x3 1x8 0x1 1x10 0x2 1x6 0x1 1x1 0x3 1x6 0x3 1x6 0x3 1x8 0x3 1x8 0x4 1x1 0x1 1x1 0x3 1x6 0x1 1x1 0x3 1x6 0x1 1x1 0x3 1x7 0x3 1x8 0x3 1x7 0x4 1x4 0x5 1x7 0x2 1x9 0x4 1x5 0x3 1x2 0x1 1x7 0x1 1x9 0x2 1x2 0x1 1x12 0x4 1x7 0x4 1x7 0x2 1x6 0x3 1x10 0x2 1x7 0x4 1x6 0x3 1x10 0x1 1x1 0x1 1x6 0x3 1x1 0x2 1x1 0x1 1x21 0x3 1x4 0x3 1x20 0x2 1x1 0x1 1x5 0x3 1x20 0x2 1x2 0x1 1x13 0x2 1x9 0x3 1x5 0x1 1x2 0x1 1x7 0x1 1x1 0x3 1x6 0x1 1x1 0x3 1x6 0x3 1x6 0x3 1x1 0x2 1x5 0x3 1x1 0x2 1x5 0x3 1x1 0x2 1x1 0x3 1x6 0x1 1x1 0x3 1x6 0x1 1x1 0x3 1x6 0x4 1x7 0x4 1x7 0x4 1x4 0x1 1x2 0x3 1x8 0x3 1x8 0x3 1x6 0x4 1x7 0x4 1x7 0x4 1x4 0x4 1x8 0x3 1x8 0x3 1x6 0x3 1x2 0x1 1x6 0x2 1x2 0x1 1x6 0x2 1x2 0x1 1x13 0x3 1x8 0x3 1x8 0x1 1x6 0x3 1x8 0x3 1x8 0x3 1x7 0x3 1x1 0x1 1x6 0x3 1x1 0x2 1x5 0x3 1x2 0x1 1x1 0x3 1x6 0x1 1x1 0x3 1x6 0x1 1x1 0x3 1x6 0x4 1x8 0x3 1x8 0x3 1x4 0x3 1x9 0x2 1x2 0x1 1x6 0x2 1x13 0x1 1x2 0x3 1x5 0x1 1x2 0x3 1x5 0x1 1x2 0x1 1x8 0x4 1x7 0x4 1x7 0x2 1x6 0x4 1x7 0x4 1x7 0x5 1x2 0x3 1x8 0x3 1x8 0x3 1x6 0x4 1x7 0x4 1x7 0x4 1x4 0x5 1x7 0x2 1x1 0x1 1x7 0x4 1x5 0x3 1x2 0x1 1x6 0x2 1x2 0x1 1x6 0x2 1x2 0x1 1x11 0x5 1x6 0x5 1x6 0x3 1x6 0x3 1x8 0x4 1x7 0x4 1x6 0x3 1x8 0x3 1x1 0x2 1x5 0x3 1x1 0x2 1x1 0x2 1x18 0x1 1x1 0x3 1x4 0x3 1x9 0x1 1x10 0x4 1x5 0x3 1x10 0x1 1x9 0x2 1x2 0x1 1x13 0x3 1x8 0x3 1x5 0x1 1x2 0x1 1x8 0x4 1x6 0x5 1x6 0x3 1x6 0x3 1x1 0x2 1x5 0x3 1x1 0x2 1x5 0x3 1x1 0x2 1x1 0x3 1x6 0x1 1x1 0x3 1x6 0x1 1x1 0x3 1x6 0x4 1x7 0x4 1x7 0x4 1x4 0x3 1x2 0x1 1x6 0x2 1x2 0x1 1x6 0x2 1x2 0x1 1x4 0x5 1x7 0x4 1x7 0x4 1x7 0x4 1x7 0x4 1x7 0x4 1x4 0x2 1x1 0x3 1x6 0x1 1x1 0x3 1x6 0x1 1x1 0x3 1x10 0x3 1x1 0x2 1x5 0x3 1x1 0x2 1x5 0x3 1x8 0x5 1x6 0x5 1x6 0x3 1x6 0x1 1x2 0x3 1x5 0x1 1x2 0x3 1x5 0x1 1x2 0x4 1x2 0x1 1x6 0x2 1x2 0x1 1x6 0x2 1x2 0x1 1x4 0x5 1x7 0x4 1x7 0x4 1x5 0x2 1x1 0x3 1x6 0x1 1x1 0x3 1x6 0x1 1x1 0x3 1x10 0x3 1x1 0x2 1x5 0x3 1x1 0x2 1x5 0x3 1x7 0x4 1x7 0x4 1x7 0x4 1x7 0x5 1x6 0x5 1x6 0x6 1x2 0x1 1x6 0x2 1x2 0x1 1x6 0x2 1x2 0x1 1x4 0x3 1x1 0x1 1x7 0x2 1x1 0x1 1x7 0x2 1x1 0x1 1x7 0x4 1x7 0x4 1x7 0x4 1x4 0x1 1x2 0x3 1x8 0x3 1x8 0x3 1x10 0x4 1x7 0x4 1x7 0x4 1x8 0x4 1x7 0x4 1x7 0x2 1x6 0x1 1x2 0x3 1x5 0x1 1x2 0x3 1x5 0x1 1x2 0x4 1x9 0x2 1x9 0x2 1x10 0x1 1x1 0x1 1x8 0x3 1x8 0x3 1x5 0x1 1x1 0x2 1x7 0x1 1x1 0x3 1x6 0x1 1x1 0x3 1x11 0x2 1x1 0x1 1x6 0x3 1x1 0x1 1x6 0x3 1x7 0x3 1x8 0x3 1x8 0x3 1x10 0x3 1x8 0x3 1x8 0x4 1x2 0x1 1x6 0x2 1x2 0x1 1x6 0x2 1x2 0x1 1x4 0x4 1x8 0x3 1x8 0x3 1x6 +case 3 2 2 0 +thresholds 96.7138151703369 75.20522774327122 63.20769809900245 72.00966967814794 48.32020515716168 54.76086956521739 50.32953147713295 32.47232788132369 24.947916666666664 +labels 0x988 1x3 0x28 1x4 0x29 1x3 0x30 1x1 0x766 1x3 0x28 1x5 0x27 1x5 0x27 1x5 0x28 1x3 0x796 1x4 0x28 1x4 0x29 1x3 0x30 1x1 0x42 2x1 0x30 2x2 0x830 2x3 0x28 2x5 0x27 2x5 0x27 2x5 0x28 2x3 0x267 3x3 0x24 3x1 0x3 3x5 0x21 5x2 0x4 3x5 0x28 3x4 0x29 3x2 0x366 2x5 0x27 2x5 0x27 2x5 0x8 4x3 0x16 2x5 0x8 4x3 0x16 2x5 0x8 4x3 0x30 4x1 0x216 5x3 0x1 3x1 0x2 3x4 0x20 5x6 3x7 0x19 5x5 0x1 3x7 0x19 5x5 0x2 3x6 0x20 5x3 0x5 3x4 0x366 2x3 0x28 2x5 0x8 4x3 0x16 2x5 0x7 4x5 0x15 2x5 0x7 4x5 0x16 2x3 0x8 4x5 0x28 4x3 0x214 5x5 0x4 3x2 0x20 5x7 0x1 3x5 0x19 5x7 3x7 0x18 5x7 3x1 0x1 3x5 0x19 5x5 0x5 3x2 0x432 2x2 0x9 4x3 0x18 2x1 0x10 4x3 0x29 4x3 0x30 4x1 0x216 5x3 0x28 5x5 0x27 5x5 0x27 5x5 0x20 6x1 0x6 5x4 0x798 5x1 0x23 6x1 0x29 6x3 0x29 6x3 0x29 6x3 0x829 6x3 0x29 6x3 0x29 6x3 0x829 6x3 0x29 6x3 0x29 6x3 0x831 6x1 0x31 6x1 0x29 6x1 0x155 +case 4 2 2 0 +thresholds 96.7138151703369 75.20522774327122 63.20769809900245 72.00966967814794 48.32020515716168 54.76086956521739 50.32953147713295 32.47232788132369 24.947916666666664 +labels 0x988 1x3 0x28 1x4 0x29 1x3 0x30 1x1 0x766 1x3 0x28 1x5 0x27 1x5 0x27 1x5 0x28 1x2 0x797 1x4 0x28 1x4 0x29 1x3 0x30 1x1 0x42 2x1 0x30 2x2 0x830 2x3 0x28 2x5 0x27 2x5 0x27 2x5 0x28 2x3 0x267 3x3 0x28 3x5 0x21 5x2 0x4 3x5 0x28 3x4 0x29 3x2 0x366 2x5 0x27 2x5 0x27 2x5 0x8 4x3 0x16 2x5 0x8 4x3 0x16 2x5 0x8 4x3 0x30 4x1 0x216 5x3 0x4 3x4 0x20 5x6 0x1 3x6 0x19 5x5 0x2 3x6 0x19 5x5 0x2 3x6 0x20 5x3 0x5 3x3 0x367 2x3 0x28 2x5 0x8 4x3 0x16 2x5 0x8 4x4 0x15 2x5 0x7 4x5 0x16 2x3 0x8 4x5 0x28 4x2 0x215 5x5 0x4 3x2 0x20 5x7 0x1 3x5 0x19 5x7 0x1 3x6 0x18 5x7 0x2 3x5 0x19 5x5 0x5 3x2 0x432 2x2 0x9 4x3 0x18 2x1 0x10 4x3 0x29 4x3 0x30 4x1 0x216 5x3 0x28 5x5 0x27 5x5 0x27 5x5 0x20 6x1 0x6 5x4 0x798 5x1 0x23 6x1 0x29 6x3 0x29 6x3 0x29 6x3 0x829 6x3 0x29 6x3 0x29 6x3 0x829 6x3 0x29 6x3 0x29 6x3 0x831 6x1 0x31 6x1 0x185 +case 1 3 2 0 +thresholds 106.51181183698455 75.8366292386967 88.08040264812568 77.38193298987136 41.618196861030235 64.08808807481816 65.99029854330573 43.54573850650545 193523.95641998175 +labels 0x989 1x1 0x30 1x3 0x29 1x3 0x798 1x1 0x30 1x3 0x28 1x5 0x28 1x3 0x30 1x1 0x799 1x1 0x30 1x3 0x29 1x3 0x439 6x1 0x30 6x2 0x22 6x3 0x5 6x4 0x21 6x2 0x5 6x3 0x399 2x3 0x28 2x5 0x27 2x5 0x27 2x5 0x28 2x3 0x235 6x2 0x21 6x6 0x2 6x5 0x18 6x10 3x3 6x1 0x18 6x3 5x1 6x3 0x1 6x2 3x3 6x2 0x17 6x7 0x1 6x2 3x3 6x1 0x19 6x5 0x3 6x5 0x28 6x2 0x336 2x3 0x28 2x5 0x27 2x5 0x9 4x1 0x17 2x5 0x8 4x3 0x17 2x3 0x10 4x1 0x184 6x1 0x29 6x7 0x2 6x3 0x19 6x4 5x1 6x6 3x3 6x1 0x17 6x2 5x5 6x3 3x5 0x17 6x2 5x5 6x3 3x5 6x1 0x16 6x2 5x5 6x3 3x5 6x1 0x17 6x2 5x3 6x5 3x3 6x1 0x18 6x7 0x2 6x4 0x335 2x3 0x28 2x5 0x27 2x5 0x8 4x3 0x16 2x5 0x8 4x3 0x17 2x3 0x9 4x3 0x30 4x1 0x151 6x4 0x27 6x7 0x3 6x2 0x19 6x3 5x3 6x9 0x16 6x2 5x7 6x3 3x3 6x1 0x17 6x1 5x7 6x2 3x4 6x2 0x16 6x2 5x5 6x4 3x3 6x1 0x17 6x2 5x5 6x8 0x18 6x7 0x3 6x2 0x23 6x1 0x389 4x1 0x30 4x3 0x30 4x1 0x214 6x7 0x24 6x3 5x2 6x4 0x23 6x2 5x5 6x7 0x18 6x2 5x5 6x2 0x2 6x3 0x18 6x2 5x5 6x3 0x2 6x2 0x19 6x2 5x3 6x2 0x25 6x7 0x699 6x2 0x29 6x5 0x26 6x7 0x25 6x8 0x24 6x7 0x19 7x1 0x5 6x6 0x27 6x4 0x766 6x2 0x53 7x1 0x30 7x3 0x30 7x1 0x863 7x1 0x1082 +case 3 3 2 0 +thresholds 106.51181183698455 75.8366292386967 88.08040264812568 77.38193298987136 41.618196861030235 64.08808807481816 65.99029854330573 43.54573850650545 193523.95641998175 +labels 0x989 1x1 0x30 1x3 0x29 1x3 0x798 1x1 0x30 1x3 0x28 1x5 0x28 1x3 0x30 1x1 0x799 1x1 0x30 1x3 0x29 1x3 0x439 6x1 0x30 6x2 0x23 6x2 0x5 6x4 0x21 6x2 0x5 6x3 0x399 2x3 0x28 2x5 0x27 2x5 0x27 2x5 0x28 2x3 0x235 6x2 0x24 6x3 0x2 6x2 0x26 6x5 3x3 0x22 5x1 0x2 6x1 0x1 6x1 0x1 3x3 0x25 6x1 0x1 6x2 3x3 0x24 6x1 0x3 6x2 0x31 6x2 0x336 2x3 0x28 2x5 0x27 2x5 0x9 4x1 0x17 2x5 0x8 4x3 0x17 2x3 0x10 4x1 0x219 6x2 0x2 6x3 0x23 5x1 0x2 6x4 3x3 0x20 5x5 0x1 6x2 3x5 0x19 5x5 0x1 6x2 3x5 0x19 5x5 0x1 6x2 3x5 0x20 5x3 0x1 6x4 3x3 0x24 6x2 0x2 6x4 0x335 2x3 0x28 2x5 0x27 2x5 0x8 4x3 0x16 2x5 0x8 4x3 0x17 2x3 0x9 4x3 0x30 4x1 0x154 6x1 0x32 6x2 0x3 6x2 0x22 5x3 0x2 6x3 0x22 5x7 0x1 6x1 0x1 3x3 0x19 5x7 6x2 3x4 0x20 5x5 0x1 6x3 3x3 0x20 5x5 0x1 6x4 0x27 6x1 0x3 6x2 0x413 4x1 0x30 4x3 0x30 4x1 0x219 6x2 0x27 5x2 0x3 6x1 0x25 5x5 0x1 6x6 0x20 5x5 0x1 6x1 0x2 6x1 0x1 6x1 0x20 5x5 0x1 6x2 0x2 6x2 0x21 5x3 0x1 6x1 0x30 6x2 0x767 6x1 0x32 6x1 0x30 6x1 0x19 7x1 0x10 6x1 0x30 6x1 0x821 7x1 0x30 7x3 0x30 7x1 0x863 7x1 0x1082 +case 4 3 2 0 +thresholds 106.51181183698455 75.8366292386967 88.08040264812568 77.38193298987136 41.618196861030235 64.08808807481816 65.99029854330573 43.54573850650545 193523.95641998175 +labels 0x989 1x1 0x30 1x3 0x29 1x3 0x798 1x1 0x30 1x3 0x28 1x5 0x28 1x3 0x30 1x1 0x799 1x1 0x30 1x3 0x29 1x3 0x936 2x3 0x28 2x5 0x27 2x5 0x27 2x5 0x28 2x3 0x299 3x3 0x22 5x1 0x6 3x3 0x29 3x3 0x399 2x3 0x28 2x5 0x27 2x5 0x9 4x1 0x17 2x5 0x8 4x3 0x17 2x3 0x10 4x1 0x249 5x1 0x6 3x3 0x21 5x4 0x4 3x4 0x19 5x5 0x3 3x5 0x19 5x5 0x3 3x5 0x20 5x3 0x5 3x2 0x368 2x3 0x28 2x5 0x27 2x5 0x8 4x3 0x16 2x5 0x8 4x3 0x17 2x3 0x9 4x3 0x247 5x3 0x27 5x7 0x3 3x3 0x19 5x7 0x2 3x4 0x20 5x5 0x4 3x3 0x20 5x5 0x451 4x1 0x30 4x3 0x30 4x1 0x248 5x2 0x29 5x5 0x27 5x5 0x27 5x5 0x28 5x3 0x885 6x1 0x863 6x1 0x30 6x3 0x30 6x1 0x863 6x1 0x1082 +case 1 1 20 0 +thresholds 60.0 60.0 60.0 60.0 60.0 60.0 60.0 60.0 60.0 +labels 0x988 1x3 0x29 1x3 0x29 1x3 0x798 1x1 0x29 1x4 0x28 1x5 0x28 1x3 0x30 1x1 0x799 1x2 0x29 1x3 0x29 1x3 0x40 2x3 0x28 2x5 0x27 2x5 0x27 2x5 0x28 2x3 0x732 2x4 0x28 2x5 0x26 2x7 0x25 2x7 0x25 2x7 0x26 2x5 0x28 2x4 0x235 3x1 0x22 3x3 0x5 3x3 0x20 3x5 0x3 3x4 0x20 3x4 0x5 3x3 0x366 2x5 0x26 2x7 0x25 2x7 0x8 4x1 0x16 2x7 0x7 4x3 0x15 2x7 0x6 4x5 0x14 2x7 0x7 4x3 0x16 2x5 0x9 4x2 0x214 3x5 0x4 3x3 0x19 3x14 0x18 3x7 0x2 3x5 0x18 3x7 0x2 3x5 0x19 3x5 0x4 3x3 0x334 2x4 0x28 2x5 0x26 2x7 0x7 4x3 0x15 2x7 0x6 4x5 0x14 2x7 0x6 4x5 0x15 2x6 0x6 4x5 0x16 2x4 0x8 4x3 0x184 3x2 0x27 3x7 0x4 3x1 0x20 3x8 0x1 3x4 0x19 3x8 0x1 3x5 0x18 3x7 0x3 3x3 0x20 3x6 0x28 3x2 0x343 2x3 0x28 2x5 0x9 4x1 0x17 2x5 0x8 4x3 0x16 2x5 0x7 4x4 0x17 2x3 0x9 4x3 0x30 4x1 0x215 3x5 0x26 3x7 0x25 3x7 0x25 3x7 0x26 3x5 0x796 3x4 0x27 3x4 0x29 3x3 0x2896 +case 3 2 10 60 +thresholds 96.7138151703369 75.20522774327122 63.20769809900245 72.00966967814794 48.32020515716168 54.76086956521739 50.32953147713295 32.47232788132369 24.947916666666664 +labels 0x988 1x3 0x28 1x4 0x29 1x3 0x30 1x1 0x766 1x3 0x28 1x5 0x27 1x5 0x27 1x5 0x28 1x3 0x796 1x4 0x28 1x4 0x29 1x3 0x30 1x1 0x1297 3x1 0x5 3x1 0x26 3x2 0x1 3x2 0x63 3x1 0x27 3x1 0x4 3x1 0x445 2x3 0x29 2x3 0x29 2x3 0x30 2x1 0x192 3x2 0x26 3x4 0x29 3x2 0x30 3x2 0x30 3x2 0x29 3x1 0x2 3x1 0x32 3x2 0x380 2x3 0x28 2x5 0x27 2x5 0x27 2x5 0x28 2x3 0x186 3x1 0x33 3x1 0x1 3x1 0x30 3x1 0x30 3x2 0x30 3x3 0x32 3x1 0x445 2x3 0x29 2x3 0x29 2x3 0x30 2x1 0x253 3x1 0x31 3x1 0x31 3x1 0x18 4x1 0x11 3x1 0x820 4x1 0x29 4x3 0x10 3x1 0x18 4x3 0x29 4x3 0x829 4x3 0x29 4x3 0x29 4x3 0x829 4x3 0x29 4x3 0x29 4x3 0x831 4x1 0x31 4x1 0x29 4x1 0x155 +case 1 4 1 0 +thresholds 171.0 96.0 121.0 98.0 10.0 63.0 71.0 13.0 1.0 +labels 1x10752 diff --git a/scijava-ops-image-gpl/src/test/resources/org/scijava/ops/image/gpl/ridge/ridge-expected.txt b/scijava-ops-image-gpl/src/test/resources/org/scijava/ops/image/gpl/ridge/ridge-expected.txt new file mode 100644 index 000000000..0544f630f --- /dev/null +++ b/scijava-ops-image-gpl/src/test/resources/org/scijava/ops/image/gpl/ridge/ridge-expected.txt @@ -0,0 +1,1044 @@ +case 0 3 0 +line 0 89 +63.0 22.24238 3.1415927 14.531937 2.7158382 2.2796016 NaN NaN +62.233627 22.463928 3.6081033 15.825786 2.5724516 2.207733 NaN NaN +61.820988 22.613789 3.5756278 16.354652 2.6228616 2.1532528 NaN NaN +61.09692 23.132767 3.772179 17.736385 2.1874106 2.0718021 NaN NaN +60.57566 23.418575 3.7720568 17.557669 2.2638876 1.995343 NaN NaN +60.011967 24.013643 3.8616073 18.928537 2.0033565 2.026044 NaN NaN +59.526745 24.574663 3.8835125 18.373947 1.9126246 2.1133988 NaN NaN +58.964848 24.961666 3.8837411 19.20728 2.007028 2.0743454 NaN NaN +58.46433 25.513391 3.8768523 18.499924 1.9477153 2.1407552 NaN NaN +57.919235 25.909317 3.869198 19.118801 2.019165 2.0732834 NaN NaN +57.376904 26.441034 3.8487442 18.622004 1.9821444 2.111848 NaN NaN +56.85277 26.821104 3.8301868 18.950293 2.0315688 2.0698698 NaN NaN +56.249413 27.3257 3.7951107 18.734667 2.0290344 2.076166 NaN NaN +55.750145 27.652925 3.7655458 18.743362 2.0669403 2.0438335 NaN NaN +55.085506 28.132477 3.7147524 18.982098 2.0700238 2.0425825 NaN NaN +53.90403 28.811876 3.6133296 18.980978 2.0068262 2.093969 NaN NaN +53.079243 29.195204 3.52722 18.874468 2.0480878 2.085228 NaN NaN +52.159092 29.559269 3.4187376 18.807041 1.927818 2.1509073 NaN NaN +50.968643 29.762892 3.2730763 18.944872 2.0229897 2.0797315 NaN NaN +49.999607 29.832253 3.143944 19.06745 2.0268114 2.0894501 NaN NaN +49.02878 29.772953 3.0154915 19.147753 2.026154 2.073725 NaN NaN +48.103592 29.58548 2.8966994 19.052593 2.0174124 2.0334427 NaN NaN +46.911884 29.217203 2.756192 19.059515 2.044791 2.055374 NaN NaN +46.08763 28.830194 2.665169 19.156866 1.9990959 2.0806482 NaN NaN +44.908295 28.140207 2.5623574 19.166044 2.0611563 2.0236063 NaN NaN +44.24681 27.660149 2.5134807 19.003582 2.0610013 2.0282938 NaN NaN +43.746437 27.332048 2.4894218 18.974133 2.0227985 2.0618412 NaN NaN +43.140656 26.827204 2.4583712 19.227736 2.0290565 2.062607 NaN NaN +42.61388 26.454771 2.4376552 18.758396 1.9745513 2.109136 NaN NaN +42.07005 25.92062 2.4185672 19.239326 2.0174983 2.0741735 NaN NaN +41.51648 25.527758 2.399909 18.466516 1.9345009 2.1474 NaN NaN +41.02666 24.971384 2.3915899 19.137117 2.016167 2.0335028 NaN NaN +40.455864 24.572771 2.3818252 18.317627 1.9123918 2.1144161 NaN NaN +39.998104 24.002005 2.3836088 19.054571 2.0225432 2.0158339 NaN NaN +39.53327 23.432566 2.3872228 18.290741 2.1190255 1.9251032 NaN NaN +38.968037 23.034475 2.394009 19.026686 2.0841894 2.0217874 NaN NaN +38.47089 22.480417 2.4053156 18.347343 2.153474 1.9535663 NaN NaN +37.92324 22.086658 2.416698 18.991274 2.084179 2.0287428 NaN NaN +37.380363 21.55446 2.4349484 18.522678 2.1179423 1.9869866 NaN NaN +36.85522 21.175867 2.4528434 18.898357 2.0741549 2.0379412 NaN NaN +36.25455 20.670328 2.4840753 18.676243 2.0765321 2.0298648 NaN NaN +35.754032 20.33863 2.5133934 18.732456 2.0447562 2.0676038 NaN NaN +35.092327 19.858198 2.5644445 18.950676 2.041275 2.0724876 NaN NaN +33.910896 19.175215 2.6711383 18.984222 2.0960371 2.011323 NaN NaN +33.082947 18.793083 2.760335 18.844595 2.085531 2.055502 NaN NaN +32.15867 18.43082 2.8697257 18.756271 2.1584325 1.9394937 NaN NaN +30.969439 18.23403 3.0117357 18.83656 2.0851293 2.0297396 NaN NaN +30.000013 18.168562 3.1416752 18.870672 2.0983913 2.0324461 NaN NaN +29.03034 18.232628 3.2712874 18.892906 2.0827146 2.0307612 NaN NaN +28.107475 18.423603 3.3900673 18.828087 2.080094 2.0202765 NaN NaN +26.915462 18.788477 3.5218048 18.928314 2.07901 2.0519269 NaN NaN +26.08673 19.170824 3.6113904 19.046232 2.092074 2.005725 NaN NaN +24.905245 19.854773 3.7196991 18.990662 2.0358853 2.0668669 NaN NaN +24.244171 20.3349 3.7715743 18.80538 2.0398424 2.0638382 NaN NaN +23.743118 20.666895 3.7985091 18.750479 2.0722404 2.0258925 NaN NaN +23.141972 21.172812 3.8293278 19.011223 2.0701408 2.034688 NaN NaN +22.61579 21.54882 3.8469958 18.589655 2.1159303 1.981902 NaN NaN +22.073061 22.082598 3.8657932 19.085573 2.0814972 2.0242734 NaN NaN +21.522675 22.475136 3.879591 18.386993 2.153254 1.9453732 NaN NaN +21.029095 23.031263 3.8910797 19.072157 2.0456269 2.0181413 NaN NaN +20.460947 23.43139 3.9003134 18.298285 2.1186557 1.9180212 NaN NaN +20.001629 24.00171 3.902487 19.050406 2.0208447 2.016594 NaN NaN +19.545189 24.574003 3.9012504 18.287317 1.9141675 2.1171682 NaN NaN +18.974548 24.97267 3.8913968 19.077507 2.0177066 2.0375261 NaN NaN +18.482508 25.53043 3.8797147 18.385468 1.9392775 2.1535165 NaN NaN +17.929935 25.9204 3.8633714 19.124287 2.0214303 2.079767 NaN NaN +17.384995 26.454376 3.8445194 18.635864 1.9789608 2.1143322 NaN NaN +16.85867 26.826603 3.8254569 19.093204 2.032875 2.0677433 NaN NaN +16.254496 27.331593 3.796196 18.846792 2.0249355 2.0670953 NaN NaN +15.753698 27.662016 3.7713473 18.901524 2.0629299 2.0341039 NaN NaN +15.093814 28.142904 3.7225096 19.079584 2.0634573 2.029814 NaN NaN +13.914167 28.832792 3.6158447 19.106552 2.0014613 2.0871727 NaN NaN +13.087533 29.217848 3.523655 18.9862 2.0491464 2.0715368 NaN NaN +12.159146 29.58187 3.408572 18.903152 1.9366698 2.1563168 NaN NaN +10.970933 29.766352 3.2653623 19.02801 2.0267918 2.0762167 NaN NaN +10.000379 29.82543 3.1394231 18.993298 2.0271769 2.089826 NaN NaN +9.031213 29.758553 3.013032 18.90471 2.0236225 2.0798378 NaN NaN +8.109883 29.565084 2.8941176 18.729456 2.011027 2.0739698 NaN NaN +6.9197383 29.19993 2.7598395 18.808424 2.0539503 2.0871832 NaN NaN +6.0919023 28.81868 2.6724796 18.917173 2.010251 2.0971591 NaN NaN +4.9096117 28.139626 2.5670745 18.920525 2.071545 2.045445 NaN NaN +4.245318 27.65965 2.517053 18.694199 2.063214 2.0483005 NaN NaN +3.746055 27.332815 2.4898088 18.629335 2.0256488 2.0808353 NaN NaN +3.131396 26.830973 2.4808056 18.663671 2.0291114 2.0882232 NaN NaN +2.6118293 26.491367 2.4729903 18.0285 1.9748598 2.1403403 NaN NaN +2.0111892 25.983875 2.534949 17.760733 2.1944134 2.1579227 NaN NaN +1.2660323 25.402624 2.722625 15.997636 2.6299617 2.095986 NaN NaN +0.8587392 25.286032 2.6828654 15.895717 2.5606031 2.1774058 NaN NaN +1.2926284E-19 25.030088 3.1415927 14.277896 2.6876924 2.2728987 NaN NaN +line 0 4 +1.2926284E-19 25.030088 8.742278E-8 14.277896 2.2728987 2.6876924 NaN NaN +4.3711392E-8 24.0 1.5707964 9.529328 1.7143728 2.2856271 NaN NaN +1.5237536E-16 23.0 1.5707964 9.529328 1.06135785E-8 1.06135785E-8 NaN NaN +-1.00686773E-16 22.0 1.5707964 7.901643 1.06135785E-8 1.06135785E-8 NaN NaN +line 0 2 +63.0 20.0 1.5707964 10.089339 1.6269979E-9 1.6269979E-9 NaN NaN +63.0 19.0 1.5707964 4.3633494 1.6269979E-9 1.6269979E-9 NaN NaN +case 1 3 0 +line 0 89 +63.0 22.24238 3.1415927 14.531937 NaN NaN NaN NaN +62.233627 22.463928 3.6081033 15.825786 NaN NaN NaN NaN +61.820988 22.613789 3.5756278 16.354652 NaN NaN NaN NaN +61.09692 23.132767 3.772179 17.736385 NaN NaN NaN NaN +60.57566 23.418575 3.7720568 17.557669 NaN NaN NaN NaN +60.011967 24.013643 3.8616073 18.928537 NaN NaN NaN NaN +59.526745 24.574663 3.8835125 18.373947 NaN NaN NaN NaN +58.964848 24.961666 3.8837411 19.20728 NaN NaN NaN NaN +58.46433 25.513391 3.8768523 18.499924 NaN NaN NaN NaN +57.919235 25.909317 3.869198 19.118801 NaN NaN NaN NaN +57.376904 26.441034 3.8487442 18.622004 NaN NaN NaN NaN +56.85277 26.821104 3.8301868 18.950293 NaN NaN NaN NaN +56.249413 27.3257 3.7951107 18.734667 NaN NaN NaN NaN +55.750145 27.652925 3.7655458 18.743362 NaN NaN NaN NaN +55.085506 28.132477 3.7147524 18.982098 NaN NaN NaN NaN +53.90403 28.811876 3.6133296 18.980978 NaN NaN NaN NaN +53.079243 29.195204 3.52722 18.874468 NaN NaN NaN NaN +52.159092 29.559269 3.4187376 18.807041 NaN NaN NaN NaN +50.968643 29.762892 3.2730763 18.944872 NaN NaN NaN NaN +49.999607 29.832253 3.143944 19.06745 NaN NaN NaN NaN +49.02878 29.772953 3.0154915 19.147753 NaN NaN NaN NaN +48.103592 29.58548 2.8966994 19.052593 NaN NaN NaN NaN +46.911884 29.217203 2.756192 19.059515 NaN NaN NaN NaN +46.08763 28.830194 2.665169 19.156866 NaN NaN NaN NaN +44.908295 28.140207 2.5623574 19.166044 NaN NaN NaN NaN +44.24681 27.660149 2.5134807 19.003582 NaN NaN NaN NaN +43.746437 27.332048 2.4894218 18.974133 NaN NaN NaN NaN +43.140656 26.827204 2.4583712 19.227736 NaN NaN NaN NaN +42.61388 26.454771 2.4376552 18.758396 NaN NaN NaN NaN +42.07005 25.92062 2.4185672 19.239326 NaN NaN NaN NaN +41.51648 25.527758 2.399909 18.466516 NaN NaN NaN NaN +41.02666 24.971384 2.3915899 19.137117 NaN NaN NaN NaN +40.455864 24.572771 2.3818252 18.317627 NaN NaN NaN NaN +39.998104 24.002005 2.3836088 19.054571 NaN NaN NaN NaN +39.53327 23.432566 2.3872228 18.290741 NaN NaN NaN NaN +38.968037 23.034475 2.394009 19.026686 NaN NaN NaN NaN +38.47089 22.480417 2.4053156 18.347343 NaN NaN NaN NaN +37.92324 22.086658 2.416698 18.991274 NaN NaN NaN NaN +37.380363 21.55446 2.4349484 18.522678 NaN NaN NaN NaN +36.85522 21.175867 2.4528434 18.898357 NaN NaN NaN NaN +36.25455 20.670328 2.4840753 18.676243 NaN NaN NaN NaN +35.754032 20.33863 2.5133934 18.732456 NaN NaN NaN NaN +35.092327 19.858198 2.5644445 18.950676 NaN NaN NaN NaN +33.910896 19.175215 2.6711383 18.984222 NaN NaN NaN NaN +33.082947 18.793083 2.760335 18.844595 NaN NaN NaN NaN +32.15867 18.43082 2.8697257 18.756271 NaN NaN NaN NaN +30.969439 18.23403 3.0117357 18.83656 NaN NaN NaN NaN +30.000013 18.168562 3.1416752 18.870672 NaN NaN NaN NaN +29.03034 18.232628 3.2712874 18.892906 NaN NaN NaN NaN +28.107475 18.423603 3.3900673 18.828087 NaN NaN NaN NaN +26.915462 18.788477 3.5218048 18.928314 NaN NaN NaN NaN +26.08673 19.170824 3.6113904 19.046232 NaN NaN NaN NaN +24.905245 19.854773 3.7196991 18.990662 NaN NaN NaN NaN +24.244171 20.3349 3.7715743 18.80538 NaN NaN NaN NaN +23.743118 20.666895 3.7985091 18.750479 NaN NaN NaN NaN +23.141972 21.172812 3.8293278 19.011223 NaN NaN NaN NaN +22.61579 21.54882 3.8469958 18.589655 NaN NaN NaN NaN +22.073061 22.082598 3.8657932 19.085573 NaN NaN NaN NaN +21.522675 22.475136 3.879591 18.386993 NaN NaN NaN NaN +21.029095 23.031263 3.8910797 19.072157 NaN NaN NaN NaN +20.460947 23.43139 3.9003134 18.298285 NaN NaN NaN NaN +20.001629 24.00171 3.902487 19.050406 NaN NaN NaN NaN +19.545189 24.574003 3.9012504 18.287317 NaN NaN NaN NaN +18.974548 24.97267 3.8913968 19.077507 NaN NaN NaN NaN +18.482508 25.53043 3.8797147 18.385468 NaN NaN NaN NaN +17.929935 25.9204 3.8633714 19.124287 NaN NaN NaN NaN +17.384995 26.454376 3.8445194 18.635864 NaN NaN NaN NaN +16.85867 26.826603 3.8254569 19.093204 NaN NaN NaN NaN +16.254496 27.331593 3.796196 18.846792 NaN NaN NaN NaN +15.753698 27.662016 3.7713473 18.901524 NaN NaN NaN NaN +15.093814 28.142904 3.7225096 19.079584 NaN NaN NaN NaN +13.914167 28.832792 3.6158447 19.106552 NaN NaN NaN NaN +13.087533 29.217848 3.523655 18.9862 NaN NaN NaN NaN +12.159146 29.58187 3.408572 18.903152 NaN NaN NaN NaN +10.970933 29.766352 3.2653623 19.02801 NaN NaN NaN NaN +10.000379 29.82543 3.1394231 18.993298 NaN NaN NaN NaN +9.031213 29.758553 3.013032 18.90471 NaN NaN NaN NaN +8.109883 29.565084 2.8941176 18.729456 NaN NaN NaN NaN +6.9197383 29.19993 2.7598395 18.808424 NaN NaN NaN NaN +6.0919023 28.81868 2.6724796 18.917173 NaN NaN NaN NaN +4.9096117 28.139626 2.5670745 18.920525 NaN NaN NaN NaN +4.245318 27.65965 2.517053 18.694199 NaN NaN NaN NaN +3.746055 27.332815 2.4898088 18.629335 NaN NaN NaN NaN +3.131396 26.830973 2.4808056 18.663671 NaN NaN NaN NaN +2.6118293 26.491367 2.4729903 18.0285 NaN NaN NaN NaN +2.0111892 25.983875 2.534949 17.760733 NaN NaN NaN NaN +1.2660323 25.402624 2.722625 15.997636 NaN NaN NaN NaN +0.8587392 25.286032 2.6828654 15.895717 NaN NaN NaN NaN +1.2926284E-19 25.030088 3.1415927 14.277896 NaN NaN NaN NaN +line 0 2 +1.5237536E-16 23.0 1.5707964 9.529328 NaN NaN NaN NaN +-1.00686773E-16 22.0 1.5707964 7.901643 NaN NaN NaN NaN +line 0 2 +63.0 20.0 1.5707964 10.089339 NaN NaN NaN NaN +63.0 19.0 1.5707964 4.3633494 NaN NaN NaN NaN +case 2 1 0 +line 0 94 +63.0 41.017807 3.1415927 13.401813 2.9837334 2.9837334 -0.012386167 -134.96832 +62.02291 40.90595 2.9026408 13.9357 2.9862182 2.9862182 0.023544101 -141.30745 +60.803833 40.504654 2.7708492 14.894093 2.8336375 2.8336375 0.034168825 -142.22643 +59.961536 40.078423 2.6855836 15.632379 2.722893 2.722893 0.016225789 -141.83673 +59.181522 39.664314 2.645878 15.93946 2.6741033 2.6741033 0.012042446 -141.89708 +58.752598 39.457073 2.6454737 15.966514 2.673947 2.673947 0.012042293 -142.13043 +57.994144 39.01038 2.627951 16.13091 2.6232653 2.6232653 0.00444332 -140.69873 +57.247993 38.567482 2.6209865 16.096436 2.6510031 2.6510031 0.0015574733 -141.45064 +56.805687 38.337387 2.6190588 16.109716 2.6500928 2.6500928 0.001557358 -141.5252 +56.056515 37.90254 2.616111 16.16441 2.6375551 2.6375551 0.0100852065 -142.03815 +55.328766 37.43493 2.6146529 16.09809 2.6447475 2.6447475 -0.003467519 -141.31236 +54.87812 37.210064 2.615852 16.167183 2.6453328 2.6453328 -0.0034676841 -141.94586 +54.127483 36.779617 2.617166 16.178135 2.6463826 2.6463826 0.008352435 -142.44043 +53.67877 36.556305 2.6179264 16.105503 2.646748 2.646748 0.008352683 -141.81783 +52.94695 36.092247 2.6196685 16.173025 2.6371264 2.6371264 -0.003642998 -141.63354 +52.19486 35.660294 2.620804 16.116814 2.651807 2.651807 0.0074575767 -142.08778 +51.75227 35.430023 2.6189418 16.074842 2.6509264 2.6509264 0.0074570426 -141.67688 +51.008953 34.984467 2.618583 16.12686 2.6192944 2.6192944 0.005630924 -140.57152 +50.26898 34.534733 2.6174202 16.06587 2.6509933 2.6509933 0.0021519563 -141.22365 +49.824608 34.303844 2.6180716 16.09587 2.651306 2.651306 0.002152011 -141.50182 +49.07452 33.871048 2.6175897 16.149542 2.6424146 2.6424146 0.011453629 -142.22873 +48.346725 33.40005 2.6175644 16.084877 2.6451032 2.6451032 -0.0018860081 -141.10071 +47.89518 33.181625 2.6181543 16.15806 2.6453862 2.6453862 -0.0018860515 -141.75572 +47.143715 32.75098 2.6181684 16.152544 2.6479838 2.6479838 0.008395255 -142.29236 +46.696888 32.5247 2.6177418 16.086508 2.6477795 2.6477795 0.008395115 -141.70117 +45.96119 32.06725 2.6182003 16.16048 2.6173928 2.6173928 0.0026822216 -140.5721 +45.215378 31.626616 2.618388 16.11895 2.6483035 2.6483035 0.0030741927 -141.6313 +44.77277 31.394775 2.6193137 16.113276 2.6487443 2.6487443 0.0030743028 -141.6018 +44.02718 30.952826 2.6188767 16.189787 2.6160307 2.6160307 0.0035380824 -140.82745 +43.288956 30.4995 2.6180022 16.138563 2.6465752 2.6465752 -8.584168E-4 -141.56654 +42.843494 30.271486 2.6186574 16.186653 2.6468883 2.6468883 -8.584386E-4 -142.00285 +42.091904 29.840967 2.6175902 16.21508 2.6420696 2.6420696 0.010517008 -142.72224 +40.912945 29.150047 2.6158834 16.194862 2.6404295 2.6404295 -0.0024111534 -141.88805 +40.16374 28.71755 2.6162276 16.160528 2.647616 2.647616 0.0077659804 -142.30042 +39.71759 28.486618 2.6157498 16.10401 2.6473837 2.6473837 0.0077658338 -141.792 +38.980545 28.033672 2.6176403 16.174456 2.616189 2.616189 0.0036286481 -140.70746 +38.23642 27.589466 2.6190999 16.12707 2.6483252 2.6483252 0.002155061 -141.63834 +37.794125 27.358688 2.620537 16.138288 2.649005 2.649005 0.0021551803 -141.76831 +37.045914 26.920137 2.619825 16.198917 2.6170468 2.6170468 0.0035319417 -140.95126 +36.31051 26.461983 2.6181524 16.125572 2.64539 2.64539 -0.0021041983 -141.48634 +35.86303 26.236406 2.6164715 16.176287 2.6445813 2.6445813 -0.0021040598 -141.89404 +35.113567 25.804594 2.6151197 16.177105 2.6429806 2.6429806 0.0081463605 -142.25937 +34.661346 25.581488 2.61422 16.092884 2.6425395 2.6425395 0.008146068 -141.4985 +33.93566 25.110945 2.616074 16.170479 2.6360624 2.6360624 -0.00597965 -141.72928 +33.18609 24.677204 2.61864 16.14124 2.6467168 2.6467168 0.003145618 -141.75896 +32.74325 24.447573 2.6207733 16.115252 2.6477265 2.6477265 0.0031458763 -141.57727 +31.998837 24.002033 2.622407 16.191551 2.6151237 2.6151237 0.0017444538 -140.67625 +31.252632 23.557844 2.6224957 16.133514 2.6489072 2.6489072 7.484989E-4 -141.62204 +30.810965 23.32988 2.621229 16.146843 2.6483169 2.6483169 7.48463E-4 -141.71175 +30.058601 22.8984 2.618424 16.179253 2.639357 2.639357 0.0122037195 -142.40388 +29.330477 22.431286 2.6151905 16.09198 2.646835 2.646835 -8.590994E-4 -141.1699 +28.879723 22.206226 2.6135933 16.147682 2.6460512 2.6460512 -8.590446E-4 -141.62244 +28.132484 21.77292 2.6134412 16.154942 2.6472945 2.6472945 0.008640996 -142.29918 +27.683006 21.545698 2.6153376 16.089249 2.6482255 2.6482255 0.008641651 -141.76358 +26.950956 21.084969 2.6181147 16.182465 2.6193056 2.6193056 0.0028095015 -140.85684 +26.202776 20.646423 2.620885 16.14876 2.6502812 2.6502812 0.0042174445 -142.06642 +25.761042 20.416994 2.6212246 16.12455 2.6504405 2.6504405 0.0042174994 -141.86082 +25.014929 19.973976 2.6207838 16.175116 2.6181204 2.6181204 0.0033280756 -140.77713 +24.27381 19.52449 2.6191406 16.09304 2.650293 2.650293 7.435879E-4 -141.33035 +23.829527 19.294645 2.6170778 16.11579 2.6493058 2.6493058 7.435282E-4 -141.4845 +23.080212 18.861792 2.6157367 16.146784 2.6417086 2.6417086 0.010496748 -142.10306 +21.901894 18.16956 2.6170714 16.16209 2.6431265 2.6431265 -0.0030103107 -141.76707 +21.150547 17.738876 2.6186059 16.169842 2.64612 2.64612 0.007777642 -142.31396 +20.705372 17.513117 2.620365 16.117252 2.6469536 2.6469536 0.0077781696 -141.88962 +19.966158 17.059076 2.6213567 16.193792 2.6159873 2.6159873 0.003282737 -140.84233 +19.21782 16.619514 2.6216543 16.133793 2.6486008 2.6486008 0.0038606848 -141.83147 +18.776087 16.389236 2.6195588 16.115055 2.6476152 2.6476152 0.0038603751 -141.62122 +18.030584 15.947059 2.6177213 16.158163 2.616472 2.616472 0.0032187642 -140.54926 +17.295244 15.491704 2.6153722 16.085598 2.6473055 2.6473055 -0.001472483 -141.17888 +16.848045 15.261656 2.6154587 16.134132 2.6473477 2.6473477 -0.001472488 -141.6068 +16.099085 14.829438 2.615315 16.170658 2.643523 2.643523 0.008976909 -142.28735 +14.9201355 14.138367 2.61811 16.194754 2.6411033 2.6411033 -0.004107325 -142.03885 +14.168774 13.706999 2.6189954 16.17776 2.6476257 2.6476257 0.006170441 -142.33804 +13.724467 13.478128 2.618802 16.130882 2.6475337 2.6475337 0.0061703944 -141.92134 +12.984649 13.026613 2.6183963 16.197674 2.614479 2.614479 0.0019327349 -140.71439 +12.241623 12.581792 2.6176884 16.135008 2.6463902 2.6463902 7.499282E-4 -141.51915 +11.7981205 12.34913 2.6173298 16.146072 2.6462178 2.6462178 7.4991764E-4 -141.60826 +11.052161 11.909914 2.6167374 16.199427 2.61535 2.61535 0.0028569668 -140.83316 +10.3194685 11.448813 2.6163087 16.132607 2.6427019 2.6427019 -0.0029015176 -141.48128 +9.870458 11.224062 2.617392 16.20047 2.643225 2.643225 -0.0029016412 -142.10051 +9.118781 10.794276 2.6179717 16.209963 2.6427007 2.6427007 0.008967542 -142.59453 +8.6690035 10.573006 2.6177692 16.13198 2.6426036 2.6426036 0.008967471 -141.90408 +7.939004 10.105783 2.6185513 16.199505 2.6348822 2.6348822 -0.0041554333 -141.79976 +7.1884837 9.672441 2.619444 16.146276 2.6477635 2.6477635 0.0048402436 -141.97235 +6.7451987 9.441766 2.6184218 16.105656 2.6472769 2.6472769 0.004840052 -141.59271 +6.003105 8.994597 2.6199255 16.159363 2.6161482 2.6161482 0.0013887405 -140.41655 +5.2581263 8.5461445 2.624475 16.078585 2.6531172 2.6531172 -0.0029390766 -141.48962 +4.818008 8.32141 2.6263742 16.088194 2.6539795 2.6539795 -0.002939283 -141.61427 +4.057742 7.8940606 2.6425571 16.026285 2.6621952 2.6621952 0.0017139713 -141.3655 +3.2781203 7.432819 2.6856902 15.659128 2.7175307 2.7175307 -0.01909753 -142.01718 +2.8697498 7.26282 2.6814814 15.587994 2.7165084 2.7165084 -0.019096313 -141.32083 +2.0492194 6.8755827 2.764885 14.927757 2.8332703 2.8332703 -0.035212774 -142.60446 +1.103152 6.546584 2.9179 13.920386 2.9550493 2.9550493 -0.013948897 -138.49399 +9.867204E-10 6.4224896 3.1415927 13.552982 2.9762414 2.9762414 0.0191846 -136.4967 +case 3 1 0 +line 0 48 +32.418976 47.0 1.5707964 14.094225 3.0353456 2.386394 NaN NaN +32.42075 46.001118 1.5681399 14.079726 3.0358844 2.3863978 NaN NaN +32.424633 45.001404 1.5674936 14.048843 3.0368981 2.3864074 NaN NaN +32.427673 44.000645 1.5692848 14.027409 3.0373821 2.3861845 NaN NaN +32.42752 42.999413 1.5721688 14.034833 3.0366004 2.3857415 NaN NaN +32.424236 41.998627 1.5740342 14.067827 3.0348303 2.385195 NaN NaN +32.420193 40.998875 1.5734712 14.104334 3.0330622 2.3847122 NaN NaN +32.418274 39.999912 1.5710015 14.12093 3.032287 2.3843682 NaN NaN +32.41955 39.00084 1.5687959 14.109493 3.032836 2.38413 NaN NaN +32.42234 38.00083 1.5688285 14.084015 3.034177 2.3840399 NaN NaN +32.423725 36.999935 1.5709518 14.068807 3.0352871 2.3842692 NaN NaN +32.422318 35.999058 1.573029 14.075782 3.0355968 2.384902 NaN NaN +32.41959 34.999115 1.5729085 14.093255 3.0354784 2.385774 NaN NaN +32.418514 34.000137 1.5704652 14.098207 3.03573 2.386571 NaN NaN +32.42075 33.00127 1.5677745 14.078873 3.0366518 2.3870678 NaN NaN +32.425087 32.00158 1.5670836 14.045888 3.0377207 2.3871844 NaN NaN +32.428497 31.000792 1.5689502 14.023889 3.0380247 2.3869436 NaN NaN +32.428608 29.9995 1.5719619 14.030933 3.0370512 2.3864427 NaN NaN +32.425472 28.998663 1.573937 14.063702 3.0351508 2.3858428 NaN NaN +32.42149 27.998888 1.5734354 14.10037 3.0333133 2.3853102 NaN NaN +32.419586 26.999918 1.5709914 14.117122 3.0325022 2.3849237 NaN NaN +32.420868 26.000843 1.5687944 14.105756 3.0330436 2.38467 NaN NaN +32.42366 25.000832 1.568834 14.080212 3.0343854 2.3845923 NaN NaN +32.425034 23.999926 1.5709705 14.064891 3.0354903 2.384855 NaN NaN +32.423588 22.999022 1.5731044 14.071883 3.0358171 2.3854733 NaN NaN +32.420685 21.999023 1.5731164 14.090258 3.0356922 2.386284 NaN NaN +32.419224 20.99992 1.5709856 14.09725 3.0360377 2.3868475 NaN NaN +32.420578 20.000813 1.5688645 14.082379 3.0371516 2.3870091 NaN NaN +32.4233 19.000805 1.5688932 14.057211 3.0385168 2.3867993 NaN NaN +32.42445 17.999847 1.5711571 14.046078 3.0390959 2.3864036 NaN NaN +32.42235 16.99879 1.5736606 14.063516 3.0382943 2.3859036 NaN NaN +32.418114 15.998562 1.5742353 14.100902 3.0365012 2.385246 NaN NaN +32.414654 14.999372 1.5723122 14.134346 3.0346918 2.3844671 NaN NaN +32.414375 14.000614 1.5693147 14.143204 3.0337188 2.3837826 NaN NaN +32.417355 13.00141 1.5674196 14.124257 3.0338159 2.3833582 NaN NaN +32.421303 12.001186 1.5679811 14.094575 3.034454 2.3831909 NaN NaN +32.42332 11.000153 1.5704352 14.078688 3.0346835 2.3832588 NaN NaN +32.42223 9.999193 1.5727069 14.087349 3.0340493 2.3835783 NaN NaN +32.419582 8.999132 1.5728657 14.107466 3.0331826 2.3840656 NaN NaN +32.41821 7.9999266 1.5709718 14.115188 3.0332048 2.3845685 NaN NaN +32.419514 7.000734 1.5690472 14.099141 3.034589 2.3849778 NaN NaN +32.42213 6.0007052 1.5691252 14.071555 3.0366461 2.3851972 NaN NaN +32.423157 4.9997797 1.5713171 14.058691 3.0379672 2.3852162 NaN NaN +32.420902 3.9987073 1.5738677 14.076614 3.0377073 2.3848047 NaN NaN +32.416092 2.9982746 1.5749431 14.120285 3.0360787 2.3837588 NaN NaN +32.411026 1.9986055 1.5741891 14.169904 3.0341036 2.3820596 NaN NaN +32.407513 0.9992927 1.572532 14.207 3.032571 2.3804498 NaN NaN +32.406338 -2.6658188E-17 1.5707964 14.220168 3.0320492 2.3797383 NaN NaN +case 4 1 0 +line 0 48 +32.032806 47.0 1.5707964 14.094225 2.6143966 2.6143966 0.44609463 166.91136 +32.03427 46.00009 1.5681399 14.079726 2.6147177 2.6147177 0.4463876 166.80489 +32.03745 45.000126 1.5674936 14.048843 2.6153135 2.6153135 0.44704083 166.5783 +32.039864 44.000057 1.5692848 14.027409 2.615432 2.615432 0.44756627 166.41815 +32.039585 42.999947 1.5721688 14.034833 2.6146443 2.6146443 0.44757277 166.46794 +32.036713 41.99988 1.5740342 14.067827 2.6132035 2.6132035 0.4470631 166.70157 +32.033226 40.99991 1.5734712 14.104334 2.6118162 2.6118162 0.44643942 166.95988 +32.031525 39.999992 1.5710015 14.12093 2.611122 2.611122 0.4461741 167.07704 +32.032528 39.000065 1.5687959 14.109493 2.6112983 2.6112983 0.44642252 166.99228 +32.03486 38.00007 1.5688285 14.084015 2.612058 2.612058 0.4468888 166.80687 +32.036137 36.999996 1.5709518 14.068807 2.6129007 2.6129007 0.44707787 166.70053 +32.035213 35.999924 1.573029 14.075782 2.6135366 2.6135366 0.44675887 166.76138 +32.03324 34.99993 1.5729085 14.093255 2.6140733 2.6140733 0.44620368 166.902 +32.032597 34.00001 1.5704652 14.098207 2.6147723 2.6147723 0.44593474 166.95052 +32.034588 33.000103 1.5677745 14.078873 2.615655 2.615655 0.4462418 166.81728 +32.03818 32.00014 1.5670836 14.045888 2.6163514 2.6163514 0.4469368 166.57779 +32.040882 31.000076 1.5689502 14.023889 2.6163373 2.6163373 0.44751653 166.41335 +32.040794 29.999952 1.5719619 14.030933 2.6153855 2.6153855 0.44756407 166.45728 +32.03803 28.99988 1.573937 14.063702 2.6138248 2.6138248 0.44707322 166.68542 +32.03459 27.999908 1.5734354 14.10037 2.612361 2.612361 0.44644818 166.94167 +32.032913 26.999994 1.5709914 14.117122 2.6116176 2.6116176 0.4461735 167.05664 +32.03392 26.000069 1.5687944 14.105756 2.6117792 2.6117792 0.4464171 166.97113 +32.03625 25.00007 1.568834 14.080212 2.612547 2.612547 0.446889 166.78627 +32.037502 23.999992 1.5709705 14.064891 2.6134064 2.6134064 0.44709232 166.68181 +32.036526 22.999914 1.5731044 14.071883 2.6140428 2.6140428 0.4467853 166.74493 +32.03438 21.99992 1.5731164 14.090258 2.6145368 2.6145368 0.446223 166.89294 +32.033398 20.999992 1.5709856 14.09725 2.6151505 2.6151505 0.44590595 166.95331 +32.034657 20.000067 1.5688645 14.082379 2.6159534 2.6159534 0.44608122 166.84686 +32.03695 19.00007 1.5688932 14.057211 2.6166549 2.6166549 0.4465189 166.65718 +32.037857 17.999987 1.5711571 14.046078 2.6167529 2.6167529 0.44672737 166.56505 +32.03603 16.999897 1.5736606 14.063516 2.6159465 2.6159465 0.44640955 166.67805 +32.03239 15.999888 1.5742353 14.100902 2.6144352 2.6144352 0.44573623 166.9322 +32.029358 14.999955 1.5723122 14.134346 2.612824 2.612824 0.44519186 167.15587 +32.028934 14.000043 1.5693147 14.143204 2.6117601 2.6117601 0.44518173 167.20566 +32.03121 13.000106 1.5674196 14.124257 2.611498 2.611498 0.44572532 167.0598 +32.03431 12.000097 1.5679811 14.094575 2.611732 2.611732 0.4464495 166.84186 +32.035843 11.000012 1.5704352 14.078688 2.6118836 2.6118836 0.44686523 166.73112 +32.034847 9.999933 1.5727069 14.087349 2.6116912 2.6116912 0.44676304 166.80695 +32.03264 8.999933 1.5728657 14.107466 2.6114838 2.6114838 0.44637892 166.9702 +32.0316 7.9999943 1.5709718 14.115188 2.611843 2.611843 0.44614676 167.04053 +32.03291 7.0000577 1.5690472 14.099141 2.612983 2.612983 0.44628093 166.9301 +32.035366 6.000059 1.5691252 14.071555 2.6144168 2.6144168 0.44658682 166.72644 +32.036446 4.999981 1.5713171 14.058691 2.6152725 2.6152725 0.44664484 166.62657 +32.034687 3.9998934 1.5738677 14.076614 2.6148837 2.6148837 0.44619367 166.74379 +32.030666 2.999873 1.5749431 14.120285 2.613245 2.613245 0.44534922 167.03705 +32.02635 1.9999106 1.5741891 14.169904 2.6109686 2.6109686 0.44446295 167.36089 +32.023308 0.9999595 1.572532 14.207 2.6090097 2.6090097 0.44383925 167.5961 +32.02228 1.6787565E-8 1.5707964 14.220168 2.6082382 2.6082382 0.4436238 167.67668 +case 5 4 6 +line 2 32 +63.0 34.88355 3.1415927 32.6997 1.8514304 1.8514304 -0.018013496 146.95117 +62.04327 34.772575 2.9535909 32.941395 1.8058987 1.8058987 -0.022915937 145.83202 +60.84206 34.50724 2.8397415 32.663414 1.7265675 1.7265675 -0.004411421 139.49214 +59.94711 34.150185 2.8029838 33.309452 1.7742538 1.7742538 -0.029804958 146.33545 +59.053967 33.85137 2.7932897 33.2653 1.7609931 1.7609931 0.015636977 144.43579 +57.84386 33.426865 2.790926 32.671852 1.7472804 1.7472804 -0.0031120381 140.33664 +56.973858 33.07219 2.7941263 33.426285 1.7475111 1.7475111 -0.021437451 144.92967 +56.08358 32.76731 2.796754 33.223595 1.7691665 1.7691665 0.017532274 144.78899 +54.87678 32.34279 2.7965164 32.99511 1.7531427 1.7531427 -0.0029752243 141.98285 +54.00041 31.998869 2.7950475 33.486675 1.7353711 1.7353711 -0.027382713 145.08246 +53.11394 31.686115 2.7933886 32.980408 1.7732809 1.7732809 0.0090858545 143.31332 +51.907806 31.253384 2.792626 33.122265 1.7672896 1.7672896 -0.01270175 143.90173 +51.019474 30.946754 2.7909503 33.474075 1.7407004 1.7407004 0.030771349 145.524 +49.80347 30.539183 2.792066 32.50767 1.7451977 1.7451977 0.0078060287 139.869 +48.940693 30.163767 2.7941458 33.56387 1.7565094 1.7565094 -0.012336282 145.27342 +48.05115 29.8584 2.7949548 33.666626 1.7536366 1.7536366 0.023235599 146.39505 +46.84162 29.43506 2.792469 33.057137 1.7418838 1.7418838 -0.0038755103 141.80331 +45.971844 29.077055 2.7912962 33.760155 1.7412857 1.7412857 -0.025779646 146.41583 +45.08457 28.770016 2.7892027 33.50979 1.7595483 1.7595483 0.016664246 145.5042 +43.877865 28.33119 2.7882893 33.317722 1.7669837 1.7669837 -0.0055892547 144.21646 +43.00012 27.999674 2.7905974 33.871986 1.7434378 1.7434378 0.0036293305 145.35196 +42.119938 27.669672 2.7933161 33.40516 1.7682754 1.7682754 0.011681569 145.10374 +40.91397 27.239489 2.7967336 33.63022 1.7605894 1.7605894 -0.014503632 145.9165 +40.02426 26.932783 2.795214 33.860092 1.7366822 1.7366822 0.02026422 146.22298 +39.15346 26.578627 2.792327 33.06372 1.7394781 1.7394781 3.9701298E-4 141.47766 +37.945904 26.146782 2.788498 33.52331 1.7513714 1.7513714 -0.027703878 146.00124 +37.059544 25.837473 2.7904289 33.360104 1.7569001 1.7569001 0.008393461 144.12186 +36.185547 25.4673 2.8064225 31.791655 1.8099017 1.8099017 0.011273752 140.11508 +34.98025 25.052711 2.7830899 29.390282 1.9793769 1.9793769 -0.041633442 141.72646 +33.9929 25.026096 2.8758857 22.827387 1.0438664 1.0438664 0.32075953 123.222 +32.811954 24.252857 2.5021522 13.688454 1.4972028 1.4972028 -0.2724138 65.7704 +32.0077 23.999922 1.5809772 11.093492 1.822583 1.822583 0.0071299043 49.023144 +line 2 24 +24.026995 47.0 1.5707961 32.30844 1.8674115 1.8674115 -0.012994524 145.78458 +24.131378 46.025646 1.3780075 32.809624 1.8270876 1.8270876 -0.017869534 146.03307 +24.37468 45.115665 1.2713672 33.030994 1.7319976 1.7319976 -0.007681971 141.52797 +24.756752 43.91415 1.2315193 33.47405 1.7783815 1.7783815 -0.012220797 145.94463 +25.0642 43.02336 1.221799 33.801876 1.7450162 1.7450162 0.022801202 146.54448 +25.473423 41.80595 1.217724 32.70716 1.745034 1.745034 0.0014805066 140.27312 +25.848824 40.944958 1.2216069 33.506374 1.7554141 1.7554141 -0.02415751 145.85165 +26.150282 40.054245 1.224403 33.441845 1.7553953 1.7553953 0.020151567 145.27057 +26.569765 38.845425 1.2258703 32.831154 1.744347 1.744347 -0.0024763376 140.84451 +26.92247 37.97216 1.2260362 33.588318 1.7436917 1.7436917 -0.022395557 145.5272 +27.227448 37.08272 1.2219688 33.425636 1.7626523 1.7626523 0.015978804 145.23706 +27.662546 35.875343 1.2169391 33.266846 1.7464417 1.7464417 -0.003979397 142.91638 +28.011913 35.004406 1.2168304 33.822758 1.7245445 1.7245445 -0.02634301 145.97774 +28.330656 34.120636 1.2209731 33.277256 1.7696886 1.7696886 0.009306613 144.44324 +28.762857 32.914524 1.2248513 33.376617 1.7625763 1.7625763 -0.016338367 145.047 +29.067877 32.024406 1.2256132 33.561115 1.739763 1.739763 0.02092623 145.12029 +29.420576 31.152407 1.2231399 32.81234 1.7418346 1.7418346 7.1042386E-4 140.52785 +29.84533 29.943804 1.2222978 33.38274 1.7541469 1.7541469 -0.022648394 145.14082 +30.15125 29.05496 1.2222525 33.32362 1.7560434 1.7560434 0.014151592 144.3447 +30.518866 28.181633 1.234069 31.8796 1.8081845 1.8081845 0.012965345 140.53397 +30.941498 26.9777 1.2066318 29.470177 1.9785709 1.9785709 -0.04420385 142.24614 +30.969915 25.991564 1.2973896 22.972733 1.0460553 1.0460553 0.3217633 123.99252 +31.754496 24.820694 0.939985 13.749182 1.4859806 1.4859806 -0.2646204 65.621216 +32.01127 23.999886 1.5809771 11.093492 1.822583 1.822583 0.0071299043 49.022697 +line 1 33 +32.01127 23.999886 1.5809772 11.093492 1.822583 1.822583 0.0071299043 49.022697 +31.203339 23.734596 2.4878325 13.513467 1.4910697 1.4910697 0.27585453 65.0657 +30.01 22.964283 2.8686137 22.690912 1.0884088 1.0884088 -0.32547715 120.6355 +29.026121 22.930635 2.7814233 29.177126 1.9873819 1.9873819 0.038065325 141.00761 +27.823404 22.517923 2.8129876 31.755663 1.8157659 1.8157659 -0.016356423 140.6261 +26.94378 22.15714 2.7980227 33.19697 1.766171 1.766171 -0.013470689 144.22855 +26.053915 21.851183 2.7940068 33.27168 1.7615385 1.7615385 0.01871666 144.71695 +24.8437 21.425041 2.789215 32.673786 1.7490067 1.7490067 -0.0021984188 140.35812 +23.974985 21.068401 2.790993 33.421032 1.7476754 1.7476754 -0.021207582 144.89742 +23.086584 20.761852 2.7928743 33.245735 1.769615 1.769615 0.020327201 145.1148 +21.880627 20.332424 2.7968357 33.165478 1.7731321 1.7731321 -0.003096 143.67606 +20.998026 20.00548 2.7958565 33.719303 1.7512002 1.7512002 0.00492594 145.15027 +20.116482 19.678097 2.7944005 33.203712 1.771658 1.771658 0.012037295 144.4198 +18.910547 19.244804 2.7912633 33.303303 1.7659069 1.7659069 -0.011388181 144.52484 +18.02309 18.937101 2.7897635 33.566593 1.7401992 1.7401992 0.030574802 145.88841 +16.807505 18.525698 2.7905843 32.59985 1.7424452 1.7424452 0.009482942 140.26239 +15.944985 18.151741 2.7937775 33.610977 1.7499603 1.7499603 -0.012402407 145.1737 +15.05533 17.846834 2.7949367 33.697838 1.7512827 1.7512827 0.024097452 146.48422 +13.847098 17.420906 2.7931478 33.163677 1.7410331 1.7410331 -0.005549051 142.34206 +12.976473 17.06444 2.7915297 33.841557 1.7381781 1.7381781 -0.028970161 146.86838 +12.089818 16.755268 2.789852 33.512226 1.7604015 1.7604015 0.010002837 145.06377 +10.883158 16.316282 2.787719 33.29664 1.7665904 1.7665904 -0.01203217 144.57622 +9.999371 16.001717 2.789966 33.7767 1.725958 1.725958 0.029314043 146.06528 +9.1261015 15.654302 2.7918177 33.273556 1.7457851 1.7457851 0.0062022004 143.07527 +7.918615 15.22616 2.796165 33.57269 1.7624279 1.7624279 -0.013181646 145.65762 +7.029053 14.919464 2.7953749 33.803234 1.7445946 1.7445946 0.020937264 146.39056 +6.1582527 14.563493 2.7937882 32.997032 1.7436647 1.7436647 8.121058E-4 141.40762 +4.950266 14.135919 2.7908187 33.482365 1.7529094 1.7529094 -0.028160205 145.92984 +4.0608735 13.832783 2.7924645 33.351562 1.7583345 1.7583345 0.016861498 144.77394 +3.193255 13.457543 2.7993531 32.35227 1.756405 1.756405 -0.002261343 139.31458 +1.9695181 13.098927 2.842696 33.113255 1.8022065 1.8022065 0.0076669063 145.26208 +1.0305493 12.840782 2.9520257 32.24945 1.844473 1.844473 -0.014643859 144.28352 +-9.031714E-10 12.724034 3.1415927 31.45141 1.863034 1.863034 -0.021123294 142.24843 +line 1 25 +32.0077 23.999922 1.5809772 11.093492 1.822583 1.822583 0.0071299043 49.023144 +32.258587 23.187187 0.94422495 13.661953 1.5022168 1.5022168 0.2655523 65.33918 +33.02931 22.00803 1.303371 22.806807 1.0544455 1.0544455 -0.3251134 122.99617 +33.057056 21.02163 1.2084693 29.40223 1.9808655 1.9808655 0.04011951 141.77806 +33.42566 20.169003 1.1928456 31.975534 1.8055099 1.8055099 -0.014766572 140.94708 +33.848846 18.944592 1.2194288 33.523693 1.7563812 1.7563812 -0.012538457 145.10834 +34.159164 18.057928 1.2217526 33.58134 1.7543945 1.7543945 0.018752128 145.72456 +34.535484 17.193144 1.2246286 32.587856 1.743557 1.743557 -0.006146731 140.02394 +34.938416 15.977818 1.225085 33.53149 1.740433 1.740433 -0.03080014 145.76357 +35.24251 15.087512 1.2244811 33.19938 1.7636389 1.7636389 0.010119362 143.87231 +35.67241 13.880849 1.2219529 33.032722 1.7699734 1.7699734 -0.009528491 143.4117 +35.99923 12.999721 1.221599 33.601414 1.7457736 1.7457736 0.0039261365 144.31915 +36.327965 12.119992 1.2200545 33.195778 1.7700266 1.7700266 0.013720488 144.42894 +36.76366 10.913947 1.221608 33.517338 1.7622111 1.7622111 -0.013040831 145.3966 +37.07345 10.026858 1.2202203 33.780277 1.7419918 1.7419918 0.022688817 146.3025 +37.431313 9.157523 1.22063 32.98694 1.7426407 1.7426407 0.0027548748 141.45631 +37.85721 7.9481955 1.2227701 33.478523 1.7538465 1.7538465 -0.021965297 145.49199 +38.159042 7.057275 1.2251279 33.404655 1.7553887 1.7553887 0.019841854 145.0857 +38.532322 6.1930056 1.2229651 32.517063 1.7449998 1.7449998 -0.0051163975 139.7111 +38.93492 4.976372 1.2225288 33.631714 1.7430915 1.7430915 -0.02389194 145.79979 +39.241524 4.08811 1.2209955 33.44547 1.7696408 1.7696408 0.017042508 145.7428 +39.669453 2.8830364 1.2306967 33.29719 1.785228 1.785228 -0.005217888 145.00562 +39.973896 1.9919252 1.2707661 33.438736 1.8108454 1.8108454 0.008503084 147.21933 +40.23835 1.0458751 1.3806515 32.236435 1.8428898 1.8428898 -0.010891859 143.86005 +40.36221 -3.4304337E-10 1.5707964 31.295639 1.856626 1.856626 -0.015989635 140.80139 +junction 32.01127 23.999886 0 1 31 false +junction 32.01127 23.999886 0 2 31 false +junction 32.01127 23.999886 1 2 23 false +junction 32.01127 23.999886 3 1 0 false +junction 32.01127 23.999886 3 2 0 false +junction 32.01127 23.999886 3 0 0 false +case 6 4 6 +line 2 24 +24.026995 47.0 1.5707961 32.30844 1.8674115 1.8674115 -0.012994524 145.78458 +24.131378 46.025646 1.3780075 32.809624 1.8270876 1.8270876 -0.017869534 146.03307 +24.37468 45.115665 1.2713672 33.030994 1.7319976 1.7319976 -0.007681971 141.52797 +24.756752 43.91415 1.2315193 33.47405 1.7783815 1.7783815 -0.012220797 145.94463 +25.0642 43.02336 1.221799 33.801876 1.7450162 1.7450162 0.022801202 146.54448 +25.473423 41.80595 1.217724 32.70716 1.745034 1.745034 0.0014805066 140.27312 +25.848824 40.944958 1.2216069 33.506374 1.7554141 1.7554141 -0.02415751 145.85165 +26.150282 40.054245 1.224403 33.441845 1.7553953 1.7553953 0.020151567 145.27057 +26.569765 38.845425 1.2258703 32.831154 1.744347 1.744347 -0.0024763376 140.84451 +26.92247 37.97216 1.2260362 33.588318 1.7436917 1.7436917 -0.022395557 145.5272 +27.227448 37.08272 1.2219688 33.425636 1.7626523 1.7626523 0.015978804 145.23706 +27.662546 35.875343 1.2169391 33.266846 1.7464417 1.7464417 -0.003979397 142.91638 +28.011913 35.004406 1.2168304 33.822758 1.7245445 1.7245445 -0.02634301 145.97774 +28.330656 34.120636 1.2209731 33.277256 1.7696886 1.7696886 0.009306613 144.44324 +28.762857 32.914524 1.2248513 33.376617 1.7625763 1.7625763 -0.016338367 145.047 +29.067877 32.024406 1.2256132 33.561115 1.739763 1.739763 0.02092623 145.12029 +29.420576 31.152407 1.2231399 32.81234 1.7418346 1.7418346 7.1042386E-4 140.52785 +29.84533 29.943804 1.2222978 33.38274 1.7541469 1.7541469 -0.022648394 145.14082 +30.15125 29.05496 1.2222525 33.32362 1.7560434 1.7560434 0.014151592 144.3447 +30.518866 28.181633 1.234069 31.8796 1.8081845 1.8081845 0.012965345 140.53397 +30.941498 26.9777 1.2066318 29.470177 1.9785709 1.9785709 -0.04420385 142.24614 +30.969915 25.991564 1.2973896 22.972733 1.0460553 1.0460553 0.3217633 123.99252 +31.754496 24.820694 0.939985 13.749182 1.4859806 1.4859806 -0.2646204 65.621216 +32.01127 23.999886 1.5809771 11.093492 1.822583 1.822583 0.0071299043 49.022697 +line 1 25 +32.0077 23.999922 1.5809772 11.093492 1.822583 1.822583 0.0071299043 49.023144 +32.258587 23.187187 0.94422495 13.661953 1.5022168 1.5022168 0.2655523 65.33918 +33.02931 22.00803 1.303371 22.806807 1.0544455 1.0544455 -0.3251134 122.99617 +33.057056 21.02163 1.2084693 29.40223 1.9808655 1.9808655 0.04011951 141.77806 +33.42566 20.169003 1.1928456 31.975534 1.8055099 1.8055099 -0.014766572 140.94708 +33.848846 18.944592 1.2194288 33.523693 1.7563812 1.7563812 -0.012538457 145.10834 +34.159164 18.057928 1.2217526 33.58134 1.7543945 1.7543945 0.018752128 145.72456 +34.535484 17.193144 1.2246286 32.587856 1.743557 1.743557 -0.006146731 140.02394 +34.938416 15.977818 1.225085 33.53149 1.740433 1.740433 -0.03080014 145.76357 +35.24251 15.087512 1.2244811 33.19938 1.7636389 1.7636389 0.010119362 143.87231 +35.67241 13.880849 1.2219529 33.032722 1.7699734 1.7699734 -0.009528491 143.4117 +35.99923 12.999721 1.221599 33.601414 1.7457736 1.7457736 0.0039261365 144.31915 +36.327965 12.119992 1.2200545 33.195778 1.7700266 1.7700266 0.013720488 144.42894 +36.76366 10.913947 1.221608 33.517338 1.7622111 1.7622111 -0.013040831 145.3966 +37.07345 10.026858 1.2202203 33.780277 1.7419918 1.7419918 0.022688817 146.3025 +37.431313 9.157523 1.22063 32.98694 1.7426407 1.7426407 0.0027548748 141.45631 +37.85721 7.9481955 1.2227701 33.478523 1.7538465 1.7538465 -0.021965297 145.49199 +38.159042 7.057275 1.2251279 33.404655 1.7553887 1.7553887 0.019841854 145.0857 +38.532322 6.1930056 1.2229651 32.517063 1.7449998 1.7449998 -0.0051163975 139.7111 +38.93492 4.976372 1.2225288 33.631714 1.7430915 1.7430915 -0.02389194 145.79979 +39.241524 4.08811 1.2209955 33.44547 1.7696408 1.7696408 0.017042508 145.7428 +39.669453 2.8830364 1.2306967 33.29719 1.785228 1.785228 -0.005217888 145.00562 +39.973896 1.9919252 1.2707661 33.438736 1.8108454 1.8108454 0.008503084 147.21933 +40.23835 1.0458751 1.3806515 32.236435 1.8428898 1.8428898 -0.010891859 143.86005 +40.36221 -3.4304337E-10 1.5707964 31.295639 1.856626 1.856626 -0.015989635 140.80139 +line 2 32 +63.0 34.88355 3.1415927 32.6997 1.8514304 1.8514304 -0.018013496 146.95117 +62.04327 34.772575 2.9535909 32.941395 1.8058987 1.8058987 -0.022915937 145.83202 +60.84206 34.50724 2.8397415 32.663414 1.7265675 1.7265675 -0.004411421 139.49214 +59.94711 34.150185 2.8029838 33.309452 1.7742538 1.7742538 -0.029804958 146.33545 +59.053967 33.85137 2.7932897 33.2653 1.7609931 1.7609931 0.015636977 144.43579 +57.84386 33.426865 2.790926 32.671852 1.7472804 1.7472804 -0.0031120381 140.33664 +56.973858 33.07219 2.7941263 33.426285 1.7475111 1.7475111 -0.021437451 144.92967 +56.08358 32.76731 2.796754 33.223595 1.7691665 1.7691665 0.017532274 144.78899 +54.87678 32.34279 2.7965164 32.99511 1.7531427 1.7531427 -0.0029752243 141.98285 +54.00041 31.998869 2.7950475 33.486675 1.7353711 1.7353711 -0.027382713 145.08246 +53.11394 31.686115 2.7933886 32.980408 1.7732809 1.7732809 0.0090858545 143.31332 +51.907806 31.253384 2.792626 33.122265 1.7672896 1.7672896 -0.01270175 143.90173 +51.019474 30.946754 2.7909503 33.474075 1.7407004 1.7407004 0.030771349 145.524 +49.80347 30.539183 2.792066 32.50767 1.7451977 1.7451977 0.0078060287 139.869 +48.940693 30.163767 2.7941458 33.56387 1.7565094 1.7565094 -0.012336282 145.27342 +48.05115 29.8584 2.7949548 33.666626 1.7536366 1.7536366 0.023235599 146.39505 +46.84162 29.43506 2.792469 33.057137 1.7418838 1.7418838 -0.0038755103 141.80331 +45.971844 29.077055 2.7912962 33.760155 1.7412857 1.7412857 -0.025779646 146.41583 +45.08457 28.770016 2.7892027 33.50979 1.7595483 1.7595483 0.016664246 145.5042 +43.877865 28.33119 2.7882893 33.317722 1.7669837 1.7669837 -0.0055892547 144.21646 +43.00012 27.999674 2.7905974 33.871986 1.7434378 1.7434378 0.0036293305 145.35196 +42.119938 27.669672 2.7933161 33.40516 1.7682754 1.7682754 0.011681569 145.10374 +40.91397 27.239489 2.7967336 33.63022 1.7605894 1.7605894 -0.014503632 145.9165 +40.02426 26.932783 2.795214 33.860092 1.7366822 1.7366822 0.02026422 146.22298 +39.15346 26.578627 2.792327 33.06372 1.7394781 1.7394781 3.9701298E-4 141.47766 +37.945904 26.146782 2.788498 33.52331 1.7513714 1.7513714 -0.027703878 146.00124 +37.059544 25.837473 2.7904289 33.360104 1.7569001 1.7569001 0.008393461 144.12186 +36.185547 25.4673 2.8064225 31.791655 1.8099017 1.8099017 0.011273752 140.11508 +34.98025 25.052711 2.7830899 29.390282 1.9793769 1.9793769 -0.041633442 141.72646 +33.9929 25.026096 2.8758857 22.827387 1.0438664 1.0438664 0.32075953 123.222 +32.811954 24.252857 2.5021522 13.688454 1.4972028 1.4972028 -0.2724138 65.7704 +32.0077 23.999922 1.5809772 11.093492 1.822583 1.822583 0.0071299043 49.023144 +line 1 33 +32.01127 23.999886 1.5809772 11.093492 1.822583 1.822583 0.0071299043 49.022697 +31.203339 23.734596 2.4878325 13.513467 1.4910697 1.4910697 0.27585453 65.0657 +30.01 22.964283 2.8686137 22.690912 1.0884088 1.0884088 -0.32547715 120.6355 +29.026121 22.930635 2.7814233 29.177126 1.9873819 1.9873819 0.038065325 141.00761 +27.823404 22.517923 2.8129876 31.755663 1.8157659 1.8157659 -0.016356423 140.6261 +26.94378 22.15714 2.7980227 33.19697 1.766171 1.766171 -0.013470689 144.22855 +26.053915 21.851183 2.7940068 33.27168 1.7615385 1.7615385 0.01871666 144.71695 +24.8437 21.425041 2.789215 32.673786 1.7490067 1.7490067 -0.0021984188 140.35812 +23.974985 21.068401 2.790993 33.421032 1.7476754 1.7476754 -0.021207582 144.89742 +23.086584 20.761852 2.7928743 33.245735 1.769615 1.769615 0.020327201 145.1148 +21.880627 20.332424 2.7968357 33.165478 1.7731321 1.7731321 -0.003096 143.67606 +20.998026 20.00548 2.7958565 33.719303 1.7512002 1.7512002 0.00492594 145.15027 +20.116482 19.678097 2.7944005 33.203712 1.771658 1.771658 0.012037295 144.4198 +18.910547 19.244804 2.7912633 33.303303 1.7659069 1.7659069 -0.011388181 144.52484 +18.02309 18.937101 2.7897635 33.566593 1.7401992 1.7401992 0.030574802 145.88841 +16.807505 18.525698 2.7905843 32.59985 1.7424452 1.7424452 0.009482942 140.26239 +15.944985 18.151741 2.7937775 33.610977 1.7499603 1.7499603 -0.012402407 145.1737 +15.05533 17.846834 2.7949367 33.697838 1.7512827 1.7512827 0.024097452 146.48422 +13.847098 17.420906 2.7931478 33.163677 1.7410331 1.7410331 -0.005549051 142.34206 +12.976473 17.06444 2.7915297 33.841557 1.7381781 1.7381781 -0.028970161 146.86838 +12.089818 16.755268 2.789852 33.512226 1.7604015 1.7604015 0.010002837 145.06377 +10.883158 16.316282 2.787719 33.29664 1.7665904 1.7665904 -0.01203217 144.57622 +9.999371 16.001717 2.789966 33.7767 1.725958 1.725958 0.029314043 146.06528 +9.1261015 15.654302 2.7918177 33.273556 1.7457851 1.7457851 0.0062022004 143.07527 +7.918615 15.22616 2.796165 33.57269 1.7624279 1.7624279 -0.013181646 145.65762 +7.029053 14.919464 2.7953749 33.803234 1.7445946 1.7445946 0.020937264 146.39056 +6.1582527 14.563493 2.7937882 32.997032 1.7436647 1.7436647 8.121058E-4 141.40762 +4.950266 14.135919 2.7908187 33.482365 1.7529094 1.7529094 -0.028160205 145.92984 +4.0608735 13.832783 2.7924645 33.351562 1.7583345 1.7583345 0.016861498 144.77394 +3.193255 13.457543 2.7993531 32.35227 1.756405 1.756405 -0.002261343 139.31458 +1.9695181 13.098927 2.842696 33.113255 1.8022065 1.8022065 0.0076669063 145.26208 +1.0305493 12.840782 2.9520257 32.24945 1.844473 1.844473 -0.014643859 144.28352 +-9.031714E-10 12.724034 3.1415927 31.45141 1.863034 1.863034 -0.021123294 142.24843 +junction 32.01127 23.999886 2 0 31 false +junction 32.01127 23.999886 2 3 31 false +junction 32.01127 23.999886 0 3 23 false +junction 32.01127 23.999886 1 0 0 false +junction 32.01127 23.999886 1 3 0 false +junction 32.01127 23.999886 1 2 0 false +case 7 2 1 +line 0 49 +40.354362 7.1103726E-18 0.0 31.295639 0.0 0.0 0.0 0.0 +40.23306 1.044857 0.0 32.236435 0.0 0.0 0.0 0.0 +39.977985 1.9931899 0.0 33.438736 0.0 0.0 0.0 0.0 +39.666943 2.8821483 0.0 33.29719 0.0 0.0 0.0 0.0 +39.249767 4.0911164 0.0 33.44547 0.0 0.0 0.0 0.0 +38.923183 4.97211 0.0 33.631714 0.0 0.0 0.0 0.0 +38.52981 6.192095 0.0 32.517063 0.0 0.0 0.0 0.0 +38.16873 7.0607643 0.0 33.404655 0.0 0.0 0.0 0.0 +37.84648 7.944304 0.0 33.478523 0.0 0.0 0.0 0.0 +37.432667 9.158017 0.0 32.98694 0.0 0.0 0.0 0.0 +37.084595 10.030934 0.0 33.780277 0.0 0.0 0.0 0.0 +36.757324 10.91164 0.0 33.517338 0.0 0.0 0.0 0.0 +36.334595 12.122418 0.0 33.195778 0.0 0.0 0.0 0.0 +36.001156 13.000422 0.0 33.601414 0.0 0.0 0.0 0.0 +35.6678 13.879172 0.0 33.032722 0.0 0.0 0.0 0.0 +35.24743 15.089287 0.0 33.19938 0.0 0.0 0.0 0.0 +34.923244 15.972354 0.0 33.53149 0.0 0.0 0.0 0.0 +34.532463 17.192055 0.0 32.587856 0.0 0.0 0.0 0.0 +34.168316 18.061258 0.0 33.58134 0.0 0.0 0.0 0.0 +33.84274 18.942352 0.0 33.523693 0.0 0.0 0.0 0.0 +33.41873 20.166252 0.0 31.975534 0.0 0.0 0.0 0.0 +33.07432 21.028173 0.0 29.40223 0.0 0.0 0.0 0.0 +32.708084 21.920017 0.0 22.806807 0.0 0.0 0.0 0.0 +32.40675 23.294437 0.0 13.661953 0.0 0.0 0.0 0.0 +32.01127 23.999886 0.0 11.093492 0.0 0.0 0.0 0.0 +32.01127 23.999886 1.5809771 11.093492 0.0 0.0 0.0 0.0 +31.60582 24.712107 0.939985 13.749182 0.0 0.0 0.0 0.0 +31.28914 26.081083 1.2973896 22.972733 0.0 0.0 0.0 0.0 +30.922466 26.970446 1.2066318 29.470177 0.0 0.0 0.0 0.0 +30.525032 28.183792 1.234069 31.8796 0.0 0.0 0.0 0.0 +30.158152 29.057468 1.2222525 33.32362 0.0 0.0 0.0 0.0 +29.834272 29.939787 1.2222978 33.38274 0.0 0.0 0.0 0.0 +29.420925 31.152533 1.2231399 32.81234 0.0 0.0 0.0 0.0 +29.07819 32.028114 1.2256132 33.561115 0.0 0.0 0.0 0.0 +28.754913 32.91166 1.2248513 33.376617 0.0 0.0 0.0 0.0 +28.335157 34.122276 1.2209731 33.277256 0.0 0.0 0.0 0.0 +27.99886 34.99958 1.2168304 33.822758 0.0 0.0 0.0 0.0 +27.660599 35.874622 1.2169391 33.266846 0.0 0.0 0.0 0.0 +27.235209 37.085545 1.2219688 33.425636 0.0 0.0 0.0 0.0 +26.911457 37.968204 1.2260362 33.588318 0.0 0.0 0.0 0.0 +26.568548 38.844986 1.2258703 32.831154 0.0 0.0 0.0 0.0 +26.16012 40.057796 1.224403 33.441845 0.0 0.0 0.0 0.0 +25.837042 40.940666 1.2216069 33.506374 0.0 0.0 0.0 0.0 +25.474148 41.806217 1.217724 32.70716 0.0 0.0 0.0 0.0 +25.075386 43.02743 1.221799 33.801876 0.0 0.0 0.0 0.0 +24.750849 43.912067 1.2315193 33.47405 0.0 0.0 0.0 0.0 +24.37082 45.114475 1.2713672 33.030994 0.0 0.0 0.0 0.0 +24.12263 46.023937 1.3780075 32.809624 0.0 0.0 0.0 0.0 +24.020655 47.0 1.5707961 32.30844 0.0 0.0 0.0 0.0 +line 0 65 +63.0 34.88355 0.0 32.6997 0.0 0.0 0.0 0.0 +62.041107 34.783936 0.0 32.941395 0.0 0.0 0.0 0.0 +60.84137 34.509464 0.0 32.663414 0.0 0.0 0.0 0.0 +59.942028 34.164616 0.0 33.309452 0.0 0.0 0.0 0.0 +59.05673 33.84377 0.0 33.2653 0.0 0.0 0.0 0.0 +57.843304 33.42839 0.0 32.671852 0.0 0.0 0.0 0.0 +56.97005 33.0827 0.0 33.426285 0.0 0.0 0.0 0.0 +56.08663 32.758816 0.0 33.223595 0.0 0.0 0.0 0.0 +54.87626 32.344242 0.0 32.99511 0.0 0.0 0.0 0.0 +53.995525 32.01239 0.0 33.486675 0.0 0.0 0.0 0.0 +53.115532 31.681728 0.0 32.980408 0.0 0.0 0.0 0.0 +51.905567 31.259537 0.0 33.122265 0.0 0.0 0.0 0.0 +51.025005 30.93163 0.0 33.474075 0.0 0.0 0.0 0.0 +49.804867 30.535355 0.0 32.50767 0.0 0.0 0.0 0.0 +48.938515 30.169783 0.0 33.56387 0.0 0.0 0.0 0.0 +48.055252 29.847046 0.0 33.666626 0.0 0.0 0.0 0.0 +46.840927 29.436966 0.0 33.057137 0.0 0.0 0.0 0.0 +45.967216 29.089725 0.0 33.760155 0.0 0.0 0.0 0.0 +45.08755 28.761917 0.0 33.50979 0.0 0.0 0.0 0.0 +43.87687 28.333893 0.0 33.317722 0.0 0.0 0.0 0.0 +43.00077 27.997892 0.0 33.871986 0.0 0.0 0.0 0.0 +42.12199 27.664015 0.0 33.40516 0.0 0.0 0.0 0.0 +40.911434 27.246552 0.0 33.63022 0.0 0.0 0.0 0.0 +40.02787 26.922783 0.0 33.860092 0.0 0.0 0.0 0.0 +39.153534 26.578432 0.0 33.06372 0.0 0.0 0.0 0.0 +37.94092 26.160305 0.0 33.52331 0.0 0.0 0.0 0.0 +37.06104 25.833385 0.0 33.360104 0.0 0.0 0.0 0.0 +36.187412 25.46194 0.0 31.791655 0.0 0.0 0.0 0.0 +34.973522 25.070667 0.0 29.390282 0.0 0.0 0.0 0.0 +34.07981 24.706736 0.0 22.827387 0.0 0.0 0.0 0.0 +32.69918 24.404501 0.0 13.688454 0.0 0.0 0.0 0.0 +32.01127 23.999886 0.0 11.093492 0.0 0.0 0.0 0.0 +32.01127 23.999886 1.5809772 11.093492 0.0 0.0 0.0 0.0 +31.320518 23.581652 2.4878325 13.513467 0.0 0.0 0.0 0.0 +29.92295 23.275208 2.8686137 22.690912 0.0 0.0 0.0 0.0 +29.032276 22.914293 2.7814233 29.177126 0.0 0.0 0.0 0.0 +27.820755 22.525694 2.8129876 31.755663 0.0 0.0 0.0 0.0 +26.94144 22.163683 2.7980227 33.19697 0.0 0.0 0.0 0.0 +26.057213 21.842081 2.7940068 33.27168 0.0 0.0 0.0 0.0 +24.843306 21.426117 2.789215 32.673786 0.0 0.0 0.0 0.0 +23.971188 21.078785 2.790993 33.421032 0.0 0.0 0.0 0.0 +23.09016 20.752018 2.7928743 33.245735 0.0 0.0 0.0 0.0 +21.880089 20.333921 2.7968357 33.165478 0.0 0.0 0.0 0.0 +20.998894 20.003069 2.7958565 33.719303 0.0 0.0 0.0 0.0 +20.118587 19.672277 2.7944005 33.203712 0.0 0.0 0.0 0.0 +18.908531 19.250322 2.7912633 33.303303 0.0 0.0 0.0 0.0 +18.028606 18.922073 2.7897635 33.566593 0.0 0.0 0.0 0.0 +16.80921 18.521042 2.7905843 32.59985 0.0 0.0 0.0 0.0 +15.942784 18.157812 2.7937775 33.610977 0.0 0.0 0.0 0.0 +15.059589 17.835043 2.7949367 33.697838 0.0 0.0 0.0 0.0 +13.846107 17.423635 2.7931478 33.163677 0.0 0.0 0.0 0.0 +12.971265 17.078705 2.7915297 33.841557 0.0 0.0 0.0 0.0 +12.091601 16.750408 2.789852 33.512226 0.0 0.0 0.0 0.0 +10.881007 16.322104 2.787719 33.29664 0.0 0.0 0.0 0.0 +10.0047 15.987189 2.789966 33.7767 0.0 0.0 0.0 0.0 +9.127211 15.65126 2.7918177 33.273556 0.0 0.0 0.0 0.0 +7.916308 15.232572 2.796165 33.57269 0.0 0.0 0.0 0.0 +7.0327635 14.909179 2.7953749 33.803234 0.0 0.0 0.0 0.0 +6.158397 14.563094 2.7937882 32.997032 0.0 0.0 0.0 0.0 +4.9452367 14.149664 2.7908187 33.482365 0.0 0.0 0.0 0.0 +4.0638623 13.8245735 2.7924645 33.351562 0.0 0.0 0.0 0.0 +3.1928613 13.458649 2.7993531 32.35227 0.0 0.0 0.0 0.0 +1.9706595 13.0952215 2.842696 33.113255 0.0 0.0 0.0 0.0 +1.0291859 12.847888 2.9520257 32.24945 0.0 0.0 0.0 0.0 +4.1021743E-19 12.734365 3.1415927 31.45141 0.0 0.0 0.0 0.0 +junction 32.01127 23.999886 0 1 24 true +case 8 2 1 +line 0 49 +40.354362 7.1103726E-18 1.5707964 31.295639 2.008511 1.8410598 0.0 0.0 +40.23306 1.044857 1.3806515 32.236435 1.9733348 1.8554367 0.0 0.0 +39.977985 1.9931899 1.2707661 33.438736 1.9086967 1.871575 0.0 0.0 +39.666943 2.8821483 1.2306967 33.29719 1.8867873 1.8547071 0.0 0.0 +39.249767 4.0911164 1.2209955 33.44547 1.8454666 1.8735511 0.0 0.0 +38.923183 4.97211 1.2225288 33.631714 1.8655827 1.8160896 0.0 0.0 +38.52981 6.192095 1.2229651 32.517063 1.9144976 1.7698034 0.0 0.0 +38.16873 7.0607643 1.2251279 33.404655 1.8255408 1.8734264 0.0 0.0 +37.84648 7.944304 1.2227701 33.478523 1.8745836 1.8222194 0.0 0.0 +37.432667 9.158017 1.22063 32.98694 1.8413575 1.8396158 0.0 0.0 +37.084595 10.030934 1.2202203 33.780277 1.8141056 1.8660154 0.0 0.0 +36.757324 10.91164 1.221608 33.517338 1.87118 1.8373693 0.0 0.0 +36.334595 12.122418 1.2200545 33.195778 1.8586837 1.8608675 0.0 0.0 +36.001156 13.000422 1.221599 33.601414 1.8448414 1.840545 0.0 0.0 +35.6678 13.879172 1.2219529 33.032722 1.8633763 1.8560884 0.0 0.0 +35.24743 15.089287 1.2244811 33.19938 1.8376225 1.8729279 0.0 0.0 +34.923244 15.972354 1.225085 33.53149 1.8626336 1.8153183 0.0 0.0 +34.532463 17.192055 1.2246286 32.587856 1.9129242 1.7693492 0.0 0.0 +34.168316 18.061258 1.2217526 33.58134 1.8244206 1.8731443 0.0 0.0 +33.84274 18.942352 1.2194288 33.523693 1.8712441 1.8290986 0.0 0.0 +33.41873 20.166252 1.1928456 31.975534 1.8510784 1.9211363 0.0 0.0 +33.07432 21.028173 1.2084693 29.40223 1.8406731 2.20972 0.0 0.0 +32.708084 21.920017 1.303371 22.806807 0.711524 2.2985244 0.0 0.0 +32.40675 23.294437 0.94422495 13.661953 1.8008777 1.5831734 0.0 0.0 +32.01127 23.999886 1.5809772 11.093492 1.9027958 1.8952352 0.0 0.0 +32.01127 23.999886 1.5809771 11.093492 1.9027958 1.8952352 0.0 0.0 +31.60582 24.712107 0.939985 13.749182 1.5742035 1.791377 0.0 0.0 +31.28914 26.081083 1.2973896 22.972733 2.2907007 0.7136036 0.0 0.0 +30.922466 26.970446 1.2066318 29.470177 2.2045946 1.8419411 0.0 0.0 +30.525032 28.183792 1.234069 31.8796 1.9758264 1.8004308 0.0 0.0 +30.158152 29.057468 1.2222525 33.32362 1.8307803 1.8690916 0.0 0.0 +29.834272 29.939787 1.2222978 33.38274 1.8742906 1.8229373 0.0 0.0 +29.420925 31.152533 1.2231399 32.81234 1.8370042 1.8428284 0.0 0.0 +29.07819 32.028114 1.2256132 33.561115 1.8116192 1.8653597 0.0 0.0 +28.754913 32.91166 1.2248513 33.376617 1.871387 1.837686 0.0 0.0 +28.335157 34.122276 1.2209731 33.277256 1.8581576 1.8609056 0.0 0.0 +27.99886 34.99958 1.2168304 33.822758 1.8437442 1.8118335 0.0 0.0 +27.660599 35.874622 1.2169391 33.266846 1.8604717 1.8258555 0.0 0.0 +27.235209 37.085545 1.2219688 33.425636 1.8352934 1.8738856 0.0 0.0 +26.911457 37.968204 1.2260362 33.588318 1.8671453 1.8153672 0.0 0.0 +26.568548 38.844986 1.2258703 32.831154 1.8416606 1.8417137 0.0 0.0 +26.16012 40.057796 1.224403 33.441845 1.8247945 1.8741828 0.0 0.0 +25.837042 40.940666 1.2216069 33.506374 1.872887 1.8261287 0.0 0.0 +25.474148 41.806217 1.217724 32.70716 1.767604 1.9167341 0.0 0.0 +25.075386 43.02743 1.221799 33.801876 1.8133321 1.8710457 0.0 0.0 +24.750849 43.912067 1.2315193 33.47405 1.8717761 1.8595303 0.0 0.0 +24.37082 45.114475 1.2713672 33.030994 1.8231701 1.842838 0.0 0.0 +24.12263 46.023937 1.3780075 32.809624 1.8595349 1.9453411 0.0 0.0 +24.020655 47.0 1.5707961 32.30844 1.8659618 1.9999237 0.0 0.0 +line 0 65 +4.1021743E-19 12.734365 3.1415927 31.45141 2.0039558 1.8553243 0.0 0.0 +1.0291859 12.847888 2.9520257 32.24945 1.9663376 1.8648391 0.0 0.0 +1.9706595 13.0952215 2.842696 33.113255 1.8955915 1.8716043 0.0 0.0 +3.1928613 13.458649 2.7993531 32.35227 1.9360679 1.7642779 0.0 0.0 +4.0638623 13.8245735 2.7924645 33.351562 1.8279426 1.8751619 0.0 0.0 +4.9452367 14.149664 2.7908187 33.482365 1.8707696 1.824733 0.0 0.0 +6.158397 14.563094 2.7937882 32.997032 1.8414342 1.8409746 0.0 0.0 +7.0327635 14.909179 2.7953749 33.803234 1.8131486 1.87063 0.0 0.0 +7.916308 15.232572 2.796165 33.57269 1.8711238 1.8377311 0.0 0.0 +9.127211 15.65126 2.7918177 33.273556 1.8246582 1.8607516 0.0 0.0 +10.0047 15.987189 2.789966 33.7767 1.8108494 1.8467269 0.0 0.0 +10.881007 16.322104 2.787719 33.29664 1.8606515 1.8540587 0.0 0.0 +12.091601 16.750408 2.789852 33.512226 1.835583 1.8704107 0.0 0.0 +12.971265 17.078705 2.7915297 33.841557 1.8607223 1.8140507 0.0 0.0 +13.846107 17.423635 2.7931478 33.163677 1.8401184 1.8386008 0.0 0.0 +15.059589 17.835043 2.7949367 33.697838 1.823112 1.870089 0.0 0.0 +15.942784 18.157812 2.7937775 33.610977 1.8721781 1.8191268 0.0 0.0 +16.80921 18.521042 2.7905843 32.59985 1.7725226 1.9081963 0.0 0.0 +18.028606 18.922073 2.7897635 33.566593 1.8157058 1.8619167 0.0 0.0 +18.908531 19.250322 2.7912633 33.303303 1.87468 1.8390664 0.0 0.0 +20.118587 19.672277 2.7944005 33.203712 1.8585341 1.8633085 0.0 0.0 +20.998894 20.003069 2.7958565 33.719303 1.849423 1.8436048 0.0 0.0 +21.880089 20.333921 2.7968357 33.165478 1.8652232 1.8586693 0.0 0.0 +23.09016 20.752018 2.7928743 33.245735 1.8434135 1.8755773 0.0 0.0 +23.971188 21.078785 2.790993 33.421032 1.8704034 1.8177121 0.0 0.0 +24.843306 21.426117 2.789215 32.673786 1.8461876 1.8437446 0.0 0.0 +26.057213 21.842081 2.7940068 33.27168 1.82685 1.8807691 0.0 0.0 +26.94144 22.163683 2.7980227 33.19697 1.8773359 1.836788 0.0 0.0 +27.820755 22.525694 2.8129876 31.755663 1.8072757 1.9804635 0.0 0.0 +29.032276 22.914293 2.7814233 29.177126 1.8488009 2.2125638 0.0 0.0 +29.92295 23.275208 2.8686137 22.690912 0.7363679 2.294383 0.0 0.0 +31.320518 23.581652 2.4878325 13.513467 1.824923 1.5473653 0.0 0.0 +32.01127 23.999886 1.5809772 11.093492 1.9027958 1.8952352 0.0 0.0 +32.01127 23.999886 1.5809772 11.093492 1.9027958 1.8952352 0.0 0.0 +32.69918 24.404501 2.5021522 13.688454 1.5714476 1.807443 0.0 0.0 +34.07981 24.706736 2.8758857 22.827387 2.2927072 0.7100703 0.0 0.0 +34.973522 25.070667 2.7830899 29.390282 2.2051973 1.8426912 0.0 0.0 +36.187412 25.46194 2.8064225 31.791655 1.9793743 1.7994769 0.0 0.0 +37.06104 25.833385 2.7904289 33.360104 1.8293833 1.8716775 0.0 0.0 +37.94092 26.160305 2.788498 33.52331 1.8704201 1.8229165 0.0 0.0 +39.153534 26.578432 2.792327 33.06372 1.835925 1.8405896 0.0 0.0 +40.02787 26.922783 2.795214 33.860092 1.8098667 1.8627741 0.0 0.0 +40.911434 27.246552 2.7967336 33.63022 1.8694701 1.8368012 0.0 0.0 +42.12199 27.664015 2.7933161 33.40516 1.8572409 1.8598399 0.0 0.0 +43.00077 27.997892 2.7905974 33.871986 1.8423373 1.8397607 0.0 0.0 +43.87687 28.333893 2.7882893 33.317722 1.8609852 1.8542604 0.0 0.0 +45.08755 28.761917 2.7892027 33.50979 1.8358213 1.868991 0.0 0.0 +45.967216 29.089725 2.7912962 33.760155 1.8653373 1.8137995 0.0 0.0 +46.840927 29.436966 2.792469 33.057137 1.8393801 1.8405312 0.0 0.0 +48.055252 29.847046 2.7949548 33.666626 1.824741 1.8717703 0.0 0.0 +48.938515 30.169783 2.7941458 33.56387 1.8770796 1.8234428 0.0 0.0 +49.804867 30.535355 2.792066 32.50767 1.7699026 1.9146851 0.0 0.0 +51.025005 30.93163 2.7909503 33.474075 1.8160809 1.8622473 0.0 0.0 +51.905567 31.259537 2.792626 33.122265 1.8740542 1.8416421 0.0 0.0 +53.115532 31.681728 2.7933886 32.980408 1.8572767 1.8668419 0.0 0.0 +53.995525 32.01239 2.7950475 33.486675 1.8545715 1.8162463 0.0 0.0 +54.87626 32.344242 2.7965164 32.99511 1.8660086 1.8297474 0.0 0.0 +56.08663 32.758816 2.796754 33.223595 1.8428582 1.8754934 0.0 0.0 +56.97005 33.0827 2.7941263 33.426285 1.8719283 1.8159567 0.0 0.0 +57.843304 33.42839 2.790926 32.671852 1.8449999 1.8425051 0.0 0.0 +59.05673 33.84377 2.7932897 33.2653 1.825165 1.8816777 0.0 0.0 +59.942028 34.164616 2.8029838 33.309452 1.8752421 1.850304 0.0 0.0 +60.84137 34.509464 2.8397415 32.663414 1.7690412 1.8893136 0.0 0.0 +62.041107 34.783936 2.9535909 32.941395 1.8376153 1.9352101 0.0 0.0 +63.0 34.88355 3.1415927 32.6997 1.8504598 1.9912536 0.0 0.0 +junction 32.01127 23.999886 0 1 24 true +case 9 6 10 +line 2 2 +59.0 24.001348 3.1415575 13.8434 1.5125173 1.5245017 0.0 0.0 +58.0 24.001226 3.1409247 24.633741 1.0095084 1.0126655 0.0 0.0 +line 3 84 +35.0 24.000093 3.1405983 18.98541 4.066029 4.0669966 NaN NaN +34.289467 25.102634 4.371656 26.520844 2.1807034 2.3341186 NaN NaN +33.9289 25.972881 4.3479924 30.42031 2.0163734 2.0475404 NaN NaN +33.570004 27.187319 4.3948793 31.902906 1.9689817 1.9125991 NaN NaN +33.206367 28.076645 4.356779 32.87853 1.8751954 1.8677679 NaN NaN +32.852116 28.93378 4.2913814 33.050304 1.825751 1.9121073 NaN NaN +32.40356 29.68081 4.221011 31.547964 1.9579414 1.8098832 NaN NaN +32.236813 30.139685 4.1794577 32.52271 1.8837241 1.8950803 NaN NaN +31.771713 30.841745 4.106224 32.602776 1.8743529 1.9138 NaN NaN +31.463417 31.357773 4.054934 31.09502 1.8135847 1.9592103 NaN NaN +30.921667 31.930332 3.9854758 32.820545 1.8549557 1.8892455 NaN NaN +30.500818 32.499565 3.928243 30.749275 1.7526398 1.9938325 NaN NaN +29.930677 32.9218 3.8668807 32.96229 1.8539064 1.8872976 NaN NaN +29.358631 33.46397 3.799628 31.319086 1.8092039 1.9576916 NaN NaN +28.841927 33.771248 3.7462726 32.88822 1.8711046 1.9111055 NaN NaN +28.139544 34.235935 3.6757014 32.829903 1.8789287 1.8889545 NaN NaN +27.677618 34.406185 3.638969 31.815779 1.9541357 1.804487 NaN NaN +26.934345 34.856018 3.5694208 33.27167 1.82315 1.9061116 NaN NaN +26.077484 35.211956 3.4920683 33.01885 1.8649186 1.8690879 NaN NaN +24.88929 35.546307 3.3809366 33.15047 1.8223101 1.8378966 NaN NaN +23.962852 35.762352 3.2966518 34.22979 1.7993546 1.8781875 NaN NaN +22.9913 35.882717 3.215636 35.086674 1.812666 1.8822477 NaN NaN +22.000319 35.91903 3.137667 35.381004 1.8120139 1.8797331 NaN NaN +21.010242 35.874554 3.0601306 35.000263 1.8075795 1.8849959 NaN NaN +20.041243 35.746536 2.9802907 34.114414 1.7879125 1.8796046 NaN NaN +19.11778 35.523582 2.8992312 33.0498 1.8255366 1.835152 NaN NaN +17.929522 35.196396 2.7970467 33.079044 1.8684783 1.8710326 NaN NaN +17.068289 34.846745 2.7224102 33.14982 1.8274366 1.9174873 NaN NaN +15.862814 34.231525 2.606684 32.533836 1.8848091 1.8992319 NaN NaN +15.16342 33.76572 2.5325162 32.509003 1.874866 1.9188021 NaN NaN +14.647895 33.452473 2.4803014 31.10348 1.8159581 1.9698142 NaN NaN +14.077869 32.91286 2.4123132 32.766068 1.856209 1.9023134 NaN NaN +13.512748 32.487713 2.3566685 30.72606 1.7591312 2.000919 NaN NaN +13.087671 31.922066 2.2974632 32.86845 1.8544532 1.9009027 NaN NaN +12.548636 31.350317 2.2308133 31.28642 1.8118677 1.9678829 NaN NaN +12.237838 30.835115 2.176988 32.705704 1.8712866 1.9148724 NaN NaN +11.772796 30.135063 2.1071322 32.813507 1.8802665 1.8909895 NaN NaN +11.151099 28.931135 1.9984331 33.36558 1.8257778 1.9092934 NaN NaN +10.7949705 28.075186 1.9222785 33.205612 1.8664354 1.8692076 NaN NaN +10.452605 27.153069 1.8434632 32.748493 1.7335067 1.9003067 NaN NaN +10.242175 25.961819 1.7271672 34.2365 1.7935501 1.8777773 NaN NaN +10.121112 24.990839 1.646294 35.061123 1.811109 1.8798163 NaN NaN +10.082256 23.999998 1.5708245 35.379295 1.8134195 1.8749474 NaN NaN +10.1212015 23.00917 1.4952812 35.035854 1.8113301 1.8805989 NaN NaN +10.242665 22.038286 1.4143145 34.178112 1.793995 1.8790057 NaN NaN +10.460902 21.11409 1.3281367 33.135555 1.8214527 1.8379536 NaN NaN +10.796473 19.925316 1.2191036 33.15906 1.8660961 1.8704191 NaN NaN +11.152348 19.069248 1.1441729 33.3067 1.8252466 1.9106358 NaN NaN +11.771707 17.86478 1.0360516 32.730804 1.8798596 1.8925239 NaN NaN +12.236286 17.163439 0.9656616 32.58403 1.8711338 1.9164784 NaN NaN +12.546847 16.648264 0.910738 31.173727 1.8117732 1.967644 NaN NaN +13.0860615 16.076462 0.8443968 32.73973 1.8538135 1.9004855 NaN NaN +13.509884 15.510316 0.7858391 30.61742 1.7552327 2.0002842 NaN NaN +14.075976 15.08503 0.72922504 32.693092 1.854615 1.9001254 NaN NaN +14.645404 14.544165 0.6611211 31.036797 1.8127047 1.9689943 NaN NaN +15.161472 14.232351 0.6073281 32.500843 1.8725938 1.9191369 NaN NaN +15.862538 13.767041 0.5330838 32.5034 1.8839056 1.8991313 NaN NaN +17.068274 13.1531315 0.41939816 33.099163 1.8274618 1.9172759 NaN NaN +17.92942 12.80334 0.3445771 33.002186 1.8687525 1.8703156 NaN NaN +18.85259 12.46954 0.27104995 32.676006 1.7400662 1.8975406 NaN NaN +20.041258 12.254699 0.16058967 34.076702 1.7897669 1.8779496 NaN NaN +21.01039 12.126784 0.08176732 34.97338 1.8093255 1.8834327 NaN NaN +22.000397 12.081582 0.0048684576 35.37014 1.8130374 1.8792288 NaN NaN +22.991394 12.117045 6.2097893 35.093216 1.8127571 1.8828439 NaN NaN +23.96286 12.237205 6.1278768 34.251984 1.7988021 1.8790642 NaN NaN +24.8889 12.45383 6.0431046 33.18701 1.822151 1.8380932 NaN NaN +26.07733 12.7883625 5.932871 33.093117 1.8647084 1.869431 NaN NaN +26.934298 13.14413 5.855478 33.322918 1.8231992 1.9063826 NaN NaN +27.676765 13.594911 5.785474 31.843605 1.9553618 1.8040717 NaN NaN +28.139215 13.765626 5.7471995 32.861217 1.8798645 1.8890656 NaN NaN +28.839972 14.230807 5.676942 32.895023 1.873321 1.9109452 NaN NaN +29.356007 14.539402 5.6251745 31.37842 1.8125113 1.9581139 NaN NaN +29.929056 15.080113 5.5584087 33.020824 1.8555198 1.8893449 NaN NaN +30.498264 15.501203 5.498321 30.84352 1.7530364 1.9936785 NaN NaN +30.921394 16.070108 5.440704 32.93456 1.8557206 1.8897436 NaN NaN +31.463955 16.641542 5.370206 31.178684 1.8137437 1.9605993 NaN NaN +31.772533 17.15807 5.3196964 32.69356 1.8750122 1.9141068 NaN NaN +32.237553 17.859856 5.2453895 32.540043 1.8852329 1.8949997 NaN NaN +32.405914 18.319435 5.2057323 31.571093 1.9591796 1.8107209 NaN NaN +32.853386 19.06576 5.134005 33.032753 1.8275727 1.9113091 NaN NaN +33.208035 19.92263 5.068447 32.863922 1.876448 1.8670756 NaN NaN +33.572426 20.811771 5.0300775 31.900751 1.9666913 1.9123635 NaN NaN +33.93007 22.02661 5.0759788 30.451355 2.0154943 2.0471933 NaN NaN +34.29022 22.897356 5.0523367 26.537296 2.1793835 2.334651 NaN NaN +line 3 5 +58.0 24.001226 3.1409247 24.633741 1.0095084 1.0126655 NaN NaN +59.0 24.001348 3.1415575 13.8434 1.5125173 1.5245017 NaN NaN +59.001263 24.997343 2.698493 11.2160845 0.35449097 1.3146863 NaN NaN +58.112286 24.82655 2.5670702 18.123585 0.20999204 0.20999204 NaN NaN +58.0 24.001226 3.1409247 24.633741 1.0095084 1.0126655 NaN NaN +line 3 5 +58.0 24.001226 3.1409247 24.633741 1.0095084 1.0126655 NaN NaN +59.0 24.001348 3.1415575 13.8434 1.5125173 1.5245017 NaN NaN +59.00381 23.008053 3.5837393 11.231307 1.3372977 0.3707841 NaN NaN +58.113663 23.17606 3.71486 18.119106 0.2131549 0.2131549 NaN NaN +58.0 24.001226 3.1409247 24.633741 1.0095084 1.0126655 NaN NaN +line 3 24 +58.0 24.001226 3.1409247 24.633741 1.0095084 1.0126655 0.0 0.0 +57.0 24.000172 3.1413417 31.334497 1.4677911 1.4697701 0.0 0.0 +56.0 24.000025 3.1417923 34.850307 1.7802817 1.7802018 0.0 0.0 +55.0 24.000229 3.1417494 36.39785 1.8179612 1.8181362 0.0 0.0 +54.0 24.00027 3.1414964 36.567993 1.8235064 1.8237689 0.0 0.0 +53.0 24.000032 3.14123 36.526157 1.8247054 1.8248273 0.0 0.0 +52.0 23.999643 3.1412506 36.511517 1.8249073 1.8247064 0.0 0.0 +51.0 23.999485 3.1416607 36.578846 1.8240235 1.8236635 0.0 0.0 +50.0 23.99977 3.1420407 36.74528 1.8220782 1.8219433 0.0 0.0 +49.0 24.000227 3.141976 36.958652 1.8197318 1.8199389 0.0 0.0 +48.0 24.000418 3.141568 37.120316 1.8179644 1.8182439 0.0 0.0 +47.0 24.000229 3.1413057 37.16771 1.8174535 1.8174855 0.0 0.0 +46.0 23.999994 3.1414685 37.098213 1.8182337 1.8180729 0.0 0.0 +45.0 24.000017 3.1417363 36.941998 1.8199022 1.8198632 0.0 0.0 +44.0 24.00019 3.1417458 36.756847 1.8219061 1.8220888 0.0 0.0 +43.0 24.00025 3.1415355 36.60988 1.8236425 1.823907 0.0 0.0 +42.0 24.000067 3.141299 36.528038 1.8246956 1.8248369 0.0 0.0 +41.0 23.999748 3.1413143 36.508327 1.8249764 1.8248396 0.0 0.0 +40.0 23.999626 3.1416605 36.54468 1.8249904 1.8247036 0.0 0.0 +39.0 23.99987 3.141971 36.484642 1.8301247 1.8299035 0.0 0.0 +38.0 24.000252 3.1418948 35.60976 1.8677063 1.8676336 0.0 0.0 +37.0 24.000456 3.1415308 32.397015 1.9941628 1.9942527 0.0 0.0 +36.0 24.000399 3.1411831 25.039423 2.2802353 2.2801342 0.0 0.0 +35.0 24.000093 3.1405983 18.98541 4.066029 4.0669966 0.0 0.0 +line 3 3 +35.0 24.000093 3.1405983 18.98541 4.066029 4.0669966 0.0 0.0 +34.29022 22.897356 1.910744 26.537296 2.334651 2.1793833 0.0 0.0 +35.0 24.000093 3.1405983 18.98541 4.066029 4.0669966 0.0 0.0 +junction 58.0 24.001226 0 2 1 false +junction 58.0 24.001226 0 3 1 false +junction 58.0 24.001226 2 3 4 false +junction 58.0 24.001226 4 2 0 false +junction 58.0 24.001226 4 3 0 false +junction 58.0 24.001226 4 0 0 false +junction 35.0 24.000093 4 1 23 false +junction 34.29022 22.897356 5 1 1 false +junction 35.0 24.000093 5 1 2 false +junction 35.0 24.000093 5 4 2 false +case 10 1 0 +line 3 24 +58.0 24.001226 3.1409247 24.633741 1.0095084 1.0126655 0.0 0.0 +57.0 24.000172 3.1413417 31.334497 1.4677911 1.4697701 0.0 0.0 +56.0 24.000025 3.1417923 34.850307 1.7802817 1.7802018 0.0 0.0 +55.0 24.000229 3.1417494 36.39785 1.8179612 1.8181362 0.0 0.0 +54.0 24.00027 3.1414964 36.567993 1.8235064 1.8237689 0.0 0.0 +53.0 24.000032 3.14123 36.526157 1.8247054 1.8248273 0.0 0.0 +52.0 23.999643 3.1412506 36.511517 1.8249073 1.8247064 0.0 0.0 +51.0 23.999485 3.1416607 36.578846 1.8240235 1.8236635 0.0 0.0 +50.0 23.99977 3.1420407 36.74528 1.8220782 1.8219433 0.0 0.0 +49.0 24.000227 3.141976 36.958652 1.8197318 1.8199389 0.0 0.0 +48.0 24.000418 3.141568 37.120316 1.8179644 1.8182439 0.0 0.0 +47.0 24.000229 3.1413057 37.16771 1.8174535 1.8174855 0.0 0.0 +46.0 23.999994 3.1414685 37.098213 1.8182337 1.8180729 0.0 0.0 +45.0 24.000017 3.1417363 36.941998 1.8199022 1.8198632 0.0 0.0 +44.0 24.00019 3.1417458 36.756847 1.8219061 1.8220888 0.0 0.0 +43.0 24.00025 3.1415355 36.60988 1.8236425 1.823907 0.0 0.0 +42.0 24.000067 3.141299 36.528038 1.8246956 1.8248369 0.0 0.0 +41.0 23.999748 3.1413143 36.508327 1.8249764 1.8248396 0.0 0.0 +40.0 23.999626 3.1416605 36.54468 1.8249904 1.8247036 0.0 0.0 +39.0 23.99987 3.141971 36.484642 1.8301247 1.8299035 0.0 0.0 +38.0 24.000252 3.1418948 35.60976 1.8677063 1.8676336 0.0 0.0 +37.0 24.000456 3.1415308 32.397015 1.9941628 1.9942527 0.0 0.0 +36.0 24.000399 3.1411831 25.039423 2.2802353 2.2801342 0.0 0.0 +35.0 24.000093 3.1405983 18.98541 4.066029 4.0669966 0.0 0.0 diff --git a/scijava-ops-image-lgpl/LICENSE.txt b/scijava-ops-image-lgpl/LICENSE.txt new file mode 100644 index 000000000..0a041280b --- /dev/null +++ b/scijava-ops-image-lgpl/LICENSE.txt @@ -0,0 +1,165 @@ + GNU LESSER GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + + This version of the GNU Lesser General Public License incorporates +the terms and conditions of version 3 of the GNU General Public +License, supplemented by the additional permissions listed below. + + 0. Additional Definitions. + + As used herein, "this License" refers to version 3 of the GNU Lesser +General Public License, and the "GNU GPL" refers to version 3 of the GNU +General Public License. + + "The Library" refers to a covered work governed by this License, +other than an Application or a Combined Work as defined below. + + An "Application" is any work that makes use of an interface provided +by the Library, but which is not otherwise based on the Library. +Defining a subclass of a class defined by the Library is deemed a mode +of using an interface provided by the Library. + + A "Combined Work" is a work produced by combining or linking an +Application with the Library. The particular version of the Library +with which the Combined Work was made is also called the "Linked +Version". + + The "Minimal Corresponding Source" for a Combined Work means the +Corresponding Source for the Combined Work, excluding any source code +for portions of the Combined Work that, considered in isolation, are +based on the Application, and not on the Linked Version. + + The "Corresponding Application Code" for a Combined Work means the +object code and/or source code for the Application, including any data +and utility programs needed for reproducing the Combined Work from the +Application, but excluding the System Libraries of the Combined Work. + + 1. Exception to Section 3 of the GNU GPL. + + You may convey a covered work under sections 3 and 4 of this License +without being bound by section 3 of the GNU GPL. + + 2. Conveying Modified Versions. + + If you modify a copy of the Library, and, in your modifications, a +facility refers to a function or data to be supplied by an Application +that uses the facility (other than as an argument passed when the +facility is invoked), then you may convey a copy of the modified +version: + + a) under this License, provided that you make a good faith effort to + ensure that, in the event an Application does not supply the + function or data, the facility still operates, and performs + whatever part of its purpose remains meaningful, or + + b) under the GNU GPL, with none of the additional permissions of + this License applicable to that copy. + + 3. Object Code Incorporating Material from Library Header Files. + + The object code form of an Application may incorporate material from +a header file that is part of the Library. You may convey such object +code under terms of your choice, provided that, if the incorporated +material is not limited to numerical parameters, data structure +layouts and accessors, or small macros, inline functions and templates +(ten or fewer lines in length), you do both of the following: + + a) Give prominent notice with each copy of the object code that the + Library is used in it and that the Library and its use are + covered by this License. + + b) Accompany the object code with a copy of the GNU GPL and this license + document. + + 4. Combined Works. + + You may convey a Combined Work under terms of your choice that, +taken together, effectively do not restrict modification of the +portions of the Library contained in the Combined Work and reverse +engineering for debugging such modifications, if you also do each of +the following: + + a) Give prominent notice with each copy of the Combined Work that + the Library is used in it and that the Library and its use are + covered by this License. + + b) Accompany the Combined Work with a copy of the GNU GPL and this license + document. + + c) For a Combined Work that displays copyright notices during + execution, include the copyright notice for the Library among + these notices, as well as a reference directing the user to the + copies of the GNU GPL and this license document. + + d) Do one of the following: + + 0) Convey the Minimal Corresponding Source under the terms of this + License, and the Corresponding Application Code in a form + suitable for, and under terms that permit, the user to + recombine or relink the Application with a modified version of + the Linked Version to produce a modified Combined Work, in the + manner specified by section 6 of the GNU GPL for conveying + Corresponding Source. + + 1) Use a suitable shared library mechanism for linking with the + Library. A suitable mechanism is one that (a) uses at run time + a copy of the Library already present on the user's computer + system, and (b) will operate properly with a modified version + of the Library that is interface-compatible with the Linked + Version. + + e) Provide Installation Information, but only if you would otherwise + be required to provide such information under section 6 of the + GNU GPL, and only to the extent that such information is + necessary to install and execute a modified version of the + Combined Work produced by recombining or relinking the + Application with a modified version of the Linked Version. (If + you use option 4d0, the Installation Information must accompany + the Minimal Corresponding Source and Corresponding Application + Code. If you use option 4d1, you must provide the Installation + Information in the manner specified by section 6 of the GNU GPL + for conveying Corresponding Source.) + + 5. Combined Libraries. + + You may place library facilities that are a work based on the +Library side by side in a single library together with other library +facilities that are not Applications and are not covered by this +License, and convey such a combined library under terms of your +choice, if you do both of the following: + + a) Accompany the combined library with a copy of the same work based + on the Library, uncombined with any other library facilities, + conveyed under the terms of this License. + + b) Give prominent notice with the combined library that part of it + is a work based on the Library, and explaining where to find the + accompanying uncombined form of the same work. + + 6. Revised Versions of the GNU Lesser General Public License. + + The Free Software Foundation may publish revised and/or new versions +of the GNU Lesser General Public License from time to time. Such new +versions will be similar in spirit to the present version, but may +differ in detail to address new problems or concerns. + + Each version is given a distinguishing version number. If the +Library as you received it specifies that a certain numbered version +of the GNU Lesser General Public License "or any later version" +applies to it, you have the option of following the terms and +conditions either of that published version or of any later version +published by the Free Software Foundation. If the Library as you +received it does not specify a version number of the GNU Lesser +General Public License, you may choose any version of the GNU Lesser +General Public License ever published by the Free Software Foundation. + + If the Library as you received it specifies that a proxy can decide +whether future versions of the GNU Lesser General Public License shall +apply, that proxy's public statement of acceptance of any version is +permanent authorization for you to choose that version for the +Library. diff --git a/scijava-ops-image-lgpl/NOTICE.txt b/scijava-ops-image-lgpl/NOTICE.txt new file mode 100644 index 000000000..7e1114472 --- /dev/null +++ b/scijava-ops-image-lgpl/NOTICE.txt @@ -0,0 +1,25 @@ +SciJava Ops Image LGPL +Copyright (C) 2026 SciJava developers. + +This component is licensed under the GNU Lesser General Public License, +version 3 or later; see LICENSE.txt. + +This component contains functionality derived from MorphoLibJ: + + MorphoLibJ: integrated library and plugins for mathematical morphology + with ImageJ. + Copyright (C) 2014-2026 INRA. + Licensed under the GNU Lesser General Public License, version 3. + https://github.com/ijpb/MorphoLibJ + + D. Legland, I. Arganda-Carreras and P. Andrey, "MorphoLibJ: integrated + library and plugins for mathematical morphology with ImageJ", + Bioinformatics 32(22):3532-3534, 2016. doi:10.1093/bioinformatics/btw413 + +It contains code adapted from MorphoLibJ's source code: + + - org.scijava.ops.image.lgpl.intrinsicvolumes + (features.intrinsicVolumes.* ops) + +Each class's javadoc credits the original authors and the papers it is +based on. diff --git a/scijava-ops-image-lgpl/README.md b/scijava-ops-image-lgpl/README.md new file mode 100644 index 000000000..9bdff403e --- /dev/null +++ b/scijava-ops-image-lgpl/README.md @@ -0,0 +1,26 @@ +# SciJava Ops Image LGPL + +Image processing ops for [SciJava Ops](https://github.com/scijava/scijava) +translated from LGPL-licensed ImageJ and Fiji plugins, such as +[MorphoLibJ](https://github.com/ijpb/MorphoLibJ). + +[SciJava Ops Image](../scijava-ops-image) is BSD-2 licensed, so algorithms +that are only available as LGPL code, and that cannot practically be +reimplemented from their published descriptions, live here instead. Keeping +them apart from [SciJava Ops Image GPL](../scijava-ops-image-gpl) lets +downstream projects depend on them without GPL obligations. Each op credits +the original authors, project and license in its javadoc. + +## Contents + +| Ops | Derived from | +|---|---| +| `features.intrinsicVolumes.*` (area, perimeter, volume, surface area, mean breadth, Euler number, densities, interface area) | MorphoLibJ | + +## License + +This component is licensed under the GNU Lesser General Public License v3 or +later; see [LICENSE.txt](LICENSE.txt). + +See [NOTICE.txt](NOTICE.txt) for the projects this component's code derives +from, notably [MorphoLibJ](https://github.com/ijpb/MorphoLibJ). diff --git a/scijava-ops-image-lgpl/pom.xml b/scijava-ops-image-lgpl/pom.xml new file mode 100644 index 000000000..4bc5ebd83 --- /dev/null +++ b/scijava-ops-image-lgpl/pom.xml @@ -0,0 +1,304 @@ + + + 4.0.0 + + + org.scijava + pom-scijava + 44.0.0 + + + + scijava-ops-image-lgpl + 1.0.0-SNAPSHOT + + SciJava Ops Image LGPL + LGPL-licensed image processing operations for SciJava Ops, translated from LGPL ImageJ/Fiji plugins. + https://github.com/scijava/scijava + 2026 + + SciJava + https://scijava.org/ + + + + GNU Lesser General Public License v3+ + https://www.gnu.org/licenses/lgpl-3.0.html + repo + + + + + + ctrueden + Curtis Rueden + + founder + debugger + reviewer + maintainer + + + + gselzer + Gabriel Selzer + + founder + debugger + reviewer + support + + + + + + + None + + + + + + Image.sc Forum + https://forum.image.sc/tag/scijava + + + + + scm:git:https://github.com/scijava/scijava + scm:git:git@github.com:scijava/scijava + HEAD + https://github.com/scijava/scijava + + + GitHub Issues + https://github.com/scijava/scijava/issues + + + GitHub Actions + https://github.com/scijava/scijava/actions + + + + org.scijava.ops.image.lgpl + + lgpl_v3 + SciJava developers. + + + 1.0.1-SNAPSHOT + 1.0.1-SNAPSHOT + 1.0.1-SNAPSHOT + 1.0.1-SNAPSHOT + 2.0.0-SNAPSHOT + 1.0.1-SNAPSHOT + 1.0.1-SNAPSHOT + 1.0.1-SNAPSHOT + 1.0.1-SNAPSHOT + + true + + + + + + org.scijava + scijava-ops-api + ${scijava-ops-api.version} + + + org.scijava + scijava-ops-spi + ${scijava-ops-spi.version} + + + org.scijava + scijava-types + ${scijava-types.version} + + + + + net.imglib2 + imglib2 + + + net.imglib2 + imglib2-algorithm + + + net.imglib2 + imglib2-roi + + + + + org.junit.jupiter + junit-jupiter-api + test + + + org.scijava + scijava-ops-engine + ${scijava-ops-engine.version} + test + + + org.scijava + scijava-ops-image + ${scijava-ops-image.version} + test + + + + + + scijava.public + https://maven.scijava.org/content/groups/public + + + + + + + maven-compiler-plugin + + + + org.scijava + scijava-ops-indexer + ${scijava-ops-indexer.version} + + + true + + -Ascijava.ops.parse="${scijava.ops.parse}" + -Ascijava.ops.opVersion="${project.version}" + + + + + maven-javadoc-plugin + + + https://javadoc.scijava.org/Java11/ + https://javadoc.scijava.org/ImgLib2/ + https://javadoc.scijava.org/SCIFIO/ + https://javadoc.scijava.org/SciJava/ + + + + org.scijava + scijava-taglets + ${scijava-taglets.version} + + + + + implNote + a + Implementation Note: + + + + + + maven-enforcer-plugin + + + enforce-package-rules + + enforce + + test + + + + + + + + + + + maven-antrun-plugin + + + union-op-yaml + process-test-classes + + run + + + + + + + + + + + + + + + + only-eclipse-scijava + + + m2e.version + + + + + + + + org.eclipse.m2e + lifecycle-mapping + + 1.0.0 + + + + + + + maven-antrun-plugin + + run + + + + + true + + true + + + + + + + + + + + + + diff --git a/scijava-ops-image-lgpl/src/main/java/org/scijava/ops/image/lgpl/intrinsicvolumes/ConfigurationHistograms.java b/scijava-ops-image-lgpl/src/main/java/org/scijava/ops/image/lgpl/intrinsicvolumes/ConfigurationHistograms.java new file mode 100644 index 000000000..4a0319753 --- /dev/null +++ b/scijava-ops-image-lgpl/src/main/java/org/scijava/ops/image/lgpl/intrinsicvolumes/ConfigurationHistograms.java @@ -0,0 +1,300 @@ +/*- + * #%L + * LGPL-licensed image processing operations for SciJava Ops, translated from LGPL ImageJ/Fiji plugins. + * %% + * Copyright (C) 2026 SciJava developers. + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program 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 Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this program. If not, see + * . + * #L% + */ + + +package org.scijava.ops.image.lgpl.intrinsicvolumes; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.Map; + +import net.imglib2.Interval; +import net.imglib2.RandomAccess; +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.iterator.LocalizingIntervalIterator; +import net.imglib2.roi.labeling.ImgLabeling; +import net.imglib2.type.BooleanType; +import net.imglib2.type.numeric.IntegerType; +import net.imglib2.view.Views; + +/** + * Histograms of the binary configurations of 2×2 pixels or + * 2×2×2 voxels of an image, from which intrinsic volumes are + * obtained by applying the look-up tables of {@link IntrinsicVolumesLuts}. + *

+ * Translated from {@code BinaryConfigurationsHistogram2D} and + * {@code BinaryConfigurationsHistogram3D} of MorphoLibJ (LGPL-3, Copyright (C) + * 2014 - 2026 INRA) by David Legland, generalized to labelings with + * overlapping labels. + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @author David Legland + */ +final class ConfigurationHistograms { + + /** Tile voxels (bit indices) in an order where consecutive ones differ by one step. */ + private static final int[] ORDER = { 0, 1, 3, 2, 6, 7, 5, 4 }; + + /** Dimension of the step leading to each element of {@link #ORDER}. */ + private static final int[] MOVE_DIM = { -1, 0, 1, 0, 2, 0, 1, 0 }; + + /** Direction of the step leading to each element of {@link #ORDER}. */ + private static final boolean[] MOVE_FWD = { true, true, true, false, true, + true, false, false }; + + private ConfigurationHistograms() { + // Prevent instantiation of static utility class. + } + + /** + * Computes the histogram of binary configurations of a 2D or 3D binary + * image. + * + * @param image the binary image + * @param innerFrame if true, consider only the configurations fully + * contained in the image (for densities); otherwise, all the + * configurations containing at least one pixel of the image, with + * pixels outside the image considered as background + * @return the number of occurrences of each configuration + */ + static > long[] histogram( + final RandomAccessibleInterval image, final boolean innerFrame) + { + final var nd = checkDimensions(image); + final var hist = new long[1 << (1 << nd)]; + final var origins = origins(image, innerFrame); + if (origins == null) return hist; + final var bg = image.getType().createVariable(); + bg.set(false); + final var ra = Views.extendValue(image, bg).randomAccess(); + final var nVoxels = 1 << nd; + final var pos = new long[nd]; + while (origins.hasNext()) { + origins.fwd(); + origins.localize(pos); + ra.setPosition(pos); + var index = 0; + for (var k = 0; k < nVoxels; k++) { + move(ra, k); + if (ra.get().get()) index |= 1 << ORDER[k]; + } + hist[index]++; + } + return hist; + } + + /** + * Computes the histogram of binary configurations of each label of a 2D or + * 3D labeling, considering all the configurations containing at least one + * pixel of the labeling. For each label, pixels without that label are + * considered as background. + * + * @param labeling the labeling + * @return the configuration histogram of each label occurring in the + * labeling + */ + static > Map histograms( + final ImgLabeling labeling) + { + final var nd = checkDimensions(labeling); + final var mapping = labeling.getMapping(); + final var labels = new ArrayList<>(mapping.getLabels()); + final var ids = new HashMap(); + for (var i = 0; i < labels.size(); i++) + ids.put(labels.get(i), i); + final var setLabels = new int[mapping.numSets()][]; + for (var s = 0; s < setLabels.length; s++) { + setLabels[s] = mapping.labelsAtIndex(s).stream().mapToInt(ids::get) + .toArray(); + } + + final var nVoxels = 1 << nd; + final var hists = new long[labels.size()][1 << nVoxels]; + final var bits = new int[labels.size()]; + final var touched = new int[labels.size()]; + final var origins = origins(labeling, false); + final var ra = indexAccess(labeling); + final var pos = new long[nd]; + while (origins.hasNext()) { + origins.fwd(); + origins.localize(pos); + ra.setPosition(pos); + var nTouched = 0; + for (var k = 0; k < nVoxels; k++) { + move(ra, k); + for (final var id : setLabels[ra.get().getInteger()]) { + if (bits[id] == 0) touched[nTouched++] = id; + bits[id] |= 1 << ORDER[k]; + } + } + for (var t = 0; t < nTouched; t++) { + final var id = touched[t]; + hists[id][bits[id]]++; + bits[id] = 0; + } + } + + final var result = new LinkedHashMap(); + for (var i = 0; i < labels.size(); i++) { + if (isEmpty(hists[i])) continue; + result.put(labels.get(i), hists[i]); + } + return result; + } + + /** + * Computes the sum, over all 2×2×2 configurations fully contained + * in a 3D labeling, of the contributions of the pairs of voxels {@code (p, q)} + * of the configuration such that {@code p} has label {@code label1} and + * {@code q} has label {@code label2}. + * + * @param labeling the 3D labeling + * @param label1 the first label + * @param label2 the second label + * @param contribs the contribution of a voxel pair, for each of the 7 + * neighbor offsets X, Y, Z, XY, XZ, YZ and XYZ within a + * configuration + * @return the sum of contributions + */ + static > double interfaceSum( + final ImgLabeling labeling, final L label1, final L label2, + final double[] contribs) + { + if (labeling.numDimensions() != 3) { + throw new IllegalArgumentException( + "Interface area requires a 3D labeling, not " + labeling + .numDimensions() + "D"); + } + final var mapping = labeling.getMapping(); + final var has1 = new boolean[mapping.numSets()]; + final var has2 = new boolean[mapping.numSets()]; + for (var s = 0; s < has1.length; s++) { + has1[s] = mapping.labelsAtIndex(s).contains(label1); + has2[s] = mapping.labelsAtIndex(s).contains(label2); + } + // Bit masks flipping the coordinates of a voxel within a configuration, + // in the order X, Y, Z, XY, XZ, YZ and XYZ. + final int[] flips = { 1, 2, 4, 3, 5, 6, 7 }; + + final var origins = origins(labeling, true); + if (origins == null) return 0; + final var ra = indexAccess(labeling); + final var pos = new long[3]; + var sum = 0.0; + while (origins.hasNext()) { + origins.fwd(); + origins.localize(pos); + ra.setPosition(pos); + var bits1 = 0; + var bits2 = 0; + for (var k = 0; k < 8; k++) { + move(ra, k); + final var s = ra.get().getInteger(); + if (has1[s]) bits1 |= 1 << ORDER[k]; + if (has2[s]) bits2 |= 1 << ORDER[k]; + } + if (bits1 == 0 || bits2 == 0) continue; + for (var p = 0; p < 8; p++) { + if ((bits1 & 1 << p) == 0) continue; + for (var n = 0; n < flips.length; n++) { + if ((bits2 & 1 << (p ^ flips[n])) != 0) sum += contribs[n]; + } + } + } + return sum; + } + + /** Computes the dot product of a configuration histogram with a LUT. */ + static double apply(final long[] histogram, final double[] lut) { + var sum = 0.0; + for (var i = 0; i < histogram.length; i++) + sum += histogram[i] * lut[i]; + return sum; + } + + /** Applies a LUT to the configuration histogram of each label. */ + static Map apply(final Map histograms, + final double[] lut) + { + final var result = new LinkedHashMap(); + for (final var entry : histograms.entrySet()) + result.put(entry.getKey(), apply(entry.getValue(), lut)); + return result; + } + + // -- Helper methods -- + + private static int checkDimensions(final Interval interval) { + final var nd = interval.numDimensions(); + if (nd != 2 && nd != 3) { + throw new IllegalArgumentException( + "Only 2D and 3D images are supported, not " + nd + "D"); + } + return nd; + } + + /** + * Iterates over the minimal corners of the configurations, or returns null + * if there is none. + */ + private static LocalizingIntervalIterator origins(final Interval interval, + final boolean innerFrame) + { + final var nd = interval.numDimensions(); + final var min = new long[nd]; + final var max = new long[nd]; + for (var d = 0; d < nd; d++) { + min[d] = innerFrame ? interval.min(d) : interval.min(d) - 1; + max[d] = innerFrame ? interval.max(d) - 1 : interval.max(d); + if (max[d] < min[d]) return null; + } + return new LocalizingIntervalIterator(min, max); + } + + private static > RandomAccess indexAccess( + final ImgLabeling labeling) + { + // Note: Index 0 of a labeling mapping is always the empty label set. + final var indexImg = labeling.getIndexImg(); + final var empty = indexImg.getType().createVariable(); + empty.setZero(); + return Views.extendValue(indexImg, empty).randomAccess(); + } + + /** Moves from voxel {@code ORDER[k - 1]} to voxel {@code ORDER[k]}. */ + private static void move(final RandomAccess ra, final int k) { + if (k == 0) return; + if (MOVE_FWD[k]) ra.fwd(MOVE_DIM[k]); + else ra.bck(MOVE_DIM[k]); + } + + private static boolean isEmpty(final long[] histogram) { + for (final var count : histogram) + if (count != 0) return false; + return true; + } + +} diff --git a/scijava-ops-image-lgpl/src/main/java/org/scijava/ops/image/lgpl/intrinsicvolumes/IntrinsicVolumes.java b/scijava-ops-image-lgpl/src/main/java/org/scijava/ops/image/lgpl/intrinsicvolumes/IntrinsicVolumes.java new file mode 100644 index 000000000..2740e2421 --- /dev/null +++ b/scijava-ops-image-lgpl/src/main/java/org/scijava/ops/image/lgpl/intrinsicvolumes/IntrinsicVolumes.java @@ -0,0 +1,578 @@ +/*- + * #%L + * LGPL-licensed image processing operations for SciJava Ops, translated from LGPL ImageJ/Fiji plugins. + * %% + * Copyright (C) 2026 SciJava developers. + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program 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 Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this program. If not, see + * . + * #L% + */ + + +package org.scijava.ops.image.lgpl.intrinsicvolumes; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import net.imglib2.Interval; +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.algorithm.neighborhood.Shape; +import net.imglib2.roi.labeling.ImgLabeling; +import net.imglib2.type.BooleanType; +import net.imglib2.type.logic.BitType; +import net.imglib2.type.numeric.IntegerType; +import net.imglib2.util.ConstantUtils; + +import org.scijava.ops.spi.Nullable; + +/** + * Ops estimating the intrinsic volumes (Minkowski functionals) of 2D and 3D + * binary images and of each label of 2D and 3D labelings: area, perimeter and + * Euler number in 2D; volume, surface area, mean breadth and Euler number in + * 3D; their densities per unit area or volume; and the area of the interface + * between two labels. + *

+ * All measures are computed from the histogram of the binary configurations of + * 2×2 pixels (or 2×2×2 voxels) of the image, by weighting each + * configuration with a look-up table. Perimeter, surface area and mean breadth + * use discretizations of the Crofton formula over 2 or 4 directions in 2D, or 3 + * or 13 directions in 3D, which (for 4 and 13 directions) are much less biased + * than counting boundary pixels or measuring the length of a polygonal + * contour. Euler numbers are computed for a chosen connectivity of the + * foreground, given as a {@link Shape}: face connectivity (a + * {@code DiamondShape} of radius 1, i.e. 4-connectivity in 2D and + * 6-connectivity in 3D) or full connectivity (a {@code RectangleShape} of span + * 1, i.e. 8-connectivity in 2D and 26-connectivity in 3D), which is the + * default; other shapes are rejected. (MorphoLibJ defaults to face + * connectivity for Euler numbers.) Spatial calibration is supported via the + * pixel spacing. + *

+ *

+ * Per-object measures consider every configuration that contains at least one + * pixel of the image, treating pixels outside the image as background, so + * objects touching the border are measured as if the image were padded with + * background. Density measures instead use only the configurations fully + * contained in the image, normalized by the area or volume of the sampling + * window {@code (size - 1) * calibration} along each dimension, which gives + * unbiased estimates for a stationary random set observed through the image + * window (Ohser and Mücklich 2000). + *

+ *

+ * Relation to other ops: {@code geom.boundarySize} and {@code geom.size} + * measure polygons, meshes and label regions, and the perimeter of a polygonal + * contour overestimates that of the digitized shape; + * {@code topology.eulerCharacteristic26N} computes the 26-connected Euler + * characteristic of a 3D image from the same look-up table as + * {@code features.intrinsicVolumes.eulerNumber} with connectivity 26, but only + * over the configurations fully inside the image (it equals the latter on an + * image padded with background, and {@code topology.eulerCorrection} accounts + * for the border). These ops live in the {@code features.intrinsicVolumes} + * namespace rather than {@code geom} or {@code topology} because they are + * pixel-configuration estimators of a whole family of related measures with + * their own parameters (directions, connectivity), operating on binary images + * and labelings rather than on geometric objects. + *

+ *

+ * Translated from the {@code inra.ijpb.measure} package of MorphoLibJ (LGPL-3, Copyright (C) + * 2014 - 2026 INRA) by David Legland (classes {@code IntrinsicVolumes2D}, + * {@code IntrinsicVolumes3D}, {@code InterfaceSurfaceArea} and their helpers). + *

+ *

+ * References: + *

+ *
    + *
  • D. Legland, K. Kiêu and M.-F. Devaux, "Computation of Minkowski + * measures on 2D and 3D binary images", Image Analysis and Stereology + * 26(2): 83–92, 2007.
  • + *
  • J. Ohser and F. Mücklich, Statistical Analysis of Microstructures + * in Materials Science, Wiley, 2000.
  • + *
  • J. Ohser and K. Schladitz, 3D Images of Material Structures: + * Processing and Analysis, Wiley, 2009.
  • + *
  • D. Legland, I. Arganda-Carreras and P. Andrey, "MorphoLibJ: integrated + * library and plugins for mathematical morphology with ImageJ", + * Bioinformatics 32(22): 3532–3534, 2016.
  • + *
+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @author David Legland + */ +public final class IntrinsicVolumes { + + private IntrinsicVolumes() { + // Prevent instantiation of static utility class. + } + + // -- 2D -- + + /** + * Computes the area of the foreground of a 2D binary image. + * + * @param pixel type + * @param image the 2D binary image + * @param calibration the pixel spacing along each dimension (default 1) + * @return the area + * @implNote op names='features.intrinsicVolumes.area', type=Function + */ + public static > Double area( + final RandomAccessibleInterval image, @Nullable double[] calibration) + { + final var s = spacing(image, calibration, 2); + return measure(image, IntrinsicVolumesLuts.areaLut(s)); + } + + /** + * Computes the area of each label of a 2D labeling. + * + * @param label type + * @param index type of the labeling + * @param labeling the 2D labeling + * @param calibration the pixel spacing along each dimension (default 1) + * @return the area of each label + * @implNote op names='features.intrinsicVolumes.area', type=Function + */ + public static > Map area( + final ImgLabeling labeling, @Nullable double[] calibration) + { + final var s = spacing(labeling, calibration, 2); + return measure(labeling, IntrinsicVolumesLuts.areaLut(s)); + } + + /** + * Computes the perimeter of the foreground of a 2D binary image, using the + * Crofton formula discretized over 2 (horizontal and vertical) or 4 (plus + * diagonal) directions. + * + * @param pixel type + * @param image the 2D binary image + * @param calibration the pixel spacing along each dimension (default 1) + * @param directions the number of directions, 2 or 4 (default 4) + * @return the perimeter + * @implNote op names='features.intrinsicVolumes.perimeter', type=Function + */ + public static > Double perimeter( + final RandomAccessibleInterval image, @Nullable double[] calibration, + @Nullable Integer directions) + { + final var s = spacing(image, calibration, 2); + return measure(image, IntrinsicVolumesLuts.perimeterLut(s, or(directions, + 4))); + } + + /** + * Computes the perimeter of each label of a 2D labeling, using the Crofton + * formula discretized over 2 (horizontal and vertical) or 4 (plus diagonal) + * directions. + * + * @param label type + * @param index type of the labeling + * @param labeling the 2D labeling + * @param calibration the pixel spacing along each dimension (default 1) + * @param directions the number of directions, 2 or 4 (default 4) + * @return the perimeter of each label + * @implNote op names='features.intrinsicVolumes.perimeter', type=Function + */ + public static > Map perimeter( + final ImgLabeling labeling, @Nullable double[] calibration, + @Nullable Integer directions) + { + final var s = spacing(labeling, calibration, 2); + return measure(labeling, IntrinsicVolumesLuts.perimeterLut(s, or( + directions, 4))); + } + + // -- 3D -- + + /** + * Computes the volume of the foreground of a 3D binary image. + * + * @param voxel type + * @param image the 3D binary image + * @param calibration the voxel spacing along each dimension (default 1) + * @return the volume + * @implNote op names='features.intrinsicVolumes.volume', type=Function + */ + public static > Double volume( + final RandomAccessibleInterval image, @Nullable double[] calibration) + { + final var s = spacing(image, calibration, 3); + return measure(image, IntrinsicVolumesLuts.volumeLut(s)); + } + + /** + * Computes the volume of each label of a 3D labeling. + * + * @param label type + * @param index type of the labeling + * @param labeling the 3D labeling + * @param calibration the voxel spacing along each dimension (default 1) + * @return the volume of each label + * @implNote op names='features.intrinsicVolumes.volume', type=Function + */ + public static > Map volume( + final ImgLabeling labeling, @Nullable double[] calibration) + { + final var s = spacing(labeling, calibration, 3); + return measure(labeling, IntrinsicVolumesLuts.volumeLut(s)); + } + + /** + * Computes the surface area of the foreground of a 3D binary image, using + * the Crofton formula discretized over 3 (isothetic) or 13 (plus face and + * cube diagonal) directions. + * + * @param voxel type + * @param image the 3D binary image + * @param calibration the voxel spacing along each dimension (default 1) + * @param directions the number of directions, 3 or 13 (default 13) + * @return the surface area + * @implNote op names='features.intrinsicVolumes.surfaceArea', type=Function + */ + public static > Double surfaceArea( + final RandomAccessibleInterval image, @Nullable double[] calibration, + @Nullable Integer directions) + { + final var s = spacing(image, calibration, 3); + return measure(image, IntrinsicVolumesLuts.surfaceAreaLut(s, or( + directions, 13))); + } + + /** + * Computes the surface area of each label of a 3D labeling, using the + * Crofton formula discretized over 3 (isothetic) or 13 (plus face and cube + * diagonal) directions. + * + * @param label type + * @param index type of the labeling + * @param labeling the 3D labeling + * @param calibration the voxel spacing along each dimension (default 1) + * @param directions the number of directions, 3 or 13 (default 13) + * @return the surface area of each label + * @implNote op names='features.intrinsicVolumes.surfaceArea', type=Function + */ + public static > Map surfaceArea( + final ImgLabeling labeling, @Nullable double[] calibration, + @Nullable Integer directions) + { + final var s = spacing(labeling, calibration, 3); + return measure(labeling, IntrinsicVolumesLuts.surfaceAreaLut(s, or( + directions, 13))); + } + + /** + * Computes the mean breadth of the foreground of a 3D binary image, i.e. + * the mean over all directions of its projected diameter (proportional to + * the integral of the mean curvature), using the Crofton formula over the + * Euler numbers of planar sections in 3 or 13 directions. The mean breadth + * of a ball is its diameter. + * + * @param voxel type + * @param image the 3D binary image + * @param calibration the voxel spacing along each dimension (default 1) + * @param directions the number of directions, 3 or 13 (default 13) + * @param connectivity the connectivity used to compute the Euler numbers of + * planar sections: face connectivity (a {@code DiamondShape} of + * radius 1, i.e. 4-connected sections) or full connectivity (a + * {@code RectangleShape} of span 1, i.e. 8-connected sections). + * Defaults to full connectivity. + * @return the mean breadth + * @implNote op names='features.intrinsicVolumes.meanBreadth', type=Function + */ + public static > Double meanBreadth( + final RandomAccessibleInterval image, @Nullable double[] calibration, + @Nullable Integer directions, @Nullable Shape connectivity) + { + final var s = spacing(image, calibration, 3); + return measure(image, IntrinsicVolumesLuts.meanBreadthLut(s, or( + directions, 13), isFace(connectivity, 3) ? 4 : 8)); + } + + /** + * Computes the mean breadth of each label of a 3D labeling, i.e. the mean + * over all directions of its projected diameter, using the Crofton formula + * over the Euler numbers of planar sections in 3 or 13 directions. + * + * @param label type + * @param index type of the labeling + * @param labeling the 3D labeling + * @param calibration the voxel spacing along each dimension (default 1) + * @param directions the number of directions, 3 or 13 (default 13) + * @param connectivity the connectivity used to compute the Euler numbers of + * planar sections: face connectivity (a {@code DiamondShape} of + * radius 1, i.e. 4-connected sections) or full connectivity (a + * {@code RectangleShape} of span 1, i.e. 8-connected sections). + * Defaults to full connectivity. + * @return the mean breadth of each label + * @implNote op names='features.intrinsicVolumes.meanBreadth', type=Function + */ + public static > Map meanBreadth( + final ImgLabeling labeling, @Nullable double[] calibration, + @Nullable Integer directions, @Nullable Shape connectivity) + { + final var s = spacing(labeling, calibration, 3); + return measure(labeling, IntrinsicVolumesLuts.meanBreadthLut(s, or( + directions, 13), isFace(connectivity, 3) ? 4 : 8)); + } + + /** + * Computes the area of the interface between two labels of a 3D labeling, + * using the Crofton formula discretized over 3 or 13 directions, by counting + * the pairs of voxels of the first and second label within each + * 2×2×2 configuration. For two complementary regions, the + * result equals the surface area of either region (away from the image + * border). As in MorphoLibJ, only the configurations fully contained in the + * image are considered. Unlike MorphoLibJ, the 3-direction estimate is + * calibrated and consistent with {@code features.intrinsicVolumes.surfaceArea} + * (MorphoLibJ returns twice that value, regardless of the spacing). + * + * @param label type + * @param index type of the labeling + * @param labeling the 3D labeling + * @param label1 the first label + * @param label2 the second label + * @param calibration the voxel spacing along each dimension (default 1) + * @param directions the number of directions, 3 or 13 (default 13) + * @return the interface area + * @implNote op names='features.intrinsicVolumes.interfaceArea', type=Function + */ + public static > Double interfaceArea( + final ImgLabeling labeling, final L label1, final L label2, + @Nullable double[] calibration, @Nullable Integer directions) + { + final var s = spacing(labeling, calibration, 3); + final var contribs = IntrinsicVolumesLuts.interfaceContributions(s, or( + directions, 13)); + return ConfigurationHistograms.interfaceSum(labeling, label1, label2, + contribs); + } + + // -- 2D and 3D -- + + /** + * Computes the Euler number of the foreground of a 2D or 3D binary image: + * in 2D, the number of connected components minus the number of holes; in + * 3D, the number of connected components minus the number of tunnels + * (handles) plus the number of cavities. + * + * @param pixel type + * @param image the 2D or 3D binary image + * @param connectivity the connectivity of the foreground: face connectivity + * (a {@code DiamondShape} of radius 1, i.e. 4-connectivity in 2D + * and 6-connectivity in 3D) or full connectivity (a + * {@code RectangleShape} of span 1, i.e. 8-connectivity in 2D and + * 26-connectivity in 3D). Defaults to full connectivity. + * @return the Euler number + * @implNote op names='features.intrinsicVolumes.eulerNumber', type=Function + */ + public static > Double eulerNumber( + final RandomAccessibleInterval image, @Nullable Shape connectivity) + { + return measure(image, eulerLut(image, connectivity)); + } + + /** + * Computes the Euler number of each label of a 2D or 3D labeling: in 2D, the + * number of connected components minus the number of holes; in 3D, the + * number of connected components minus the number of tunnels (handles) plus + * the number of cavities. + * + * @param label type + * @param index type of the labeling + * @param labeling the 2D or 3D labeling + * @param connectivity the connectivity of the labels: face connectivity (a + * {@code DiamondShape} of radius 1, i.e. 4-connectivity in 2D and + * 6-connectivity in 3D) or full connectivity (a + * {@code RectangleShape} of span 1, i.e. 8-connectivity in 2D and + * 26-connectivity in 3D). Defaults to full connectivity. + * @return the Euler number of each label + * @implNote op names='features.intrinsicVolumes.eulerNumber', type=Function + */ + public static > Map eulerNumber( + final ImgLabeling labeling, @Nullable Shape connectivity) + { + return measure(labeling, eulerLut(labeling, connectivity)); + } + + /** + * Computes the densities of the intrinsic volumes of the foreground of a 2D + * or 3D binary image, considered as a sample of a stationary random set + * (microstructure analysis), i.e. the measures per unit area or volume of + * the sampling window. Only the configurations fully contained in the image + * are considered, which avoids edge effects. The image must therefore have + * a size of at least 2 along each dimension; an image with a dimension of + * size 1 (e.g. a one-slice 3D stack) is rejected with an + * {@link IllegalArgumentException}. + * + * @param pixel type + * @param image the 2D or 3D binary image + * @param calibration the pixel spacing along each dimension (default 1) + * @param directions the number of directions of the Crofton formula: 2 or 4 + * in 2D (default 4), 3 or 13 in 3D (default 13) + * @param connectivity the connectivity of the foreground for the Euler + * number: face connectivity (a {@code DiamondShape} of radius 1, + * i.e. 4-connectivity in 2D and 6-connectivity in 3D) or full + * connectivity (a {@code RectangleShape} of span 1, i.e. + * 8-connectivity in 2D and 26-connectivity in 3D). Defaults to full + * connectivity. + * @return in 2D, a map with the entries {@code areaDensity} (the area + * fraction), {@code perimeterDensity} and + * {@code eulerNumberDensity}; in 3D, a map with the entries + * {@code volumeDensity} (the volume fraction), + * {@code surfaceAreaDensity}, {@code meanBreadthDensity} (with + * 8-connected planar sections) and {@code eulerNumberDensity} + * @implNote op names='features.intrinsicVolumes.densities', type=Function + */ + public static > Map densities( + final RandomAccessibleInterval image, @Nullable double[] calibration, + @Nullable Integer directions, @Nullable Shape connectivity) + { + final var nd = image.numDimensions(); + for (var d = 0; d < nd; d++) { + if (image.dimension(d) < 2) { + throw new IllegalArgumentException("Dimension " + d + + " has size 1, so no configuration lies inside the image; drop it" + + " (e.g. with Views.dropSingletonDimensions) to measure a " + (nd - + 1) + "D image"); + } + } + final var s = spacing(image, calibration, nd); + final var hist = ConfigurationHistograms.histogram(image, true); + var window = 1.0; + for (var d = 0; d < nd; d++) + window *= (image.dimension(d) - 1) * s[d]; + final var eulerLut = eulerLut(image, connectivity); + final var result = new LinkedHashMap(); + if (nd == 2) { + final var nDirs = or(directions, 4); + result.put("areaDensity", ConfigurationHistograms.apply(hist, + IntrinsicVolumesLuts.areaLut(s)) / window); + result.put("perimeterDensity", ConfigurationHistograms.apply(hist, + IntrinsicVolumesLuts.perimeterLut(s, nDirs)) / window); + } + else { + final var nDirs = or(directions, 13); + result.put("volumeDensity", ConfigurationHistograms.apply(hist, + IntrinsicVolumesLuts.volumeLut(s)) / window); + result.put("surfaceAreaDensity", ConfigurationHistograms.apply(hist, + IntrinsicVolumesLuts.surfaceAreaLut(s, nDirs)) / window); + result.put("meanBreadthDensity", ConfigurationHistograms.apply(hist, + IntrinsicVolumesLuts.meanBreadthLut(s, nDirs, 8)) / window); + } + result.put("eulerNumberDensity", ConfigurationHistograms.apply(hist, + eulerLut) / window); + return result; + } + + // -- Helper methods -- + + private static > double measure( + final RandomAccessibleInterval image, final double[] lut) + { + return ConfigurationHistograms.apply(ConfigurationHistograms.histogram( + image, false), lut); + } + + private static > Map measure( + final ImgLabeling labeling, final double[] lut) + { + return ConfigurationHistograms.apply(ConfigurationHistograms.histograms( + labeling), lut); + } + + private static double[] eulerLut(final Interval interval, + final Shape connectivity) + { + switch (interval.numDimensions()) { + case 2: + return IntrinsicVolumesLuts.eulerNumberLut2D(isFace(connectivity, 2) + ? 4 : 8); + case 3: + return IntrinsicVolumesLuts.eulerNumberLut3D(isFace(connectivity, 3) + ? 6 : 26); + default: + throw new IllegalArgumentException( + "Only 2D and 3D images are supported, not " + interval + .numDimensions() + "D"); + } + } + + private static double[] spacing(final Interval interval, + final double[] calibration, final int nd) + { + if (interval.numDimensions() != nd) { + throw new IllegalArgumentException("Expected a " + nd + + "D image, not " + interval.numDimensions() + "D"); + } + if (calibration == null) { + final var ones = new double[nd]; + Arrays.fill(ones, 1); + return ones; + } + if (calibration.length != nd) { + throw new IllegalArgumentException("Expected " + nd + + " calibration values, not " + calibration.length); + } + return calibration; + } + + /** + * Tells whether a connectivity is face connectivity (true) or full + * connectivity (false, also for null), by enumerating its neighborhood in + * {@code n} dimensions. + * + * @throws IllegalArgumentException if it is neither + */ + private static boolean isFace(final Shape connectivity, final int n) { + if (connectivity == null) return false; + final var source = ConstantUtils.constantRandomAccessible(new BitType(), + n); + final var ra = connectivity.neighborhoodsRandomAccessible(source) + .randomAccess(); + ra.setPosition(new long[n]); + final var c = ra.get().localizingCursor(); + final var offsets = new HashSet>(); + var face = true; + var full = true; + while (c.hasNext()) { + c.fwd(); + final var o = new ArrayList(n); + var nonzero = 0; + for (var d = 0; d < n; d++) { + final var v = c.getIntPosition(d); + o.add(v); + if (v != 0) nonzero++; + if (Math.abs(v) > 1) full = false; + } + if (nonzero == 0) continue; + if (nonzero != 1) face = false; + offsets.add(o); + } + if (face && offsets.size() == 2 * n) return true; + if (full && offsets.size() == (int) Math.pow(3, n) - 1) return false; + throw new IllegalArgumentException("Unsupported connectivity: expected " + + "face connectivity (a DiamondShape of radius 1) or full connectivity " + + "(a RectangleShape of span 1), not " + connectivity); + } + + private static int or(final Integer value, final int defaultValue) { + return value == null ? defaultValue : value; + } + +} diff --git a/scijava-ops-image-lgpl/src/main/java/org/scijava/ops/image/lgpl/intrinsicvolumes/IntrinsicVolumesLuts.java b/scijava-ops-image-lgpl/src/main/java/org/scijava/ops/image/lgpl/intrinsicvolumes/IntrinsicVolumesLuts.java new file mode 100644 index 000000000..c8f58b78e --- /dev/null +++ b/scijava-ops-image-lgpl/src/main/java/org/scijava/ops/image/lgpl/intrinsicvolumes/IntrinsicVolumesLuts.java @@ -0,0 +1,544 @@ +/*- + * #%L + * LGPL-licensed image processing operations for SciJava Ops, translated from LGPL ImageJ/Fiji plugins. + * %% + * Copyright (C) 2026 SciJava developers. + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program 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 Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this program. If not, see + * . + * #L% + */ + + +package org.scijava.ops.image.lgpl.intrinsicvolumes; + +/** + * Look-up tables associating each binary configuration of 2×2 pixels + * (16 configurations) or 2×2×2 voxels (256 configurations) with its + * contribution to an intrinsic volume. + *

+ * Configuration indices encode pixel {@code (dx, dy)} (or voxel + * {@code (dx, dy, dz)}) of the tile in bit {@code dx + 2 dy (+ 4 dz)}. + *

+ *

+ * Translated from {@code IntrinsicVolumes2DUtils}, + * {@code IntrinsicVolumes3DUtils}, {@code GeometryUtils} and + * {@code InterfaceSurfaceArea} of MorphoLibJ (LGPL-3, Copyright (C) + * 2014 - 2026 INRA) by David Legland. See Legland, Kiêu and Devaux, + * "Computation of Minkowski measures on 2D and 3D binary images", Image + * Analysis and Stereology 26(2): 83–92, 2007; Ohser and + * Mücklich, Statistical Analysis of Microstructures in Materials + * Science, Wiley, 2000; and Ohser and Schladitz, 3D Images of Material + * Structures, Wiley, 2009 (Euler number tables 3.2 and 3.3). + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @author David Legland + */ +final class IntrinsicVolumesLuts { + + private IntrinsicVolumesLuts() { + // Prevent instantiation of static utility class. + } + + // -- 2D -- + + /** Area contribution of each 2x2 configuration. */ + static double[] areaLut(final double[] spacing) { + final var lut = new double[16]; + final var pixelArea = spacing[0] * spacing[1]; + for (var i = 0; i < 16; i++) + lut[i] = Integer.bitCount(i) / 4.0 * pixelArea; + return lut; + } + + /** Crofton perimeter contribution of each 2x2 configuration. */ + static double[] perimeterLut(final double[] spacing, final int nDirs) { + if (nDirs != 2 && nDirs != 4) { + throw new IllegalArgumentException( + "Number of directions must be 2 or 4, not " + nDirs); + } + final var d1 = spacing[0]; + final var d2 = spacing[1]; + final var d12 = Math.hypot(d1, d2); + final var area = d1 * d2; + + // Weights of the four directions: [1 0], [0 1], [1 1] and [-1 1]. + final var theta = Math.atan2(d2, d1); + final double[] weights = { theta / Math.PI, (Math.PI / 2 - theta) / + Math.PI, 0.25, 0.25 }; + + final var lut = new double[16]; + final var im = new boolean[2][2]; + for (var i = 0; i < 16; i++) { + im[0][0] = (i & 1) > 0; + im[0][1] = (i & 2) > 0; + im[1][0] = (i & 4) > 0; + im[1][1] = (i & 8) > 0; + for (var y = 0; y < 2; y++) { + for (var x = 0; x < 2; x++) { + if (!im[y][x]) continue; + // Note: Dividing by two converts intersection counts to + // projected diameters. + final var ke1 = im[y][1 - x] ? 0 : (area / d1) / 2; + final var ke2 = im[1 - y][x] ? 0 : (area / d2) / 2; + if (nDirs == 2) { + // Note: Divide by two for the average over directions, and by + // two for the multiplicity. + lut[i] += (ke1 + ke2) / 4; + } + else { + final var ke12 = im[1 - y][1 - x] ? 0 : (area / d12) / 2; + lut[i] += (ke1 / 2) * weights[0] + (ke2 / 2) * weights[1] + + ke12 * weights[2]; + } + } + } + lut[i] *= Math.PI; + } + return lut; + } + + /** Euler number contribution of each 2x2 configuration. */ + static double[] eulerNumberLut2D(final int conn) { + switch (conn) { + case 4: + return new double[] { 0, 0.25, 0.25, 0, 0.25, 0, 0.5, -0.25, 0.25, + 0.5, 0, -0.25, 0, -0.25, -0.25, 0 }; + case 8: + return new double[] { 0, 0.25, 0.25, 0, 0.25, 0, -0.5, -0.25, 0.25, + -0.5, 0, -0.25, 0, -0.25, -0.25, 0 }; + default: + throw new IllegalArgumentException( + "2D connectivity must be 4 or 8, not " + conn); + } + } + + // -- 3D -- + + /** Volume contribution of each 2x2x2 configuration. */ + static double[] volumeLut(final double[] spacing) { + final var lut = new double[256]; + final var voxelVolume = spacing[0] * spacing[1] * spacing[2]; + for (var i = 0; i < 256; i++) + lut[i] = Integer.bitCount(i) * voxelVolume / 8; + return lut; + } + + /** Crofton surface area contribution of each 2x2x2 configuration. */ + static double[] surfaceAreaLut(final double[] spacing, final int nDirs) { + checkDirections3D(nDirs); + final var d1 = spacing[0]; + final var d2 = spacing[1]; + final var d3 = spacing[2]; + final var vol = d1 * d2 * d3; + final var d12 = Math.hypot(d1, d2); + final var d13 = Math.hypot(d1, d3); + final var d23 = Math.hypot(d2, d3); + final var d123 = Math.hypot(d12, d3); + final var weights = nDirs == 13 ? directionWeights13(spacing) : null; + + final var lut = new double[256]; + final var im = new boolean[2][2][2]; + final var kei = new double[7]; + for (var i = 0; i < 256; i++) { + updateTile(im, i); + for (var z = 0; z < 2; z++) { + for (var y = 0; y < 2; y++) { + for (var x = 0; x < 2; x++) { + if (!im[z][y][x]) continue; + if (nDirs == 3) { + final var ke1 = im[z][y][1 - x] ? 0 : vol / d1 / 2.0; + final var ke2 = im[z][1 - y][x] ? 0 : vol / d2 / 2.0; + final var ke3 = im[1 - z][y][x] ? 0 : vol / d3 / 2.0; + // Note: With 3 directions, the multiplicity 4 cancels the + // factor 4 of the Crofton formula, so we just average. + lut[i] += (ke1 + ke2 + ke3) / 3; + continue; + } + kei[0] = im[z][y][1 - x] ? 0 : vol / d1 / 8.0; + kei[1] = im[z][1 - y][x] ? 0 : vol / d2 / 8.0; + kei[2] = im[1 - z][y][x] ? 0 : vol / d3 / 8.0; + kei[3] = im[z][1 - y][1 - x] ? 0 : vol / d12 / 4.0; + kei[4] = im[1 - z][y][1 - x] ? 0 : vol / d13 / 4.0; + kei[5] = im[1 - z][1 - y][x] ? 0 : vol / d23 / 4.0; + kei[6] = im[1 - z][1 - y][1 - x] ? 0 : vol / d123 / 2.0; + for (var k = 0; k < 7; k++) + lut[i] += 4 * kei[k] * weights[k]; + } + } + } + } + return lut; + } + + /** Crofton mean breadth contribution of each 2x2x2 configuration. */ + static double[] meanBreadthLut(final double[] spacing, final int nDirs, + final int conn2d) + { + checkDirections3D(nDirs); + if (conn2d != 4 && conn2d != 8) { + throw new IllegalArgumentException( + "2D connectivity must be 4 or 8, not " + conn2d); + } + final var d1 = spacing[0]; + final var d2 = spacing[1]; + final var d3 = spacing[2]; + final var vol = d1 * d2 * d3; + final var d12 = Math.hypot(d1, d2); + final var d13 = Math.hypot(d1, d3); + final var d23 = Math.hypot(d2, d3); + + // Areas of the elementary profiles along each direction. + final var s = (d12 + d13 + d23) / 2; + final double[] areas = { d2 * d3, d1 * d3, d1 * d2, d3 * d12, d2 * d13, + d1 * d23, 2 * Math.sqrt(s * (s - d12) * (s - d13) * (s - d23)) }; + final var weights = nDirs == 13 ? directionWeights13(spacing) : null; + final var diams = new double[7]; + + final var lut = new double[256]; + final var im = new boolean[2][2][2]; + // Note: The empty and full configurations contribute zero by definition. + for (var i = 1; i < 255; i++) { + updateTile(im, i); + for (var v = 0; v < 8; v++) { + final var p1 = v >> 1 & 1; + final var p2 = v & 1; + final var p3 = v >> 2 & 1; + if (!im[p1][p2][p3]) continue; + final var q1 = 1 - p1; + final var q2 = 1 - p2; + final var q3 = 1 - p3; + + // Faces in each isothetic direction. + final boolean[] face1 = { true, im[p1][q2][p3], im[p1][p2][q3], + im[p1][q2][q3] }; + final boolean[] face2 = { true, im[q1][p2][p3], im[p1][p2][q3], + im[q1][p2][q3] }; + final boolean[] face3 = { true, im[q1][p2][p3], im[p1][q2][p3], + im[q1][q2][p3] }; + final var f1 = eulerContribTile2D(face1, conn2d, d3, d2); + final var f2 = eulerContribTile2D(face2, conn2d, d3, d1); + final var f3 = eulerContribTile2D(face3, conn2d, d2, d1); + if (nDirs == 3) { + // Note: Divide by 3 for the average over directions, and by 2 + // because each face is shared by 2 configurations. + lut[i] += vol * (f1 / areas[0] + f2 / areas[1] + f3 / areas[2]) / + 6; + continue; + } + diams[0] = f1 / 2.0; + diams[1] = f2 / 2.0; + diams[2] = f3 / 2.0; + + // Faces normal to the square diagonals. + final boolean[] face4 = { true, im[q1][q2][p3], im[p1][p2][q3], + im[q1][q2][q3] }; + final boolean[] face6 = { true, im[q1][p2][q3], im[p1][q2][p3], + im[q1][q2][q3] }; + final boolean[] face8 = { true, im[p1][q2][q3], im[q1][p2][p3], + im[q1][q2][q3] }; + diams[3] = eulerContribTile2D(face4, conn2d, d12, d3); + diams[4] = eulerContribTile2D(face6, conn2d, d13, d2); + diams[5] = eulerContribTile2D(face8, conn2d, d23, d1); + + // Triangular faces normal to the cube diagonals. + final boolean[] faceA = { true, im[q1][q2][p3], im[q1][p2][q3] }; + final boolean[] faceB = { true, im[q1][p2][q3], im[p1][q2][q3] }; + final boolean[] faceC = { true, im[q1][q2][p3], im[p1][q2][q3] }; + diams[6] = eulerContribTriangle(faceA, d12, d13, d23) + + eulerContribTriangle(faceB, d13, d23, d12) + eulerContribTriangle( + faceC, d12, d23, d13); + + for (var k = 0; k < 7; k++) + lut[i] += vol * (diams[k] / areas[k]) * weights[k]; + } + } + return lut; + } + + /** Euler number contribution of each 2x2x2 configuration. */ + static double[] eulerNumberLut3D(final int conn) { + final int[] lut; + if (conn == 6) lut = EULER_C6; + else if (conn == 26) lut = EULER_C26; + else { + throw new IllegalArgumentException( + "3D connectivity must be 6 or 26, not " + conn); + } + final var result = new double[256]; + for (var i = 0; i < 256; i++) + result[i] = lut[i] / 8.0; + return result; + } + + /** + * Contribution of each pair of voxels within a 2x2x2 configuration to the + * interface surface area, for the 7 neighbor offsets X, Y, Z, XY, XZ, YZ and + * XYZ. + *

+ * Unlike MorphoLibJ, the contributions for 3 directions are calibrated and + * consistent with {@link #surfaceAreaLut}: MorphoLibJ uses an uncalibrated + * contribution of 1/3 per voxel pair, which yields twice the 3-direction + * surface area estimate of a region bounded by a single other region. + *

+ */ + static double[] interfaceContributions(final double[] spacing, + final int nDirs) + { + checkDirections3D(nDirs); + final var d1 = spacing[0]; + final var d2 = spacing[1]; + final var d3 = spacing[2]; + final var vol = d1 * d2 * d3; + if (nDirs == 3) { + return new double[] { vol / d1 / 6, vol / d2 / 6, vol / d3 / 6, 0, 0, 0, + 0 }; + } + final var d12 = Math.hypot(d1, d2); + final var d13 = Math.hypot(d1, d3); + final var d23 = Math.hypot(d2, d3); + final var d123 = Math.hypot(d12, d3); + final var w = directionWeights13(spacing); + return new double[] { 4 * w[0] * vol / d1 / 8, 4 * w[1] * vol / d2 / 8, + 4 * w[2] * vol / d3 / 8, 4 * w[3] * vol / d12 / 4, 4 * w[4] * vol / d13 / + 4, 4 * w[5] * vol / d23 / 4, 4 * w[6] * vol / d123 / 2 }; + } + + /** + * Weights of the 7 direction classes (X, Y, Z, XY, XZ, YZ, XYZ) among the 13 + * directions of the cubic grid, given by the areas of the spherical Voronoi + * domains of the 26 neighbor directions. The weights satisfy + * {@code w0 + w1 + w2 + 2 (w3 + w4 + w5) + 4 w6 = 1}. + */ + static double[] directionWeights13(final double[] spacing) { + final var dx = spacing[0]; + final var dy = spacing[1]; + final var dz = spacing[2]; + if (dx == dy && dx == dz) { + // Precomputed weights for cubic voxels. + final var w1 = 0.04577789120476 * 2; + final var w2 = 0.03698062787608 * 2; + final var w3 = 0.03519563978232 * 2; + return new double[] { w1, w1, w1, w2, w2, w2, w3 }; + } + // Reference vectors are named after the sign of each coordinate: + // P (positive), N (negative) or Z (zero). + final var vPNN = unit(dx, -dy, -dz); + final var vPZN = unit(dx, 0, -dz); + final var vNPN = unit(-dx, dy, -dz); + final var vZPN = unit(0, dy, -dz); + final var vPPN = unit(dx, dy, -dz); + final var vPNZ = unit(dx, -dy, 0); + final var vPZZ = unit(dx, 0, 0); + final var vNPZ = unit(-dx, dy, 0); + final var vZPZ = unit(0, dy, 0); + final var vPPZ = unit(dx, dy, 0); + final var vNNP = unit(-dx, -dy, dz); + final var vZNP = unit(0, -dy, dz); + final var vPNP = unit(dx, -dy, dz); + final var vNZP = unit(-dx, 0, dz); + final var vZZP = unit(0, 0, dz); + final var vPZP = unit(dx, 0, dz); + final var vNPP = unit(-dx, dy, dz); + final var vZPP = unit(0, dy, dz); + final var vPPP = unit(dx, dy, dz); + + final var weights = new double[7]; + weights[0] = voronoiArea(vPZZ, vPNN, vPNZ, vPNP, vPZP, vPPP, vPPZ, vPPN, + vPZN); + weights[1] = voronoiArea(vZPZ, vPPZ, vPPP, vZPP, vNPP, vNPZ, vNPN, vZPN, + vPPN); + weights[2] = voronoiArea(vZZP, vPZP, vPPP, vZPP, vNPP, vNZP, vNNP, vZNP, + vPNP); + weights[3] = voronoiArea(vPPZ, vPZZ, vPPP, vZPZ, vPPN); + weights[4] = voronoiArea(vPZP, vPZZ, vPPP, vZZP, vPNP); + weights[5] = voronoiArea(vZPP, vZPZ, vNPP, vZZP, vPPP); + weights[6] = voronoiArea(vPPP, vPZP, vZZP, vZPP, vZPZ, vPPZ, vPZZ); + for (var k = 0; k < 7; k++) + weights[k] /= 2 * Math.PI; + return weights; + } + + // -- Helper methods -- + + private static void checkDirections3D(final int nDirs) { + if (nDirs != 3 && nDirs != 13) { + throw new IllegalArgumentException( + "Number of directions must be 3 or 13, not " + nDirs); + } + } + + private static void updateTile(final boolean[][][] tile, final int index) { + for (var b = 0; b < 8; b++) + tile[b >> 2 & 1][b >> 1 & 1][b & 1] = (index & 1 << b) != 0; + } + + /** + * Euler number contribution of the reference vertex {@code face[0]} of a + * rectangular tile; {@code d1} and {@code d2} are the distances from vertex 0 + * to vertices 1 and 2, and vertex 3 is opposite to vertex 0. + */ + private static double eulerContribTile2D(final boolean[] face, + final int conn2d, final double d1, final double d2) + { + var n = 0; + for (final var b : face) + if (b) n++; + switch (n) { + case 1: + return 0.25; + case 2: + // An isothetic edge is shared with another tile; a diagonal one is + // an isolated pair (C4) or an edge (C8). + return face[3] ? (conn2d == 4 ? 0.25 : -0.25) : 0; + case 3: + if (conn2d == 4) return face[3] ? 0 : -0.25; + if (!face[3]) return 0; + // Triangle seen from an acute angle. + final var alpha = face[2] ? Math.atan2(d2, d1) : Math.atan2(d1, d2); + return (Math.PI - alpha) / (2 * Math.PI) - 0.5; + default: + return 0; + } + } + + /** + * Euler number contribution of the reference vertex {@code face[0]} of a + * triangular tile, with side lengths {@code d1} (vertices 0-1), {@code d2} + * (vertices 0-2) and {@code d3} (vertices 1-2). + */ + private static double eulerContribTriangle(final boolean[] face, + final double d1, final double d2, final double d3) + { + var n = 1; + if (face[1]) n++; + if (face[2]) n++; + switch (n) { + case 1: + // Isolated vertex, of multiplicity 6. + return 1.0 / 6.0; + case 2: + // Vertex (1/6) minus half an edge of multiplicity 2. + return -1.0 / 12.0; + default: + // Full triangle: angle at the reference vertex by the cosine law. + final var alpha = Math.acos((d1 * d1 + d2 * d2 - d3 * d3) / (2 * d1 * + d2)); + return (Math.PI - alpha) / (2 * Math.PI) - 1.0 / 3.0; + } + } + + private static double[] unit(final double x, final double y, + final double z) + { + final var norm = Math.sqrt(x * x + y * y + z * z); + return new double[] { x / norm, y / norm, z / norm }; + } + + private static double[] minus(final double[] a, final double[] b) { + return new double[] { a[0] - b[0], a[1] - b[1], a[2] - b[2] }; + } + + private static double[] cross(final double[] a, final double[] b) { + return new double[] { a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], + a[0] * b[1] - a[1] * b[0] }; + } + + private static double norm(final double[] a) { + return Math.sqrt(a[0] * a[0] + a[1] * a[1] + a[2] * a[2]); + } + + private static double[] normalize(final double[] a) { + final var n = norm(a); + return new double[] { a[0] / n, a[1] / n, a[2] / n }; + } + + private static double angle(final double[] a, final double[] b) { + final var dot = a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; + return Math.atan2(norm(cross(a, b)), dot); + } + + /** + * Area of the spherical Voronoi domain of {@code germ}, given its neighbor + * germs in cyclic order. + */ + private static double voronoiArea(final double[] germ, + final double[]... neighbors) + { + final var g = normalize(germ); + final var n = neighbors.length; + // Normals of the median planes between germ and neighbors. + final var normals = new double[n][]; + for (var i = 0; i < n; i++) + normals[i] = normalize(minus(normalize(neighbors[i]), g)); + // Directions of the intersection lines of consecutive median planes. + final var lines = new double[n][]; + for (var i = 0; i < n; i++) + lines[i] = cross(normals[i], normals[(i + 1) % n]); + // Sum of the spherical angles of the polygon vertices. + var area = 0.0; + for (var i = 0; i < n; i++) { + final var v1 = lines[i]; + final var v2 = lines[(i + 1) % n]; + final var v3 = lines[(i + 2) % n]; + final var u = cross(v2, cross(v1, v2)); + final var w = cross(cross(v2, v3), v2); + var a = angle(u, w); + if (a > Math.PI) a = 2 * Math.PI - a; + area += a; + } + return area - Math.PI * (n - 2); + } + + // Note: Tables 3.2 and 3.3 of Ohser and Schladitz (2009), scaled by 8. + private static final int[] EULER_C6 = { // + 0, 1, 1, 0, 1, 0, 2, -1, 1, 2, 0, -1, 0, -1, -1, 0, // + 1, 0, 2, -1, 2, -1, 3, -2, 2, 1, 1, -2, 1, -2, 0, -1, // + 1, 2, 0, -1, 2, 1, 1, -2, 2, 3, -1, -2, 1, 0, -2, -1, // + 0, -1, -1, 0, 1, -2, 0, -1, 1, 0, -2, -1, 0, -3, -3, 0, // + 1, 2, 2, 1, 0, -1, 1, -2, 2, 3, 1, 0, -1, -2, -2, -1, // + 0, -1, 1, -2, -1, 0, 0, -1, 1, 0, 0, -3, -2, -1, -3, 0, // + 2, 3, 1, 0, 1, 0, 0, -3, 3, 4, 0, -1, 0, -1, -3, -2, // + -1, -2, -2, -1, -2, -1, -3, 0, 0, -1, -3, -2, -3, -2, -6, 1, // + 1, 2, 2, 1, 2, 1, 3, 0, 0, 1, -1, -2, -1, -2, -2, -1, // + 2, 1, 3, 0, 3, 0, 4, -1, 1, 0, 0, -3, 0, -3, -1, -2, // + 0, 1, -1, -2, 1, 0, 0, -3, -1, 0, 0, -1, -2, -3, -1, 0, // + -1, -2, -2, -1, 0, -3, -1, -2, -2, -3, -1, 0, -3, -6, -2, 1, // + 0, 1, 1, 0, -1, -2, 0, -3, -1, 0, -2, -3, 0, -1, -1, 0, // + -1, -2, 0, -3, -2, -1, -1, -2, -2, -3, -3, -6, -1, 0, -2, 1, // + -1, 0, -2, -3, -2, -3, -3, -6, -2, -1, -1, -2, -1, -2, 0, 1, // + 0, -1, -1, 0, -1, 0, -2, 1, -1, -2, 0, 1, 0, 1, 1, 0 }; + + private static final int[] EULER_C26 = { // + 0, 1, 1, 0, 1, 0, -2, -1, 1, -2, 0, -1, 0, -1, -1, 0, // + 1, 0, -2, -1, -2, -1, -1, -2, -6, -3, -3, -2, -3, -2, 0, -1, // + 1, -2, 0, -1, -6, -3, -3, -2, -2, -1, -1, -2, -3, 0, -2, -1, // + 0, -1, -1, 0, -3, -2, 0, -1, -3, 0, -2, -1, 0, 1, 1, 0, // + 1, -2, -6, -3, 0, -1, -3, -2, -2, -1, -3, 0, -1, -2, -2, -1, // + 0, -1, -3, -2, -1, 0, 0, -1, -3, 0, 0, 1, -2, -1, 1, 0, // + -2, -1, -3, 0, -3, 0, 0, 1, -1, 4, 0, 3, 0, 3, 1, 2, // + -1, -2, -2, -1, -2, -1, 1, 0, 0, 3, 1, 2, 1, 2, 2, 1, // + 1, -6, -2, -3, -2, -3, -1, 0, 0, -3, -1, -2, -1, -2, -2, -1, // + -2, -3, -1, 0, -1, 0, 4, 3, -3, 0, 0, 1, 0, 1, 3, 2, // + 0, -3, -1, -2, -3, 0, 0, 1, -1, 0, 0, -1, -2, 1, -1, 0, // + -1, -2, -2, -1, 0, 1, 3, 2, -2, 1, -1, 0, 1, 2, 2, 1, // + 0, -3, -3, 0, -1, -2, 0, 1, -1, 0, -2, 1, 0, -1, -1, 0, // + -1, -2, 0, 1, -2, -1, 3, 2, -2, 1, 1, 2, -1, 0, 2, 1, // + -1, 0, -2, 1, -2, 1, 1, 2, -2, 3, -1, 2, -1, 2, 0, 1, // + 0, -1, -1, 0, -1, 0, 2, 1, -1, 2, 0, 1, 0, 1, 1, 0 }; + +} diff --git a/scijava-ops-image-lgpl/src/main/java/org/scijava/ops/image/lgpl/package-info.java b/scijava-ops-image-lgpl/src/main/java/org/scijava/ops/image/lgpl/package-info.java new file mode 100644 index 000000000..01cb730fe --- /dev/null +++ b/scijava-ops-image-lgpl/src/main/java/org/scijava/ops/image/lgpl/package-info.java @@ -0,0 +1,31 @@ +/*- + * #%L + * LGPL-licensed image processing operations for SciJava Ops, translated from LGPL ImageJ/Fiji plugins. + * %% + * Copyright (C) 2026 SciJava developers. + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program 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 Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this program. If not, see + * . + * #L% + */ + +/** + * Image processing ops translated from LGPL-licensed ImageJ and Fiji plugins. + *

+ * Ops live in subpackages of this package, but use the same op namespaces as + * SciJava Ops Image (e.g. {@code morphology.*}, {@code segment.*}). Each op's + * javadoc credits the original authors, project and license. + *

+ */ +package org.scijava.ops.image.lgpl; diff --git a/scijava-ops-image-lgpl/src/test/java/org/scijava/ops/image/lgpl/AbstractOpTest.java b/scijava-ops-image-lgpl/src/test/java/org/scijava/ops/image/lgpl/AbstractOpTest.java new file mode 100644 index 000000000..9a99e6e46 --- /dev/null +++ b/scijava-ops-image-lgpl/src/test/java/org/scijava/ops/image/lgpl/AbstractOpTest.java @@ -0,0 +1,45 @@ +/*- + * #%L + * LGPL-licensed image processing operations for SciJava Ops, translated from LGPL ImageJ/Fiji plugins. + * %% + * Copyright (C) 2026 SciJava developers. + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program 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 Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this program. If not, see + * . + * #L% + */ + + +package org.scijava.ops.image.lgpl; + +import org.scijava.ops.api.Hints; +import org.scijava.ops.api.OpEnvironment; + +/** + * Base class for unit tests of the ops in this component, mirroring + * {@code org.scijava.ops.image.AbstractOpTest} (which is not published in a + * test JAR). + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +public abstract class AbstractOpTest { + + protected static final OpEnvironment ops = OpEnvironment.build(); + + static { + ops.setDefaultHints(new Hints("progress.TRACK")); + } + +} diff --git a/scijava-ops-image-lgpl/src/test/java/org/scijava/ops/image/lgpl/OpRegressionTest.java b/scijava-ops-image-lgpl/src/test/java/org/scijava/ops/image/lgpl/OpRegressionTest.java new file mode 100644 index 000000000..b10af6dba --- /dev/null +++ b/scijava-ops-image-lgpl/src/test/java/org/scijava/ops/image/lgpl/OpRegressionTest.java @@ -0,0 +1,69 @@ +/*- + * #%L + * LGPL-licensed image processing operations for SciJava Ops, translated from LGPL ImageJ/Fiji plugins. + * %% + * Copyright (C) 2026 SciJava developers. + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program 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 Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this program. If not, see + * . + * #L% + */ + +package org.scijava.ops.image.lgpl; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import org.junit.jupiter.api.Test; +import org.scijava.ops.api.OpEnvironment; +import org.scijava.ops.api.OpInfo; + +/** + * Guards against ops silently dropped by the op indexer (e.g. after an + * apostrophe in an {@code @param} description), like + * {@code org.scijava.ops.image.OpRegressionTest}. + *

+ * Only the op infos implemented in this module are counted, i.e. those whose + * implementation name starts with this package, so the count does not change + * with the ops of the other modules on the classpath. As in the other test, + * each {@code @Nullable} parameter adds a reduced op info per signature. + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +public class OpRegressionTest { + + private static final String PREFIX = "org.scijava.ops.image.lgpl."; + + protected static final OpEnvironment ops = OpEnvironment.build(); + + @Test + public void testOpDiscoveryRegression() { + final long expected = 39; + final long actual = ops.infos().stream() // + .filter(info -> info.implementationName().startsWith(PREFIX)) // + .count(); + assertEquals(expected, actual); + } + + @Test + public void testOpDescriptionRegression() { + for (final OpInfo info : ops.infos()) { + if (!info.implementationName().startsWith(PREFIX)) continue; + assertNotNull(info.description(), () -> "Info from " + info.id() + + " has a null description"); + } + } +} diff --git a/scijava-ops-image-lgpl/src/test/java/org/scijava/ops/image/lgpl/intrinsicvolumes/IntrinsicVolumesTest.java b/scijava-ops-image-lgpl/src/test/java/org/scijava/ops/image/lgpl/intrinsicvolumes/IntrinsicVolumesTest.java new file mode 100644 index 000000000..9f493cdaa --- /dev/null +++ b/scijava-ops-image-lgpl/src/test/java/org/scijava/ops/image/lgpl/intrinsicvolumes/IntrinsicVolumesTest.java @@ -0,0 +1,592 @@ +/*- + * #%L + * LGPL-licensed image processing operations for SciJava Ops, translated from LGPL ImageJ/Fiji plugins. + * %% + * Copyright (C) 2026 SciJava developers. + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program 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 Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this program. If not, see + * . + * #L% + */ + + +package org.scijava.ops.image.lgpl.intrinsicvolumes; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.Arrays; +import java.util.Map; +import java.util.function.Predicate; +import java.util.function.ToIntFunction; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.algorithm.neighborhood.DiamondShape; +import net.imglib2.algorithm.neighborhood.RectangleShape; +import net.imglib2.algorithm.neighborhood.Shape; +import net.imglib2.img.Img; +import net.imglib2.img.array.ArrayImgs; +import net.imglib2.roi.labeling.ImgLabeling; +import net.imglib2.type.logic.BitType; +import net.imglib2.type.numeric.integer.IntType; +import net.imglib2.type.numeric.real.DoubleType; + +import org.junit.jupiter.api.Test; +import org.scijava.ops.image.lgpl.AbstractOpTest; +import org.scijava.types.Nil; + +/** + * Tests {@link IntrinsicVolumes}. + *

+ * The expected values of the parity tests were produced by running MorphoLibJ + * 1.6.4 ({@code inra.ijpb.measure.IntrinsicVolumes2D} and + * {@code IntrinsicVolumes3D}) on the same pseudo-random images, generated by + * {@link #hash}, with ImageJ calibrations matching the spacings. + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +public class IntrinsicVolumesTest extends AbstractOpTest { + + private static final Nil> PER_LABEL = new Nil<>() {}; + + private static final Nil> DENSITIES = new Nil<>() {}; + + private static final double EPS = 1e-9; + + // -- Parity with MorphoLibJ -- + + @Test + public void testParity2D() { + final var bin = binary(12, 10, 1, p -> hash(p[0], p[1], 0) % 100 < 45); + final var lab = labeling(12, 10, 1, p -> hash(p[0], p[1], 0) % 4); + + assertEquals(60, area(bin, null), EPS); + assertEquals(105.24335389525807, perimeter(bin, null, 2), EPS); + assertEquals(89.27546118743555, perimeter(bin, null, 4), EPS); + assertEquals(60, area(bin, new double[] { 0.5, 2 }), EPS); + assertEquals(128.0199006337841, perimeter(bin, new double[] { 0.5, 2 }, 2), + EPS); + assertEquals(114.28374562845664, perimeter(bin, new double[] { 0.5, 2 }, + 4), EPS); + assertEquals(16, euler(bin, 4), EPS); + assertEquals(-2, euler(bin, 8), EPS); + // The default connectivity is full, i.e. 8 in 2D. + assertEquals(-2, euler(bin, null), EPS); + + var d = densities(bin, null, 4, 4); + assertEquals(0.5050505050505051, d.get("areaDensity"), EPS); + assertEquals(0.6666119425189818, d.get("perimeterDensity"), EPS); + assertEquals(0.10606060606060606, d.get("eulerNumberDensity"), EPS); + d = densities(bin, new double[] { 0.5, 2 }, 4, 4); + assertEquals(0.5050505050505051, d.get("areaDensity"), EPS); + assertEquals(0.8666372186105976, d.get("perimeterDensity"), EPS); + assertEquals(0.10606060606060606, d.get("eulerNumberDensity"), EPS); + + assertMap(perLabel("area", lab), 31, 30, 23); + assertMap(perLabel("perimeter", lab, null, 4), 61.90493020682089, + 63.24568873748812, 48.82266747129058); + assertMap(perLabel("perimeter", lab, null, 2), 73.82742735936012, + 75.39822368615503, 56.548667764616276); + final double[] s = { 0.5, 2 }; + assertMap(perLabel("area", lab, s), 31, 30, 23); + assertMap(perLabel("perimeter", lab, s, 4), 76.01289759628554, + 81.5746529883182, 64.03028441037807); + assertMap(perLabel("perimeter", lab, s, 2), 86.3937979737193, + 91.89158511750144, 70.68583470577032); + assertMap(perLabel("eulerNumber", lab, shape(4)), 17, 18, 13); + assertMap(perLabel("eulerNumber", lab, shape(8)), 8, 9, 11); + } + + @Test + public void testParity3D() { + final var bin = binary(7, 6, 5, p -> hash(p[0], p[1], p[2] + 1) % + 100 < 45); + final var lab = labeling(7, 6, 5, p -> hash(p[0], p[1], p[2] + 1) % 4); + + assertEquals(72, volume(bin, null), EPS); + assertEquals(206.66666666666674, surface(bin, null, 3), EPS); + assertEquals(161.49158570909822, surface(bin, null, 13), EPS); + assertEquals(32.33333333333333, meanBreadth(bin, null, 3, 4), EPS); + assertEquals(16.0, meanBreadth(bin, null, 3, 8), EPS); + assertEquals(24.705799427430545, meanBreadth(bin, null, 13, 4), EPS); + assertEquals(16.087984155857484, meanBreadth(bin, null, 13, 8), EPS); + assertEquals(14, euler(bin, 6), EPS); + assertEquals(-5, euler(bin, 26), EPS); + + final double[] s = { 0.5, 1.5, 2 }; + assertEquals(108, volume(bin, s), EPS); + assertEquals(323.00000000000006, surface(bin, s, 3), EPS); + assertEquals(259.00942514190314, surface(bin, s, 13), EPS); + assertEquals(43.50000000000001, meanBreadth(bin, s, 3, 4), EPS); + assertEquals(21.33333333333333, meanBreadth(bin, s, 3, 8), EPS); + assertEquals(19.363199137370188, meanBreadth(bin, s, 13, 4), EPS); + assertEquals(12.771447903141299, meanBreadth(bin, s, 13, 8), EPS); + + var d = densities(bin, null, 13, 6); + assertEquals(0.35833333333333334, d.get("volumeDensity"), EPS); + assertEquals(0.6924672486360745, d.get("surfaceAreaDensity"), EPS); + assertEquals(0.011552636508380508, d.get("meanBreadthDensity"), EPS); + assertEquals(0.0020833333333333333, d.get("eulerNumberDensity"), EPS); + d = densities(bin, s, 13, 6); + assertEquals(0.35833333333333334, d.get("volumeDensity"), EPS); + assertEquals(0.7447414838154569, d.get("surfaceAreaDensity"), EPS); + assertEquals(0.0062957442508001565, d.get("meanBreadthDensity"), EPS); + assertEquals(0.001388888888888889, d.get("eulerNumberDensity"), EPS); + + assertMap(perLabel("volume", lab), 47, 48, 56); + assertMap(perLabel("surfaceArea", lab, null, 13), 118.69278720817074, + 115.0421554549595, 133.77511595792024); + assertMap(perLabel("surfaceArea", lab, null, 3), 156.0, 145.33333333333334, + 171.99999999999997); + assertMap(perLabel("meanBreadth", lab, null, 13, shape(8)), + 16.857054988715547, 16.555114166411823, 17.500310986470176); + assertMap(perLabel("volume", lab, s), 70.5, 72, 84); + assertMap(perLabel("surfaceArea", lab, s, 13), 193.98294227181546, + 184.78292739636024, 215.70041383821234); + assertMap(perLabel("surfaceArea", lab, s, 3), 247.33333333333323, + 229.66666666666663, 269.33333333333337); + assertMap(perLabel("meanBreadth", lab, s, 13, shape(8)), + 13.315660795567057, 13.059382787120807, 13.804732421239835); + assertMap(perLabel("eulerNumber", lab, shape(6)), 24, 17, 19); + assertMap(perLabel("eulerNumber", lab, shape(26)), -8, 1, 0); + + assertEquals(19.292030554601833, interfaceArea(lab, 1, 2, null, 13), EPS); + assertEquals(32.416057513642365, interfaceArea(lab, 1, 2, s, 13), EPS); + // Note: MorphoLibJ gives 56 for 3 directions, regardless of spacing; see + // IntrinsicVolumesLuts.interfaceContributions. + assertEquals(28, interfaceArea(lab, 1, 2, null, 3), EPS); + } + + // -- Analytic shapes -- + + @Test + public void testDisc() { + final var r = 30.0; + final var disc = binary(71, 71, 1, p -> sq(p[0] - 35) + sq(p[1] - 35) <= + r * r); + assertEquals(Math.PI * r * r, area(disc, null), 0.01 * Math.PI * r * r); + assertEquals(2 * Math.PI * r, perimeter(disc, null, 4), 0.01 * 2 * + Math.PI * r); + assertEquals(2 * Math.PI * r, perimeter(disc, null, null), 0.01 * 2 * + Math.PI * r); + assertEquals(1, euler(disc, 4), EPS); + assertEquals(1, euler(disc, 8), EPS); + + // An ellipse sampled with anisotropic spacing. + final var ellipse = binary(71, 36, 1, p -> sq(p[0] - 35) + sq(2 * (p[1] - + 17.5)) <= r * r); + final double[] s = { 1, 2 }; + assertEquals(Math.PI * r * r, area(ellipse, s), 0.02 * Math.PI * r * r); + assertEquals(2 * Math.PI * r, perimeter(ellipse, s, 4), 0.02 * 2 * + Math.PI * r); + } + + @Test + public void testEuler2D() { + // An annulus has one hole. + final var annulus = binary(41, 41, 1, p -> { + final var d2 = sq(p[0] - 20) + sq(p[1] - 20); + return d2 <= 18 * 18 && d2 > 10 * 10; + }); + assertEquals(0, euler(annulus, 4), EPS); + assertEquals(0, euler(annulus, 8), EPS); + + // Two diagonally adjacent pixels: two components (C4) or one (C8). + final var diag = binary(4, 4, 1, p -> p[0] == p[1] && p[0] < 2); + assertEquals(2, euler(diag, 4), EPS); + assertEquals(1, euler(diag, 8), EPS); + + // A diamond outline encloses a hole only in 8-connectivity: in + // 4-connectivity, its four pixels are separate and the hole merges with + // the background. + final var diamond = binary(5, 5, 1, p -> Math.abs(p[0] - 2) + Math.abs( + p[1] - 2) == 1); + assertEquals(4, euler(diamond, 4), EPS); + assertEquals(0, euler(diamond, 8), EPS); + + // Objects touching the border are measured as if the image were padded. + final var full = binary(5, 5, 1, p -> true); + assertEquals(1, euler(full, 4), EPS); + assertEquals(25, area(full, null), EPS); + } + + @Test + public void testBall() { + final var r = 12.0; + final var ball = binary(29, 29, 29, p -> sq(p[0] - 14) + sq(p[1] - 14) + + sq(p[2] - 14) <= r * r); + final var v = 4.0 / 3 * Math.PI * r * r * r; + final var a = 4 * Math.PI * r * r; + assertEquals(v, volume(ball, null), 0.02 * v); + assertEquals(a, surface(ball, null, 13), 0.02 * a); + assertEquals(a, surface(ball, null, null), 0.02 * a); + assertEquals(2 * r, meanBreadth(ball, null, 13, 8), 0.03 * 2 * r); + assertEquals(2 * r, meanBreadth(ball, null, null, null), 0.03 * 2 * r); + assertEquals(1, euler(ball, 6), EPS); + assertEquals(1, euler(ball, 26), EPS); + + // A ball sampled with anisotropic spacing. + final var ellipsoid = binary(29, 29, 15, p -> sq(p[0] - 14) + sq(p[1] - + 14) + sq(2 * (p[2] - 7)) <= r * r); + final double[] s = { 1, 1, 2 }; + assertEquals(v, volume(ellipsoid, s), 0.02 * v); + assertEquals(a, surface(ellipsoid, s, 13), 0.03 * a); + assertEquals(2 * r, meanBreadth(ellipsoid, s, 13, 8), 0.04 * 2 * r); + } + + @Test + public void testEuler3D() { + // A hollow ball (spherical shell) has one cavity. + final var shell = binary(25, 25, 25, p -> { + final var d2 = sq(p[0] - 12) + sq(p[1] - 12) + sq(p[2] - 12); + return d2 <= 11 * 11 && d2 > 6 * 6; + }); + assertEquals(2, euler(shell, 6), EPS); + assertEquals(2, euler(shell, 26), EPS); + + // A solid torus has one tunnel. + final double big = 10, small = 4; + final var torus = binary(31, 31, 13, p -> { + final var rho = Math.sqrt(sq(p[0] - 15) + sq(p[1] - 15)) - big; + return sq(rho) + sq(p[2] - 6) <= small * small; + }); + assertEquals(0, euler(torus, 6), EPS); + assertEquals(0, euler(torus, 26), EPS); + + // A cube has Euler number 1, and so does a cube touching the border. + final var cube = binary(6, 6, 6, p -> p[0] < 4 && p[1] < 4 && p[2] < 4); + assertEquals(1, euler(cube, 6), EPS); + assertEquals(1, euler(cube, 26), EPS); + assertEquals(64, volume(cube, null), EPS); + + // Two voxels sharing only a vertex: two components (C6) or one (C26). + final var pair = binary(3, 3, 3, p -> p[0] == p[1] && p[1] == p[2] && + p[0] < 2); + assertEquals(2, euler(pair, 6), EPS); + assertEquals(1, euler(pair, 26), EPS); + } + + @Test + public void testEulerCharacteristic26N() { + // topology.eulerCharacteristic26N ignores the configurations crossing + // the border, so it matches on an image padded with background. + final var img = binary(9, 8, 7, p -> hash(p[0], p[1], p[2]) % 100 < 40); + final var padded = binary(11, 10, 9, p -> p[0] > 0 && p[1] > 0 && + p[2] > 0 && p[0] < 10 && p[1] < 9 && p[2] < 8 && hash(p[0] - 1, p[1] - + 1, p[2] - 1) % 100 < 40); + final var expected = new DoubleType(); + ops.op("topology.eulerCharacteristic26N").input(padded).output(expected) + .compute(); + assertEquals(expected.get(), euler(img, 26), EPS); + } + + // -- Labelings -- + + @Test + public void testLabelingMatchesBinary() { + final var lab = labeling(20, 18, 1, p -> hash(p[0], p[1], 7) % 3); + final var areas = perLabel("area", lab); + final var perims = perLabel("perimeter", lab, null, 4); + final var eulers = perLabel("eulerNumber", lab, shape(8)); + for (var label = 1; label <= 2; label++) { + final var l = label; + final var mask = binary(20, 18, 1, p -> hash(p[0], p[1], 7) % 3 == l); + assertEquals(area(mask, null), areas.get(label), EPS); + assertEquals(perimeter(mask, null, 4), perims.get(label), EPS); + assertEquals(euler(mask, 8), eulers.get(label), EPS); + } + } + + @Test + public void testOverlappingLabels() { + // Two overlapping squares, each measured as a whole. + final var lab = new ImgLabeling(ArrayImgs.ints(10, 10)); + final var ra = lab.randomAccess(); + for (var y = 0; y < 10; y++) + for (var x = 0; x < 10; x++) { + final var t = ra.setPositionAndGet(x, y); + if (x >= 1 && x < 6 && y >= 1 && y < 6) t.add(1); + if (x >= 4 && x < 9 && y >= 4 && y < 9) t.add(2); + } + final var areas = perLabel("area", lab); + assertEquals(25, areas.get(1), EPS); + assertEquals(25, areas.get(2), EPS); + final var square = binary(10, 10, 1, p -> p[0] >= 1 && p[0] < 6 && + p[1] >= 1 && p[1] < 6); + final var perims = perLabel("perimeter", lab, null, 4); + assertEquals(perimeter(square, null, 4), perims.get(1), EPS); + assertEquals(perimeter(square, null, 4), perims.get(2), EPS); + } + + @Test + public void testInterfaceArea() { + // Label 1 is a ball, label 2 fills the rest of the image, so their + // interface is the surface of the ball. + final var lab = labeling(21, 21, 21, p -> sq(p[0] - 10) + sq(p[1] - 10) + + sq(p[2] - 10) <= 64 ? 1 : 2); + final var ball = binary(21, 21, 21, p -> sq(p[0] - 10) + sq(p[1] - 10) + + sq(p[2] - 10) <= 64); + final double[] s = { 1, 1.5, 0.75 }; + for (final var dirs : new int[] { 3, 13 }) { + final var surf = surface(ball, s, dirs); + assertEquals(surf, interfaceArea(lab, 1, 2, s, dirs), 1e-9 * surf); + assertEquals(surf, interfaceArea(lab, 2, 1, s, dirs), 1e-9 * surf); + } + assertEquals(surface(ball, null, 13), interfaceArea(lab, 1, 2, null, + null), 1e-9); + + // Note: MorphoLibJ 1.6.4 gives a surface area of 788 (3 directions) and + // 793.5647911220026 (13 directions) for this ball, and interface areas of + // 1576 and 793.5647911221519 respectively (harness l1harness2). + assertEquals(788, surface(ball, null, 3), 1e-9); + assertEquals(793.5647911220026, surface(ball, null, 13), 1e-9); + assertEquals(788, interfaceArea(lab, 1, 2, null, 3), 1e-9); + assertEquals(793.5647911220026, interfaceArea(lab, 1, 2, null, 13), 1e-9); + } + + @Test + public void testDensities() { + // A full image has volume fraction 1 and no boundary. + final var full = binary(5, 5, 5, p -> true); + final var d = densities(full, null, null, null); + assertEquals(1, d.get("volumeDensity"), EPS); + assertEquals(0, d.get("surfaceAreaDensity"), EPS); + assertEquals(0, d.get("meanBreadthDensity"), EPS); + assertEquals(0, d.get("eulerNumberDensity"), EPS); + + // Vertical stripes of width 2 and period 4: half the area, and two + // boundary lines per period. + final var stripes = binary(41, 41, 1, p -> p[0] % 4 < 2); + final var d2 = densities(stripes, null, 2, null); + assertEquals(0.5, d2.get("areaDensity"), EPS); + assertEquals(Math.PI / 4 * 0.5, d2.get("perimeterDensity"), EPS); + + // A dimension of size 1 leaves no configuration inside the image, so the + // densities are undefined and the image is rejected. + assertThrows(IllegalArgumentException.class, () -> densities(ArrayImgs + .bits(8, 8, 1), null, null, null)); + assertThrows(IllegalArgumentException.class, () -> densities(ArrayImgs + .bits(1, 8), null, null, null)); + } + + @Test + public void testInvalidArguments() { + final var img2 = binary(4, 4, 1, p -> true); + final var img3 = binary(4, 4, 4, p -> true); + assertThrows(IllegalArgumentException.class, () -> perimeter(img2, null, + 3)); + assertThrows(IllegalArgumentException.class, () -> surface(img3, null, 4)); + // Note: Shapes are dimensionless, so a connectivity cannot mismatch the + // dimensionality, but shapes other than face and full connectivity are + // rejected. + assertThrows(IllegalArgumentException.class, () -> call("eulerNumber", + img2, new DiamondShape(2))); + assertThrows(IllegalArgumentException.class, () -> call("eulerNumber", + img3, new RectangleShape(2, false))); + assertThrows(IllegalArgumentException.class, () -> call("meanBreadth", + img3, null, 13, new DiamondShape(2))); + assertThrows(IllegalArgumentException.class, () -> volume(img2, null)); + assertThrows(IllegalArgumentException.class, () -> area(img2, + new double[] { 1, 1, 1 })); + } + + // -- Helper methods -- + + private static double area(final RandomAccessibleInterval img, + final double[] spacing) + { + return call("area", img, spacing); + } + + private static double perimeter(final RandomAccessibleInterval img, + final double[] spacing, final Integer dirs) + { + return call("perimeter", img, spacing, dirs); + } + + private static double volume(final RandomAccessibleInterval img, + final double[] spacing) + { + return call("volume", img, spacing); + } + + private static double surface(final RandomAccessibleInterval img, + final double[] spacing, final Integer dirs) + { + return call("surfaceArea", img, spacing, dirs); + } + + private static double meanBreadth( + final RandomAccessibleInterval img, final double[] spacing, + final Integer dirs, final Integer conn2d) + { + return call("meanBreadth", img, spacing, dirs, shape(conn2d)); + } + + private static double euler(final RandomAccessibleInterval img, + final Integer conn) + { + return call("eulerNumber", img, shape(conn)); + } + + private static Map densities( + final RandomAccessibleInterval img, final double[] spacing, + final Integer dirs, final Integer conn) + { + return run("densities", DENSITIES, inputs(img, spacing, dirs, shape( + conn))); + } + + /** + * Gets the shape of the connectivity with the given number of neighbors (4 + * or 6 for face connectivity, 8 or 26 for full connectivity), or null. + */ + private static Shape shape(final Integer conn) { + if (conn == null) return null; + return conn == 4 || conn == 6 ? new DiamondShape(1) : new RectangleShape( + 1, false); + } + + private static double interfaceArea(final ImgLabeling lab, + final int l1, final int l2, final double[] spacing, final Integer dirs) + { + final var in = inputs(lab, spacing, dirs); + final var inputs = new Object[in.length + 2]; + inputs[0] = lab; + inputs[1] = l1; + inputs[2] = l2; + System.arraycopy(in, 1, inputs, 3, in.length - 1); + return run("interfaceArea", Nil.of(Double.class), inputs); + } + + private static Map perLabel(final String name, + final ImgLabeling lab, final Object... args) + { + return run(name, PER_LABEL, inputs(lab, args)); + } + + private static double call(final String name, + final RandomAccessibleInterval img, final Object... args) + { + return run(name, Nil.of(Double.class), inputs(img, args)); + } + + private static T run(final String name, final Nil outType, + final Object... in) + { + final var op = ops.op("features.intrinsicVolumes." + name); + switch (in.length) { + case 1: + return op.input(in[0]).outType(outType).apply(); + case 2: + return op.input(in[0], in[1]).outType(outType).apply(); + case 3: + return op.input(in[0], in[1], in[2]).outType(outType).apply(); + case 4: + return op.input(in[0], in[1], in[2], in[3]).outType(outType).apply(); + case 5: + return op.input(in[0], in[1], in[2], in[3], in[4]).outType(outType) + .apply(); + default: + throw new IllegalArgumentException("Too many inputs: " + in.length); + } + } + + /** + * Builds the op inputs, omitting trailing null arguments so that the op + * defaults apply, and replacing a null spacing followed by other arguments + * with unit spacing. + */ + private static Object[] inputs(final RandomAccessibleInterval img, + final Object... args) + { + var n = args.length; + while (n > 0 && args[n - 1] == null) + n--; + final var inputs = new Object[n + 1]; + inputs[0] = img; + for (var i = 0; i < n; i++) { + if (args[i] != null) inputs[i + 1] = args[i]; + else { + final var ones = new double[img.numDimensions()]; + Arrays.fill(ones, 1); + inputs[i + 1] = ones; + } + } + return inputs; + } + + private static void assertMap(final Map actual, + final double... expected) + { + assertEquals(expected.length, actual.size()); + for (var i = 0; i < expected.length; i++) + assertEquals(expected[i], actual.get(i + 1), EPS, "label " + (i + 1)); + } + + /** Pseudo-random hash of a position, also used by the MorphoLibJ harness. */ + private static int hash(final long x, final long y, final long z) { + var v = ((int) x * 73856093) ^ ((int) y * 19349663) ^ ((int) z * 83492791); + v ^= v >>> 13; + v *= 0x5bd1e995; + v ^= v >>> 15; + return v & 0x7fffffff; + } + + private static double sq(final double v) { + return v * v; + } + + private static RandomAccessibleInterval binary(final int w, + final int h, final int d, final Predicate fg) + { + final Img img = d == 1 ? ArrayImgs.bits(w, h) : ArrayImgs.bits(w, + h, d); + final var c = img.localizingCursor(); + final var pos = new long[3]; + while (c.hasNext()) { + c.fwd(); + c.localize(pos); + if (d == 1) pos[2] = 0; + c.get().set(fg.test(pos)); + } + return img; + } + + private static ImgLabeling labeling(final int w, + final int h, final int d, + final ToIntFunction label) + { + final Img index = d == 1 ? ArrayImgs.ints(w, h) : ArrayImgs.ints( + w, h, d); + final var c = index.localizingCursor(); + final var pos = new long[3]; + var max = 0; + while (c.hasNext()) { + c.fwd(); + c.localize(pos); + if (d == 1) pos[2] = 0; + final var v = label.applyAsInt(pos); + c.get().set(v); + max = Math.max(max, v); + } + return ImgLabeling.fromImageAndLabels(index, IntStream.rangeClosed(1, + max).boxed().collect(Collectors.toList())); + } + +} diff --git a/scijava-ops-image/CHANGELOG.md b/scijava-ops-image/CHANGELOG.md new file mode 100644 index 000000000..7563ecb8c --- /dev/null +++ b/scijava-ops-image/CHANGELOG.md @@ -0,0 +1,102 @@ +# Changelog + +All notable changes to SciJava Ops Image. Details of each op (origin, +licensing, differences from other implementations) are in its javadoc. + +## [Unreleased] + +### Changed + +These change the results of existing ops. + +- Global thresholds (`threshold.*`) now match Fiji's Auto Threshold: + - integer images get one histogram bin per grey level, instead of 256 + bins over the data range; + - an image with only two grey levels is thresholded at the lower one; + - `threshold.otsu` resolves ties toward the lowest bin (this also changes + `coloc.maxTKendallTau` results); + - `threshold.minError` no longer fails when its iteration does not + converge; + - `threshold.huang` is now Fiji's "Huang" method; the previous algorithm + is available as `threshold.huang2` (Fiji's "Huang2"). +- Local thresholds (`threshold.local*`) now match Fiji's Auto Local + Threshold: + - `localContrast` was inverted, and now treats zero pixels as background; + - `localSauvola` used a wrong formula; its default `r` is now half the + type's range (128 for 8-bit); + - `localPhansalkar` stretches its input to [0, 1], unless the new + `normalize` parameter is false; + - `localNiblack`, `localSauvola` and `localPhansalkar` use the population + standard deviation; + - `localBernsen` rounds the mid grey down on integer images; + - histogram-based local methods use one bin per grey level, at most 256. +- `filter.tubeness` now honors its calibration, and rejects images that are + not 2D or 3D. + +### Added + +- Optional `ignoreBlack` and `ignoreWhite` parameters for global thresholds. +- `threshold.huang2`, `threshold.hysteresis`, `threshold.rats`, + `threshold.ratsSurface`. +- `filter.*`: `coherenceEnhancingDiffusion`, `convolvedBackgroundSubtraction`, + `dericheGradientMagnitude`, `derichePartialDerivative`, `flatFieldCorrect`, + `gaussianWeightedMedian`, `helmholtzOrientation`, `helmholtzWavelength`, + `kalman`, `kuwahara`, `kuwaharaLinear`, `localCoefficientOfVariation`, + `peronaMalik`, `phaseCorrelate`, `pseudoFlatField`, `retinex`, `tvDenoise`, + `waveletATrous`, `waveletDenoise`, `weightedMedian`; tensor filters + `coherency`, `harris`, `hessianEigenvalues`, `orientation`, `shapeIndex`, + `sheetness`, `structureTensor`, `structureTensorEigenvalues`. +- `color.*`: `convert` (RGB, HSB, XYZ, Lab, Luv, YCbCr), `deconvolveStains`, + `deltaE`. +- `deconvolve.*`: `landweber`, `tikhonov`, `wiener`. +- `morphology.*`: `reconstructByDilation`, `reconstructByErosion`, + `regionalMaxima`/`Minima`, `extendedMaxima`/`Minima`, `hMaxima`/`Minima`, + `imposeMaxima`/`Minima`, `killBorders`, grayscale `fillHoles`; + `areaOpening`/`Closing`, `boxDiagonalOpening`/`Closing`, + `grayVolumeOpening`/`Closing`, `attributeOpening`/`Closing`; + `gradient`, `internalGradient`, `externalGradient`, `laplacian`, + `directionalFilter`, `geodesicDistanceMap`, `interpolateSlices`, + `localThickness`. +- `labeling.*`: `adjacency`, `boundaries`, `clarkEvans`, `cropLabel`, + `distanceMap`, `dilate`, `erode`, `open`, `close`, `edgeCorrectedCount`, + `edgeCorrectionWeights`, `equivalentEllipsoid`, `featureMap`, `fillHoles`, + `filter`, `filterBySize`, `filterByValue`, `geodesicDiameter`, + `geodesicDiameterPath`, `influenceZones`, `inscribedBall`, `keepLabels`, + `removeLabels`, `keepLargest`, `removeLargest`, `maximumFeret`, + `maximumFeretsDiameter`, `mergeLabels`, `nearestNeighborDistances`, + `relabel`, `removeBorderLabels`, `selectByOverlap`, `shapeBasedAverage`; + segmentation evaluation `labeling.overlap.*` (Dice, Jaccard, target + overlap, volume similarity, false negative/positive error, total and per + label). +- `segment.*`: `cannyDericheEdges`, `compactWatershed`, `detectHoughCircles`, + `detectSpotsDoG`, `distanceTransformWatershed`, `extendedMinimaWatershed`, + `fastMarching`, `graphCut`, `hWatershed`, `kMeans`, `levelSet` + (Chan–Vese, geodesic active contours), `phantast`, `seededRegionGrowing`, + `siox`, `statisticalRegionMerging`. +- `image.*`: `bleachCorrect`, `bleachFit`, `fastMarchingTimes`, + `histogram2D`, `histogramMatch`, `kymograph`, `levelSetFunction`, + `localExtrema`, `piv`, `quantileNormalize`, `signedDistanceTransform`. +- `transform.*`: `bin`, `focusStack`, `focusStackIndex`, `houghCircle`. +- `coloc.*`: `costesThreshold`, `manders`, `mutualInformation`, `spearman`; + `stats.entropy`. +- `geom.*`: `compactness`, `extent`, `feretAspectRatio`. +- IBSI texture features: matrix ops `image.runLengthMatrix`, + `sizeZoneMatrix`, `distanceZoneMatrix`, + `neighborhoodGrayToneDifferenceMatrix`, + `neighboringGrayLevelDependenceMatrix`, with the feature families + `features.glrlm.*`, `glszm.*`, `gldzm.*`, `ngtdm.*`, `ngldm.*`; seven more + `features.haralick.*` features; `features.twoPointCorrelation`. + +### Fixed + +- `threshold.localMean`, `localNiblack`, `localSauvola` and + `localPhansalkar` failed on rectangular neighborhoods larger than 3x3. +- `image.integral` and `image.squareIntegral` were wrong for images with + three or more dimensions. +- `image.distanceTransform` and `filter.tubeness` misread inputs whose + minimum is not zero. +- `geom.majorAxis` and `geom.minorAxis` (and the ops built on them, such as + `geom.eccentricity` and `geom.roundness`) were inaccurate for axis-aligned + shapes and returned NaN for clockwise polygons. +- `coloc.pValue` misaligned or failed on images that are not a multiple of + the block size. diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/coloc/ShuffledView.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/coloc/ShuffledView.java index 0022c6b22..b3cc7aa4a 100644 --- a/scijava-ops-image/src/main/java/org/scijava/ops/image/coloc/ShuffledView.java +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/coloc/ShuffledView.java @@ -159,8 +159,9 @@ public ShuffledRandomAccess() { public T get() { // Convert from image coordinates to block coordinates. for (var d = 0; d < position.length; d++) { - blockPos[d] = position[d] / blockSize[d]; - blockOffset[d] = position[d] % blockSize[d]; + final var p = position[d] - image.min(d); + blockPos[d] = p / blockSize[d]; + blockOffset[d] = p % blockSize[d]; } // Convert N-D block coordinates to 1D block index. @@ -178,7 +179,8 @@ public T get() { // Finally, position the original image according to our shuffled // position. for (var d = 0; d < position.length; d++) { - final var pd = shuffledBlockPos[d] * blockSize[d] + blockOffset[d]; + final var pd = image.min(d) + shuffledBlockPos[d] * blockSize[d] + + blockOffset[d]; imageRA.setPosition(pd, d); } return imageRA.get(); @@ -223,7 +225,7 @@ private static RandomAccessibleInterval cropAt( final var numDims = image.numDimensions(); final var minsize = new long[numDims * 2]; for (var d = 0; d < numDims; d++) { - minsize[d] = offset.getLongPosition(d); + minsize[d] = image.min(d) + offset.getLongPosition(d); final var shaveSize = image.dimension(d) % blockSize[d]; minsize[numDims + d] = image.dimension(d) - shaveSize; } diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/coloc/costesThreshold/CostesResult.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/coloc/costesThreshold/CostesResult.java new file mode 100644 index 000000000..6477735cd --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/coloc/costesThreshold/CostesResult.java @@ -0,0 +1,95 @@ +/*- + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.coloc.costesThreshold; + +/** + * The result of {@code coloc.costesThreshold}: the thresholds of both + * channels, the regression line they lie on, and the Pearson correlation of + * the pixels below them. + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +public class CostesResult { + + private final double threshold1; + private final double threshold2; + private final double slope; + private final double intercept; + private final double pearsonsBelow; + + public CostesResult(final double threshold1, final double threshold2, + final double slope, final double intercept, final double pearsonsBelow) + { + this.threshold1 = threshold1; + this.threshold2 = threshold2; + this.slope = slope; + this.intercept = intercept; + this.pearsonsBelow = pearsonsBelow; + } + + /** Gets the threshold of the first channel. */ + public double getThreshold1() { + return threshold1; + } + + /** + * Gets the threshold of the second channel, which is + * {@code slope * threshold1 + intercept}. + */ + public double getThreshold2() { + return threshold2; + } + + /** Gets the slope of the regression line of channel 2 on channel 1. */ + public double getSlope() { + return slope; + } + + /** Gets the intercept of the regression line of channel 2 on channel 1. */ + public double getIntercept() { + return intercept; + } + + /** + * Gets the Pearson correlation of the pixels below the thresholds, which + * is at most zero unless no threshold satisfies this. + */ + public double getPearsonsBelow() { + return pearsonsBelow; + } + + @Override + public String toString() { + return "threshold1 = " + threshold1 + ", threshold2 = " + threshold2 + + ", slope = " + slope + ", intercept = " + intercept + + ", pearsonsBelow = " + pearsonsBelow; + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/coloc/costesThreshold/DefaultCostesThreshold.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/coloc/costesThreshold/DefaultCostesThreshold.java new file mode 100644 index 000000000..423370a3a --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/coloc/costesThreshold/DefaultCostesThreshold.java @@ -0,0 +1,187 @@ +/*- + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.coloc.costesThreshold; + +import java.util.Arrays; +import java.util.function.BiFunction; + +import net.imglib2.type.numeric.IntegerType; +import net.imglib2.type.numeric.RealType; +import net.imglib2.util.IterablePair; + +import org.scijava.ops.image.coloc.ColocUtil; + +/** + * Computes the automatic colocalization thresholds of Costes et al. (2004), + * "Automatic and quantitative measurement of protein-protein colocalization in + * live cells", Biophys J 86(6):3993-4003. + *

+ * The second channel is regressed on the first by orthogonal (total least + * squares) regression, giving the line {@code y = slope * x + intercept}. The + * thresholds {@code t1} and {@code t2 = slope * t1 + intercept} then move + * down this line until the Pearson correlation of the pixels below the + * thresholds is no longer positive: the result is the highest {@code t1} for + * which the pixels with {@code x <= t1} or {@code y <= t2} are uncorrelated or + * anticorrelated. With these thresholds, {@code coloc.manders} gives the + * thresholded coefficients tM1 and tM2. + *

+ *

+ * The threshold is found by bisection between the minimum and maximum of the + * first channel, which assumes that the Pearson correlation below the + * thresholds increases with the threshold; for integer types the threshold + * of the first channel is an integer. If even the correlation of all pixels + * is not positive, the threshold is the maximum of the first channel. + *

+ *

+ * Note that Coloc 2 (Fiji) reports the lowest values above the thresholds + * instead, so for integer images its thresholds are the smallest integers + * greater than these. + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @param type of the first image + * @param type of the second image + * @implNote op names='coloc.costesThreshold' + */ +public class DefaultCostesThreshold, U extends RealType> + implements BiFunction, Iterable, CostesResult> +{ + + /** + * Computes the Costes thresholds of two images. + * + * @param image1 the first image + * @param image2 the second image, with the same size and iteration order as + * the first + * @return the thresholds, the regression line and the Pearson correlation + * below the thresholds + */ + @Override + public CostesResult apply(final Iterable image1, + final Iterable image2) + { + if (!ColocUtil.sameIterationOrder(image1, image2)) + throw new IllegalArgumentException( + "Input images must have the same dimensionality and iteration order!"); + + // Copy the samples, since they are visited many times. + var x = new double[1024]; + var y = new double[1024]; + var n = 0; + boolean integer1 = false; + for (final var pair : new IterablePair<>(image1, image2)) { + if (n == x.length) { + x = Arrays.copyOf(x, 2 * n); + y = Arrays.copyOf(y, 2 * n); + } + if (n == 0) integer1 = pair.getA() instanceof IntegerType; + x[n] = pair.getA().getRealDouble(); + y[n] = pair.getB().getRealDouble(); + n++; + } + if (n < 2) throw new IllegalArgumentException( + "At least two samples are needed."); + + // Orthogonal regression of y on x. + double meanX = 0, meanY = 0; + for (var i = 0; i < n; i++) { + meanX += x[i]; + meanY += y[i]; + } + meanX /= n; + meanY /= n; + double sxx = 0, syy = 0, sxy = 0; + double min = Double.POSITIVE_INFINITY, max = Double.NEGATIVE_INFINITY; + for (var i = 0; i < n; i++) { + final var dx = x[i] - meanX; + final var dy = y[i] - meanY; + sxx += dx * dx; + syy += dy * dy; + sxy += dx * dy; + min = Math.min(min, x[i]); + max = Math.max(max, x[i]); + } + if (sxy == 0) throw new IllegalArgumentException( + "The channels are uncorrelated: no regression line exists."); + final var diff = syy - sxx; + final var slope = (diff + Math.sqrt(diff * diff + 4 * sxy * sxy)) / + (2 * sxy); + final var intercept = meanY - slope * meanX; + + // Bisect for the highest threshold with a non-positive correlation below. + final var rAll = pearsonsBelow(x, y, n, max, slope * max + intercept); + if (!(rAll > 0)) { + return new CostesResult(max, slope * max + intercept, slope, intercept, + rAll); + } + var lo = min; + var hi = max; + final var tolerance = integer1 ? 1 : (max - min) * 1e-6; + while (hi - lo > tolerance) { + final var mid = integer1 ? Math.floor((lo + hi) / 2) : (lo + hi) / 2; + if (pearsonsBelow(x, y, n, mid, slope * mid + intercept) > 0) hi = mid; + else lo = mid; + } + final var t2 = slope * lo + intercept; + return new CostesResult(lo, t2, slope, intercept, pearsonsBelow(x, y, n, + lo, t2)); + } + + /** + * Computes the Pearson correlation of the samples which are not above both + * thresholds, or {@code NaN} if it is undefined. + */ + private static double pearsonsBelow(final double[] x, final double[] y, + final int n, final double t1, final double t2) + { + long count = 0; + double sx = 0, sy = 0; + for (var i = 0; i < n; i++) { + if (x[i] > t1 && y[i] > t2) continue; + sx += x[i]; + sy += y[i]; + count++; + } + if (count < 2) return Double.NaN; + final var mx = sx / count; + final var my = sy / count; + double sxx = 0, syy = 0, sxy = 0; + for (var i = 0; i < n; i++) { + if (x[i] > t1 && y[i] > t2) continue; + final var dx = x[i] - mx; + final var dy = y[i] - my; + sxx += dx * dx; + syy += dy * dy; + sxy += dx * dy; + } + return sxy / Math.sqrt(sxx * syy); + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/coloc/manders/DefaultManders.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/coloc/manders/DefaultManders.java new file mode 100644 index 000000000..067ee5862 --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/coloc/manders/DefaultManders.java @@ -0,0 +1,115 @@ +/*- + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.coloc.manders; + +import net.imglib2.type.numeric.RealType; +import net.imglib2.util.IterablePair; + +import org.scijava.function.Functions; +import org.scijava.ops.image.coloc.ColocUtil; +import org.scijava.ops.spi.Nullable; + +/** + * Computes the Manders colocalization coefficients M1 and M2 of two channels + * (Manders, Verbeek & Aten (1993), "Measurement of co-localization of + * objects in dual-colour confocal images", J. Microsc. 169(3):375-382). + *

+ * With thresholds {@code t1} and {@code t2}, + *

+ * + *
+ * M1 = sum(x where y > t2) / sum(x)
+ * M2 = sum(y where x > t1) / sum(y)
+ * 
+ *

+ * where {@code x} and {@code y} are the values of the first and second + * channel and the sums run over all pixels. M1 is thus the fraction of the + * intensity of the first channel found in pixels where the second channel is + * above its threshold. With the default thresholds of zero these are the + * original coefficients of Manders et al.; with the thresholds of + * {@code coloc.costesThreshold} they are the thresholded coefficients tM1 and + * tM2 of Costes et al. (2004), Biophys J 86(6):3993-4003. A coefficient is + * {@code NaN} when its channel has no intensity. + *

+ *

+ * Note that Coloc 2 (Fiji) counts pixels equal to a nonzero threshold as + * above it; for integer images, its tM1 and tM2 at thresholds {@code (t1, t2)} + * equal these coefficients at {@code (t1 - 1, t2 - 1)}. + *

+ *

+ * To restrict the computation to a region of interest, pass views of both + * images sampled within the same region. + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @param type of the first image + * @param type of the second image + * @implNote op names='coloc.manders' + */ +public class DefaultManders, U extends RealType> + implements + Functions.Arity4, Iterable, Double, Double, MandersResult> +{ + + /** + * Computes the Manders coefficients of two images. + * + * @param image1 the first image + * @param image2 the second image, with the same size and iteration order as + * the first + * @param threshold1 the threshold of the first channel (default 0) + * @param threshold2 the threshold of the second channel (default 0) + * @return the coefficients M1 and M2 + */ + @Override + public MandersResult apply(final Iterable image1, + final Iterable image2, @Nullable Double threshold1, + @Nullable Double threshold2) + { + if (!ColocUtil.sameIterationOrder(image1, image2)) + throw new IllegalArgumentException( + "Input images must have the same dimensionality and iteration order!"); + final double t1 = threshold1 == null ? 0 : threshold1; + final double t2 = threshold2 == null ? 0 : threshold2; + + double sum1 = 0, sum2 = 0, coloc1 = 0, coloc2 = 0; + for (final var pair : new IterablePair<>(image1, image2)) { + final var x = pair.getA().getRealDouble(); + final var y = pair.getB().getRealDouble(); + sum1 += x; + sum2 += y; + if (y > t2) coloc1 += x; + if (x > t1) coloc2 += y; + } + return new MandersResult(sum1 == 0 ? Double.NaN : coloc1 / sum1, + sum2 == 0 ? Double.NaN : coloc2 / sum2); + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/coloc/manders/MandersResult.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/coloc/manders/MandersResult.java new file mode 100644 index 000000000..910c84e3a --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/coloc/manders/MandersResult.java @@ -0,0 +1,69 @@ +/*- + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.coloc.manders; + +/** + * The result of {@code coloc.manders}: the Manders colocalization + * coefficients of both channels. + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +public class MandersResult { + + private final double m1; + private final double m2; + + public MandersResult(final double m1, final double m2) { + this.m1 = m1; + this.m2 = m2; + } + + /** + * Gets the fraction of the intensity of the first channel found where the + * second channel is above its threshold. + */ + public double getM1() { + return m1; + } + + /** + * Gets the fraction of the intensity of the second channel found where the + * first channel is above its threshold. + */ + public double getM2() { + return m2; + } + + @Override + public String toString() { + return "M1 = " + m1 + ", M2 = " + m2; + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/coloc/mutualInformation/DefaultMutualInformation.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/coloc/mutualInformation/DefaultMutualInformation.java new file mode 100644 index 000000000..c0b025d68 --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/coloc/mutualInformation/DefaultMutualInformation.java @@ -0,0 +1,99 @@ +/*- + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.coloc.mutualInformation; + +import net.imglib2.img.Img; +import net.imglib2.type.numeric.RealType; +import net.imglib2.type.numeric.integer.LongType; + +import org.scijava.function.Functions; +import org.scijava.ops.image.stats.entropy.DefaultEntropy; +import org.scijava.ops.spi.Nullable; +import org.scijava.ops.spi.OpDependency; + +/** + * Computes the mutual information, in bits, of the intensities of two images: + * {@code MI = H(X) + H(Y) - H(X, Y)}, where {@code H(X)} and {@code H(Y)} are + * the Shannon entropies of the two channels and {@code H(X, Y)} is their + * joint entropy, all estimated from the joint histogram computed by + * {@code image.histogram2D}. Mutual information is zero for independent + * channels and equals {@code H(X)} when one channel is a one-to-one function + * of the other. See Cover & Thomas (2006), "Elements of Information + * Theory", 2nd edition, chapter 2. + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @param type of the first image + * @param type of the second image + * @implNote op names='coloc.mutualInformation' + */ +public class DefaultMutualInformation, U extends RealType> + implements Functions.Arity3, Iterable, Integer, Double> +{ + + @OpDependency(name = "image.histogram2D") + private Functions.Arity4, Iterable, Integer, Integer, Img> histogramOp; + + /** + * Computes the mutual information of two images. + * + * @param image1 the first image + * @param image2 the second image, with the same size and iteration order as + * the first + * @param bins the number of histogram bins of each channel (default 256) + * @return the mutual information in bits + */ + @Override + public Double apply(final Iterable image1, final Iterable image2, + @Nullable Integer bins) + { + if (bins == null) bins = 256; + final var histogram = histogramOp.apply(image1, image2, bins, bins); + final var b1 = (int) histogram.dimension(0); + final var b2 = (int) histogram.dimension(1); + final var joint = new long[b1 * b2]; + final var marginal1 = new long[b1]; + final var marginal2 = new long[b2]; + final var cursor = histogram.localizingCursor(); + while (cursor.hasNext()) { + final var count = cursor.next().get(); + final var i = cursor.getIntPosition(0); + final var j = cursor.getIntPosition(1); + joint[i + b1 * j] = count; + marginal1[i] += count; + marginal2[j] += count; + } + final var mi = DefaultEntropy.entropy(marginal1) + DefaultEntropy.entropy( + marginal2) - DefaultEntropy.entropy(joint); + // Note: rounding can make the MI of independent images slightly negative. + return Math.max(0, mi); + } + +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/coloc/pValue/DefaultPValue.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/coloc/pValue/DefaultPValue.java index 77c490b81..a7cc8f775 100644 --- a/scijava-ops-image/src/main/java/org/scijava/ops/image/coloc/pValue/DefaultPValue.java +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/coloc/pValue/DefaultPValue.java @@ -54,6 +54,22 @@ * (2017) IEEE Signal Processing "Automated and Robust Quantification of * Colocalization in Dual-Color Fluorescence Microscopy: A Nonparametric * Statistical Approach". + *

+ * The first image is shuffled block-wise {@code nrRandomizations} times, and + * the colocalization measure is computed between each shuffled image and the + * unshuffled second image. The p-value is the fraction of shuffles whose + * value exceeds the observed one, so values near 0 are significant. + *

+ * With {@code coloc.pearsons} as the measure and PSF-sized blocks, this is + * the significance test of Costes et al. (2004) Biophys J 86(6):3993-4003, + * "Automatic and quantitative measurement of protein-protein colocalization + * in live cells". Costes et al. report the complementary value, the fraction + * of shuffles below the observed Pearson coefficient, and consider P > 95% + * significant; this corresponds to a p-value < 0.05 here (in the absence + * of ties). Note that Coloc 2 differs from the published test: it smooths + * each shuffled image with a Gaussian of the PSF width, and reports the + * normal cumulative distribution function at the z-score of the observed + * value instead of an empirical fraction. * * @implNote op names='coloc.pValue' */ @@ -63,15 +79,19 @@ public class DefaultPValue, U extends RealType> { /** - * TODO + * Computes the colocalization value of the two images and its p-value. * - * @param image1 the first image + * @param image1 the first image, which is shuffled * @param image2 the second image - * @param op the op - * @param nrRandomizations the number of randomizations - * @param psfSize Size of blocks for random shufflings. - * @param seed the seed - * @param output the output + * @param op the colocalization measure, such as {@code coloc.pearsons} + * @param nrRandomizations the number of randomizations (default 100) + * @param psfSize the size of the shuffled blocks, ideally the size of the + * point spread function in pixels (default: the square root of + * each image dimension); both images are trimmed to a multiple of + * it + * @param seed the seed of the random shuffles + * @param output the result: the observed value, the shuffled values and + * the fraction of shuffled values exceeding the observed one */ @Override public void compute( // @@ -93,8 +113,9 @@ public void compute( // } final var blockSize = blockSize(image1, psfSize); - final var trimmedImage1 = trim(image1, blockSize); - final var trimmedImage2 = trim(image2, blockSize); + // Note: zero-min views keep the shuffled buffer aligned with image2. + final var trimmedImage1 = Views.zeroMin(trim(image1, blockSize)); + final var trimmedImage2 = Views.zeroMin(trim(image2, blockSize)); final var type1 = Util.getTypeFromInterval(image1); final var sampleDistribution = new double[nrRandomizations]; diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/coloc/spearman/DefaultSpearman.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/coloc/spearman/DefaultSpearman.java new file mode 100644 index 000000000..6fd363084 --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/coloc/spearman/DefaultSpearman.java @@ -0,0 +1,123 @@ +/*- + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.coloc.spearman; + +import java.util.Arrays; +import java.util.function.BiFunction; + +import net.imglib2.type.numeric.RealType; +import net.imglib2.util.IterablePair; + +import org.scijava.ops.image.coloc.ColocUtil; + +/** + * Computes the Spearman rank correlation coefficient of two images: the + * Pearson correlation of the ranks of their values, where tied values get the + * mean of the ranks they span (Spearman (1904), "The proof and measurement of + * association between two things", Am J Psychol 15(1):72-101). It is + * {@code NaN} if either image is constant. + *

+ * The t-statistic of the coefficient {@code rho} for {@code n} pixels is + * {@code rho * sqrt((n - 2) / (1 - rho^2))} with {@code n - 2} degrees of + * freedom. + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @param type of the first image + * @param type of the second image + * @implNote op names='coloc.spearman' + */ +public class DefaultSpearman, U extends RealType> + implements BiFunction, Iterable, Double> +{ + + /** + * Computes the Spearman rank correlation of two images. + * + * @param image1 the first image + * @param image2 the second image, with the same size and iteration order as + * the first + * @return the Spearman rank correlation coefficient + */ + @Override + public Double apply(final Iterable image1, final Iterable image2) { + if (!ColocUtil.sameIterationOrder(image1, image2)) + throw new IllegalArgumentException( + "Input images must have the same dimensionality and iteration order!"); + var x = new double[1024]; + var y = new double[1024]; + var n = 0; + for (final var pair : new IterablePair<>(image1, image2)) { + if (n == x.length) { + x = Arrays.copyOf(x, 2 * n); + y = Arrays.copyOf(y, 2 * n); + } + x[n] = pair.getA().getRealDouble(); + y[n] = pair.getB().getRealDouble(); + n++; + } + final var rx = ranks(x, n); + final var ry = ranks(y, n); + + // Note: the mean rank is (n + 1) / 2 in both channels. + final var mean = (n + 1) / 2.0; + double sxx = 0, syy = 0, sxy = 0; + for (var i = 0; i < n; i++) { + final var dx = rx[i] - mean; + final var dy = ry[i] - mean; + sxx += dx * dx; + syy += dy * dy; + sxy += dx * dy; + } + return sxy / Math.sqrt(sxx * syy); + } + + /** Computes the 1-based ranks of the values, averaging ties. */ + private static double[] ranks(final double[] values, final int n) { + final var order = new Integer[n]; + for (var i = 0; i < n; i++) + order[i] = i; + Arrays.sort(order, (a, b) -> Double.compare(values[a], values[b])); + final var ranks = new double[n]; + var start = 0; + while (start < n) { + var end = start + 1; + while (end < n && values[order[end]] == values[order[start]]) + end++; + // Positions start..end-1 hold ties, with ranks start+1..end. + final var rank = (start + 1 + end) / 2.0; + for (var i = start; i < end; i++) + ranks[order[i]] = rank; + start = end; + } + return ranks; + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/color/ColorUtils.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/color/ColorUtils.java new file mode 100644 index 000000000..6e335241a --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/color/ColorUtils.java @@ -0,0 +1,59 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.color; + +import net.imglib2.RandomAccessibleInterval; + +/** + * Utility methods shared by the colour ops. + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +public final class ColorUtils { + + private ColorUtils() { + // Prevent instantiation of static utility class. + } + + /** + * Finds the default channel dimension: the last dimension of size 3. + * + * @param input the image + * @return the index of the channel dimension + */ + public static int defaultChannelDim(final RandomAccessibleInterval input) { + for (var d = input.numDimensions() - 1; d >= 0; d--) { + if (input.dimension(d) == 3) return d; + } + throw new IllegalArgumentException( + "Input image has no dimension of size 3 to use as RGB channels"); + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/color/convert/ColorConvert.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/color/convert/ColorConvert.java new file mode 100644 index 000000000..3abde05d9 --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/color/convert/ColorConvert.java @@ -0,0 +1,165 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.color.convert; + +import java.util.function.BiFunction; + +import net.imglib2.Dimensions; +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.loops.LoopBuilder; +import net.imglib2.type.numeric.IntegerType; +import net.imglib2.type.numeric.RealType; +import net.imglib2.type.numeric.real.DoubleType; +import net.imglib2.util.Intervals; +import net.imglib2.util.Util; +import net.imglib2.view.Views; + +import org.scijava.function.Functions; +import org.scijava.ops.spi.Nullable; +import org.scijava.ops.spi.OpDependency; +import org.scijava.ops.image.color.ColorUtils; + +/** + * Converts an image between colour spaces: RGB (gamma-encoded sRGB), linear + * RGB, HSB/HSV, CIE XYZ, CIELAB, CIELUV and YCbCr. + *

+ * The three colour components lie along one dimension of the image. The + * conversions follow the sRGB standard (IEC 61966-2-1, D65 white) and CIE + * 15:2004; see {@link ColorSpace} for the formulas and value ranges of each + * space. RGB components are divided by {@code rgbMax} on input and multiplied + * by it on output, so that, e.g., 8-bit images are read in {@code [0, 255]}. + * Results are not clipped to the sRGB gamut. + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @param input pixel type + * @see IEC 61966-2-1:1999, + * Multimedia systems and equipment - Colour measurement and management - + * Part 2-1: Default RGB colour space - sRGB + * @implNote op names='color.convert' + */ +public class ColorConvert> implements + Functions.Arity5, String, String, Integer, Double, RandomAccessibleInterval> +{ + + @OpDependency(name = "create.img") + private BiFunction> createOp; + + /** + * Converts an image between colour spaces. + * + * @param input the image, with the three colour components along one + * dimension + * @param from the colour space of the input: RGB, linearRGB, HSB (or HSV), + * XYZ, Lab, Luv or YCbCr + * @param to the colour space of the output, as for {@code from} + * @param channelDim the dimension holding the colour components (default: + * the last dimension of size 3) + * @param rgbMax the value of full intensity for RGB components, in both + * input and output (default: the maximum value of the pixel type + * when converting from RGB or linear RGB with an integer type, + * otherwise 1) + * @return the converted image, with the same dimensions as the input + */ + @Override + public RandomAccessibleInterval apply( + final RandomAccessibleInterval input, final String from, + final String to, @Nullable final Integer channelDim, + @Nullable final Double rgbMax) + { + final var src = ColorSpace.byName(from); + final var dst = ColorSpace.byName(to); + final int cd = channelDim == null ? ColorUtils.defaultChannelDim( + input) : channelDim; + final double scale = rgbMax == null ? defaultRGBMax(input, src) : rgbMax; + final var output = createOp.apply(input, new DoubleType()); + convert(input, src, dst, cd, scale, output); + return output; + } + + static > double defaultRGBMax( + final RandomAccessibleInterval input, final ColorSpace from) + { + final var type = Util.getTypeFromInterval(input); + return from.isRGB() && type instanceof IntegerType ? type.getMaxValue() + : 1; + } + + /** + * Converts the colours of an image. + * + * @param input the input image + * @param from the colour space of the input + * @param to the colour space of the output + * @param channelDim the dimension holding the colour components + * @param rgbMax the value of full intensity for RGB components + * @param output the output image, with the same dimensions as the input + */ + public static > void convert( + final RandomAccessibleInterval input, final ColorSpace from, + final ColorSpace to, final int channelDim, final double rgbMax, + final RandomAccessibleInterval output) + { + if (channelDim < 0 || channelDim >= input.numDimensions() || input + .dimension(channelDim) != 3) + { + throw new IllegalArgumentException("Channel dimension " + channelDim + + " must exist and have size 3"); + } + if (!Intervals.equalDimensions(input, output)) { + throw new IllegalArgumentException( + "Input and output must have the same dimensions"); + } + if (!(rgbMax > 0)) { + throw new IllegalArgumentException("rgbMax must be positive: " + + rgbMax); + } + final var inScale = from.isRGB() ? 1 / rgbMax : 1; + final var outScale = to.isRGB() ? rgbMax : 1; + final long cMin = input.min(channelDim); + final long oMin = output.min(channelDim); + LoopBuilder.setImages( // + Views.hyperSlice(input, channelDim, cMin), // + Views.hyperSlice(input, channelDim, cMin + 1), // + Views.hyperSlice(input, channelDim, cMin + 2), // + Views.hyperSlice(output, channelDim, oMin), // + Views.hyperSlice(output, channelDim, oMin + 1), // + Views.hyperSlice(output, channelDim, oMin + 2) // + ).multiThreaded().forEachPixel((i0, i1, i2, o0, o1, o2) -> { + final var c = new double[] { i0.getRealDouble() * inScale, // + i1.getRealDouble() * inScale, i2.getRealDouble() * inScale }; + ColorSpace.convert(from, to, c, c); + o0.set(c[0] * outScale); + o1.set(c[1] * outScale); + o2.set(c[2] * outScale); + }); + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/color/convert/ColorConvertARGB.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/color/convert/ColorConvertARGB.java new file mode 100644 index 000000000..b43ce9f74 --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/color/convert/ColorConvertARGB.java @@ -0,0 +1,79 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.color.convert; + +import java.util.function.BiFunction; + +import net.imglib2.Dimensions; +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.converter.Converters; +import net.imglib2.type.numeric.ARGBType; +import net.imglib2.type.numeric.real.DoubleType; + +import org.scijava.ops.spi.OpDependency; + +/** + * Converts a packed {@link ARGBType} (sRGB) image to another colour space. The + * alpha channel is ignored. See {@link ColorConvert} and {@link ColorSpace} + * for details. + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @implNote op names='color.convert' + */ +public class ColorConvertARGB implements + BiFunction, String, RandomAccessibleInterval> +{ + + @OpDependency(name = "create.img") + private BiFunction> createOp; + + /** + * Converts an ARGB image to another colour space. + * + * @param input the ARGB image + * @param to the colour space of the output: RGB, linearRGB, HSB (or HSV), + * XYZ, Lab, Luv or YCbCr (RGB components are output in + * {@code [0, 255]}) + * @return the converted image, with one more dimension than the input; + * the last dimension holds the three colour components + */ + @Override + public RandomAccessibleInterval apply( + final RandomAccessibleInterval input, final String to) + { + final var dst = ColorSpace.byName(to); + final var rgb = Converters.argbChannels(input, 1, 2, 3); + final var output = createOp.apply(rgb, new DoubleType()); + ColorConvert.convert(rgb, ColorSpace.RGB, dst, rgb.numDimensions() - 1, + 255, output); + return output; + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/color/convert/ColorSpace.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/color/convert/ColorSpace.java new file mode 100644 index 000000000..0a42d06c8 --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/color/convert/ColorSpace.java @@ -0,0 +1,477 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.color.convert; + +import java.util.Locale; + +/** + * The colour spaces supported by {@code color.convert}, with conversions to + * and from CIE XYZ. + *

+ * All conversions use the sRGB standard (IEC 61966-2-1:1999): its primaries, + * its D65 reference white and its transfer function. The RGB to XYZ matrix is + * the one given in the standard, and the reference white used for CIELAB and + * CIELUV is the XYZ of sRGB white under that matrix, + * {@code (0.9505, 1, 1.0890)}, so that RGB white maps exactly to + * {@code L = 100} with zero chroma. Conversions are not clipped: colours + * outside the sRGB gamut yield RGB components outside {@code [0, 1]} (the + * transfer function is extended to negative values by odd symmetry), and all + * conversions are inverses of one another up to rounding. + *

+ *

+ * Value ranges, for in-gamut colours: + *

+ *
    + *
  • {@link #RGB}: gamma-encoded sRGB R', G', B' in {@code [0, 1]}.
  • + *
  • {@link #LINEAR_RGB}: linear-light sRGB R, G, B in {@code [0, 1]}.
  • + *
  • {@link #HSB} (also called HSV): hue in degrees within + * {@code [0, 360)}, saturation and brightness (value) in {@code [0, 1]}, + * computed from gamma-encoded RGB.
  • + *
  • {@link #XYZ}: CIE 1931 XYZ, with {@code Y = 1} for the reference + * white.
  • + *
  • {@link #LAB}: CIE 1976 L*a*b*, with L* in {@code [0, 100]}.
  • + *
  • {@link #LUV}: CIE 1976 L*u*v*, with L* in {@code [0, 100]}.
  • + *
  • {@link #YCBCR}: full-range YCbCr from gamma-encoded RGB with the ITU-R + * BT.601 luma coefficients (as in JPEG/JFIF), Y' in {@code [0, 1]} and Cb, Cr + * in {@code [-0.5, 0.5]} (no offset).
  • + *
+ *

+ * The CIELAB and CIELUV formulas are those of CIE 15:2004, with the exact + * constants {@code ε = 216/24389} and {@code κ = 24389/27}. + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +public enum ColorSpace { + + /** Gamma-encoded sRGB. */ + RGB("rgb", "srgb"), + + /** Linear-light sRGB. */ + LINEAR_RGB("linearrgb", "linearsrgb", "linear"), + + /** Hue, saturation and brightness (value), from gamma-encoded sRGB. */ + HSB("hsb", "hsv"), + + /** CIE 1931 XYZ. */ + XYZ("xyz", "ciexyz"), + + /** CIE 1976 L*a*b*. */ + LAB("lab", "cielab"), + + /** CIE 1976 L*u*v*. */ + LUV("luv", "cieluv"), + + /** Full-range ITU-R BT.601 YCbCr, from gamma-encoded sRGB. */ + YCBCR("ycbcr"); + + /** RGB to XYZ matrix of IEC 61966-2-1. */ + private static final double[][] RGB_TO_XYZ = { // + { 0.4124, 0.3576, 0.1805 }, // + { 0.2126, 0.7152, 0.0722 }, // + { 0.0193, 0.1192, 0.9505 } }; + + private static final double[][] XYZ_TO_RGB = invert(RGB_TO_XYZ); + + /** X of the reference white (sRGB white). */ + public static final double XN = RGB_TO_XYZ[0][0] + RGB_TO_XYZ[0][1] + + RGB_TO_XYZ[0][2]; + + /** Y of the reference white (sRGB white). */ + public static final double YN = RGB_TO_XYZ[1][0] + RGB_TO_XYZ[1][1] + + RGB_TO_XYZ[1][2]; + + /** Z of the reference white (sRGB white). */ + public static final double ZN = RGB_TO_XYZ[2][0] + RGB_TO_XYZ[2][1] + + RGB_TO_XYZ[2][2]; + + private static final double UN = 4 * XN / (XN + 15 * YN + 3 * ZN); + private static final double VN = 9 * YN / (XN + 15 * YN + 3 * ZN); + + private static final double EPSILON = 216.0 / 24389; + private static final double KAPPA = 24389.0 / 27; + + private static final double KR = 0.299, KB = 0.114, KG = 1 - KR - KB; + + private final String[] names; + + ColorSpace(final String... names) { + this.names = names; + } + + /** + * Looks up a colour space by name, ignoring case, spaces, dashes, + * underscores and asterisks. Accepted names are {@code RGB}/{@code sRGB}, + * {@code linearRGB}, {@code HSB}/{@code HSV}, {@code XYZ}, {@code Lab}, + * {@code Luv} and {@code YCbCr}, optionally prefixed by {@code CIE} for the + * CIE spaces. + * + * @param name the name of the colour space + * @return the colour space + * @throws IllegalArgumentException if the name is unknown + */ + public static ColorSpace byName(final String name) { + if (name == null) throw new IllegalArgumentException( + "Colour space name is null"); + final var key = name.toLowerCase(Locale.ROOT).replaceAll("[\\s_\\-*]", + ""); + for (final var space : values()) { + for (final var n : space.names) { + if (n.equals(key)) return space; + } + } + throw new IllegalArgumentException("Unknown colour space: " + name); + } + + /** + * Whether the components of this space are RGB intensities, which are + * scaled by the RGB range of {@code color.convert}. + * + * @return true for {@link #RGB} and {@link #LINEAR_RGB} + */ + public boolean isRGB() { + return this == RGB || this == LINEAR_RGB; + } + + /** + * Converts a colour of this space to CIE XYZ. + * + * @param c the three components of the colour + * @param xyz the X, Y and Z values (output; may be the same array as + * {@code c}) + */ + public void toXYZ(final double[] c, final double[] xyz) { + switch (this) { + case XYZ: + System.arraycopy(c, 0, xyz, 0, 3); + break; + case LINEAR_RGB: + multiply(RGB_TO_XYZ, c, xyz); + break; + case RGB: + decode(c, xyz); + multiply(RGB_TO_XYZ, xyz, xyz); + break; + case HSB: + hsbToRGB(c, xyz); + RGB.toXYZ(xyz, xyz); + break; + case YCBCR: + ycbcrToRGB(c, xyz); + RGB.toXYZ(xyz, xyz); + break; + case LAB: + labToXYZ(c, xyz); + break; + case LUV: + luvToXYZ(c, xyz); + break; + default: + throw new IllegalStateException(toString()); + } + } + + /** + * Converts a CIE XYZ colour to this space. + * + * @param xyz the X, Y and Z values + * @param c the three components of the colour in this space (output; may be + * the same array as {@code xyz}) + */ + public void fromXYZ(final double[] xyz, final double[] c) { + switch (this) { + case XYZ: + System.arraycopy(xyz, 0, c, 0, 3); + break; + case LINEAR_RGB: + multiply(XYZ_TO_RGB, xyz, c); + break; + case RGB: + multiply(XYZ_TO_RGB, xyz, c); + encode(c, c); + break; + case HSB: + RGB.fromXYZ(xyz, c); + rgbToHSB(c, c); + break; + case YCBCR: + RGB.fromXYZ(xyz, c); + rgbToYCbCr(c, c); + break; + case LAB: + xyzToLab(xyz, c); + break; + case LUV: + xyzToLuv(xyz, c); + break; + default: + throw new IllegalStateException(toString()); + } + } + + /** + * Converts a colour from one space to another. Conversions among RGB, HSB + * and YCbCr are done directly; all others go through CIE XYZ. + * + * @param from the space of the input colour + * @param to the space of the output colour + * @param in the input components + * @param out the output components (may be the same array as {@code in}) + */ + public static void convert(final ColorSpace from, final ColorSpace to, + final double[] in, final double[] out) + { + if (from == to) { + System.arraycopy(in, 0, out, 0, 3); + return; + } + if (from.isGammaRGBBased() && to.isGammaRGBBased()) { + from.toRGB(in, out); + to.fromRGB(out, out); + return; + } + from.toXYZ(in, out); + to.fromXYZ(out, out); + } + + private boolean isGammaRGBBased() { + return this == RGB || this == HSB || this == YCBCR; + } + + private void toRGB(final double[] in, final double[] rgb) { + if (this == HSB) hsbToRGB(in, rgb); + else if (this == YCBCR) ycbcrToRGB(in, rgb); + else System.arraycopy(in, 0, rgb, 0, 3); + } + + private void fromRGB(final double[] rgb, final double[] out) { + if (this == HSB) rgbToHSB(rgb, out); + else if (this == YCBCR) rgbToYCbCr(rgb, out); + else System.arraycopy(rgb, 0, out, 0, 3); + } + + // -- sRGB transfer function -- + + private static double decode(final double v) { + final var a = Math.abs(v); + final var l = a <= 0.04045 ? a / 12.92 : Math.pow((a + 0.055) / 1.055, + 2.4); + return Math.copySign(l, v); + } + + private static double encode(final double v) { + final var a = Math.abs(v); + final var e = a <= 0.0031308 ? 12.92 * a : 1.055 * Math.pow(a, 1 / 2.4) - + 0.055; + return Math.copySign(e, v); + } + + private static void decode(final double[] in, final double[] out) { + for (var i = 0; i < 3; i++) + out[i] = decode(in[i]); + } + + private static void encode(final double[] in, final double[] out) { + for (var i = 0; i < 3; i++) + out[i] = encode(in[i]); + } + + // -- HSB -- + + private static void rgbToHSB(final double[] rgb, final double[] hsb) { + final double r = rgb[0], g = rgb[1], b = rgb[2]; + final var max = Math.max(r, Math.max(g, b)); + final var min = Math.min(r, Math.min(g, b)); + final var delta = max - min; + double h; + if (delta == 0) h = 0; + else if (max == r) h = 60 * ((g - b) / delta); + else if (max == g) h = 60 * ((b - r) / delta + 2); + else h = 60 * ((r - g) / delta + 4); + if (h < 0) h += 360; + hsb[0] = h; + hsb[1] = max == 0 ? 0 : delta / max; + hsb[2] = max; + } + + private static void hsbToRGB(final double[] hsb, final double[] rgb) { + var h = hsb[0] % 360; + if (h < 0) h += 360; + final double s = hsb[1], v = hsb[2]; + final var sector = h / 60; + final var i = (int) Math.floor(sector); + final var f = sector - i; + final var p = v * (1 - s); + final var q = v * (1 - s * f); + final var t = v * (1 - s * (1 - f)); + final double r, g, b; + switch (i % 6) { + case 0: + r = v; + g = t; + b = p; + break; + case 1: + r = q; + g = v; + b = p; + break; + case 2: + r = p; + g = v; + b = t; + break; + case 3: + r = p; + g = q; + b = v; + break; + case 4: + r = t; + g = p; + b = v; + break; + default: + r = v; + g = p; + b = q; + } + rgb[0] = r; + rgb[1] = g; + rgb[2] = b; + } + + // -- YCbCr -- + + private static void rgbToYCbCr(final double[] rgb, final double[] ycc) { + final double r = rgb[0], g = rgb[1], b = rgb[2]; + final var y = KR * r + KG * g + KB * b; + ycc[0] = y; + ycc[1] = (b - y) / (2 * (1 - KB)); + ycc[2] = (r - y) / (2 * (1 - KR)); + } + + private static void ycbcrToRGB(final double[] ycc, final double[] rgb) { + final var y = ycc[0]; + final var r = y + 2 * (1 - KR) * ycc[2]; + final var b = y + 2 * (1 - KB) * ycc[1]; + rgb[0] = r; + rgb[1] = (y - KR * r - KB * b) / KG; + rgb[2] = b; + } + + // -- CIELAB and CIELUV -- + + private static double f(final double t) { + return t > EPSILON ? Math.cbrt(t) : (KAPPA * t + 16) / 116; + } + + private static double fInverse(final double f) { + final var f3 = f * f * f; + return f3 > EPSILON ? f3 : (116 * f - 16) / KAPPA; + } + + private static double yFromLightness(final double l) { + return YN * (l > KAPPA * EPSILON ? Math.pow((l + 16) / 116, 3) : l / + KAPPA); + } + + private static void xyzToLab(final double[] xyz, final double[] lab) { + final var fx = f(xyz[0] / XN); + final var fy = f(xyz[1] / YN); + final var fz = f(xyz[2] / ZN); + lab[0] = 116 * fy - 16; + lab[1] = 500 * (fx - fy); + lab[2] = 200 * (fy - fz); + } + + private static void labToXYZ(final double[] lab, final double[] xyz) { + final double l = lab[0], a = lab[1], b = lab[2]; + final var fy = (l + 16) / 116; + xyz[0] = XN * fInverse(fy + a / 500); + xyz[1] = yFromLightness(l); + xyz[2] = ZN * fInverse(fy - b / 200); + } + + private static void xyzToLuv(final double[] xyz, final double[] luv) { + final double x = xyz[0], y = xyz[1], z = xyz[2]; + final var l = 116 * f(y / YN) - 16; + final var denom = x + 15 * y + 3 * z; + luv[0] = l; + if (denom == 0) { + luv[1] = 0; + luv[2] = 0; + return; + } + luv[1] = 13 * l * (4 * x / denom - UN); + luv[2] = 13 * l * (9 * y / denom - VN); + } + + private static void luvToXYZ(final double[] luv, final double[] xyz) { + final var l = luv[0]; + if (l == 0) { + xyz[0] = xyz[1] = xyz[2] = 0; + return; + } + final var u = luv[1] / (13 * l) + UN; + final var v = luv[2] / (13 * l) + VN; + final var y = yFromLightness(l); + xyz[0] = y * 9 * u / (4 * v); + xyz[1] = y; + xyz[2] = y * (12 - 3 * u - 20 * v) / (4 * v); + } + + // -- Linear algebra -- + + private static void multiply(final double[][] m, final double[] v, + final double[] out) + { + final double a = v[0], b = v[1], c = v[2]; + for (var i = 0; i < 3; i++) + out[i] = m[i][0] * a + m[i][1] * b + m[i][2] * c; + } + + private static double[][] invert(final double[][] m) { + final var inv = new double[3][3]; + final var det = m[0][0] * (m[1][1] * m[2][2] - m[1][2] * m[2][1]) - + m[0][1] * (m[1][0] * m[2][2] - m[1][2] * m[2][0]) + m[0][2] * (m[1][0] * + m[2][1] - m[1][1] * m[2][0]); + for (var i = 0; i < 3; i++) { + for (var j = 0; j < 3; j++) { + // Note: Cyclic indices give the signed cofactor of m[j][i]. + final int r1 = (j + 1) % 3, r2 = (j + 2) % 3; + final int c1 = (i + 1) % 3, c2 = (i + 2) % 3; + inv[i][j] = (m[r1][c1] * m[r2][c2] - m[r1][c2] * m[r2][c1]) / det; + } + } + return inv; + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/color/deconvolve/ColourDeconvolution.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/color/deconvolve/ColourDeconvolution.java new file mode 100644 index 000000000..1c0b4d3b7 --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/color/deconvolve/ColourDeconvolution.java @@ -0,0 +1,218 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.color.deconvolve; + +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.loops.LoopBuilder; +import net.imglib2.type.numeric.IntegerType; +import net.imglib2.type.numeric.RealType; +import net.imglib2.type.numeric.real.DoubleType; +import net.imglib2.util.Intervals; +import net.imglib2.util.Util; +import net.imglib2.view.Views; + +/** + * Colour deconvolution after Ruifrok and Johnston (2001), shared by the + * {@code color.deconvolveStains} ops. + *

+ * By the Beer-Lambert law, the optical density {@code OD = -log10(I / I0)} of + * each RGB channel is linear in the stain concentrations: + * {@code OD = c · M}, where row {@code k} of the 3 × 3 matrix {@code M} is + * the unit-length OD vector of stain {@code k}. The stain amounts of a pixel + * are thus {@code c = OD · M}-1. + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +public final class ColourDeconvolution { + + /** Smallest transmittance allowed for non-integer images. */ + private static final double MIN_TRANSMITTANCE = 1e-6; + + private ColourDeconvolution() { + // NB: Prevent instantiation of utility class. + } + + /** + * Builds the normalized 3 × 3 stain matrix from two or three stain + * vectors. Each vector is scaled to unit length. If only two vectors are + * given, or the third one is all zero, the third stain is taken to be the + * unit vector orthogonal to the first two (their normalized cross product), + * so that it absorbs whatever the first two stains cannot explain. + * + * @param stains two or three rows of red, green and blue OD values + * @return the normalized stain matrix, one stain per row + */ + public static double[][] stainMatrix(final double[][] stains) { + if (stains == null || stains.length < 2 || stains.length > 3) { + throw new IllegalArgumentException( + "Expected two or three stain vectors"); + } + final var m = new double[3][]; + for (var k = 0; k < stains.length; k++) { + if (stains[k] == null || stains[k].length != 3) { + throw new IllegalArgumentException("Stain vector " + k + + " must have three (red, green, blue) components"); + } + m[k] = stains[k].clone(); + } + for (var k = 0; k < 2; k++) { + if (!normalize(m[k])) { + throw new IllegalArgumentException("Stain vector " + k + " is zero"); + } + } + if (m[2] == null || !normalize(m[2])) { + m[2] = new double[] { // + m[0][1] * m[1][2] - m[0][2] * m[1][1], // + m[0][2] * m[1][0] - m[0][0] * m[1][2], // + m[0][0] * m[1][1] - m[0][1] * m[1][0] }; + if (!normalize(m[2])) { + throw new IllegalArgumentException( + "The first two stain vectors are parallel"); + } + } + return m; + } + + /** + * Inverts a 3 × 3 matrix. + * + * @param m the matrix + * @return its inverse + * @throws IllegalArgumentException if the matrix is singular + */ + public static double[][] invert(final double[][] m) { + final var c00 = m[1][1] * m[2][2] - m[1][2] * m[2][1]; + final var c01 = m[1][2] * m[2][0] - m[1][0] * m[2][2]; + final var c02 = m[1][0] * m[2][1] - m[1][1] * m[2][0]; + final var det = m[0][0] * c00 + m[0][1] * c01 + m[0][2] * c02; + if (Math.abs(det) < 1e-12) { + throw new IllegalArgumentException( + "Stain vectors are linearly dependent"); + } + final var inv = new double[3][3]; + inv[0][0] = c00 / det; + inv[1][0] = c01 / det; + inv[2][0] = c02 / det; + inv[0][1] = (m[0][2] * m[2][1] - m[0][1] * m[2][2]) / det; + inv[1][1] = (m[0][0] * m[2][2] - m[0][2] * m[2][0]) / det; + inv[2][1] = (m[0][1] * m[2][0] - m[0][0] * m[2][1]) / det; + inv[0][2] = (m[0][1] * m[1][2] - m[0][2] * m[1][1]) / det; + inv[1][2] = (m[0][2] * m[1][0] - m[0][0] * m[1][2]) / det; + inv[2][2] = (m[0][0] * m[1][1] - m[0][1] * m[1][0]) / det; + return inv; + } + + /** + * Computes the default incident light intensity: the maximum value of the + * pixel type for integer types, and the maximum intensity of the image + * otherwise. + * + * @param input the image + * @return the incident light intensity + */ + public static > double defaultI0( + final RandomAccessibleInterval input) + { + final var type = Util.getTypeFromInterval(input); + if (type instanceof IntegerType) return type.getMaxValue(); + var max = Double.NEGATIVE_INFINITY; + for (final var t : Views.iterable(input)) + max = Math.max(max, t.getRealDouble()); + return max; + } + + /** + * Deconvolves the stains of an RGB image. + * + * @param input the RGB image + * @param stains two or three stain OD vectors (see {@link #stainMatrix}) + * @param channelDim the dimension holding the red, green and blue channels + * @param i0 the incident (background) light intensity + * @param output the stain amounts (in OD units), with the same dimensions as + * the input; index {@code k} of the channel dimension holds stain + * {@code k} + */ + public static > void deconvolve( + final RandomAccessibleInterval input, final double[][] stains, + final int channelDim, final double i0, + final RandomAccessibleInterval output) + { + if (channelDim < 0 || channelDim >= input.numDimensions() || input + .dimension(channelDim) != 3) + { + throw new IllegalArgumentException("Channel dimension " + channelDim + + " must exist and have size 3"); + } + if (!Intervals.equalDimensions(input, output)) { + throw new IllegalArgumentException( + "Input and output must have the same dimensions"); + } + if (!(i0 > 0)) { + throw new IllegalArgumentException("I0 must be positive: " + i0); + } + final var inv = invert(stainMatrix(stains)); + final var integer = Util.getTypeFromInterval(input) instanceof IntegerType; + final var minI = integer ? 1 : i0 * MIN_TRANSMITTANCE; + + final long cMin = input.min(channelDim); + final long oMin = output.min(channelDim); + LoopBuilder.setImages( // + Views.hyperSlice(input, channelDim, cMin), // + Views.hyperSlice(input, channelDim, cMin + 1), // + Views.hyperSlice(input, channelDim, cMin + 2), // + Views.hyperSlice(output, channelDim, oMin), // + Views.hyperSlice(output, channelDim, oMin + 1), // + Views.hyperSlice(output, channelDim, oMin + 2) // + ).multiThreaded().forEachPixel((r, g, b, c0, c1, c2) -> { + final var odR = od(r.getRealDouble(), minI, i0); + final var odG = od(g.getRealDouble(), minI, i0); + final var odB = od(b.getRealDouble(), minI, i0); + c0.set(odR * inv[0][0] + odG * inv[1][0] + odB * inv[2][0]); + c1.set(odR * inv[0][1] + odG * inv[1][1] + odB * inv[2][1]); + c2.set(odR * inv[0][2] + odG * inv[1][2] + odB * inv[2][2]); + }); + } + + private static double od(final double v, final double minI, + final double i0) + { + return -Math.log10(Math.max(v, minI) / i0); + } + + private static boolean normalize(final double[] v) { + final var len = Math.sqrt(v[0] * v[0] + v[1] * v[1] + v[2] * v[2]); + if (len == 0) return false; + for (var i = 0; i < 3; i++) + v[i] /= len; + return true; + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/color/deconvolve/DeconvolveStains.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/color/deconvolve/DeconvolveStains.java new file mode 100644 index 000000000..8f3271a15 --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/color/deconvolve/DeconvolveStains.java @@ -0,0 +1,109 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.color.deconvolve; + +import java.util.function.BiFunction; + +import net.imglib2.Dimensions; +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.type.numeric.RealType; +import net.imglib2.type.numeric.real.DoubleType; + +import org.scijava.function.Functions; +import org.scijava.ops.spi.Nullable; +import org.scijava.ops.spi.OpDependency; +import org.scijava.ops.image.color.ColorUtils; + +/** + * Separates the stains of a brightfield RGB image by colour deconvolution + * (Ruifrok and Johnston, 2001). + *

+ * Each RGB value is converted to optical density {@code OD = -log10(I / I0)} + * and multiplied by the inverse of the stain matrix, whose rows are the + * unit-length OD vectors of the stains. The result is the amount of each stain + * at each pixel, in OD units: a pixel with stain amounts {@code c} has + * {@code OD = c · M}. When only two stain vectors are given, the third is + * their normalized cross product. Zero intensities are clamped to one grey + * level for integer images, and to a transmittance of 10-6 + * otherwise. + *

+ *

+ * Implemented from the paper; see {@link StainVectors} for published stain + * vectors. Differences from G. Landini's Colour Deconvolution plugin for + * ImageJ: the result is the stain amounts in OD units, not 8-bit + * transmittance images; a missing third vector is the cross product of the + * other two, where the plugin uses its own complement heuristic; and no + * 8-bit rescaling of the optical densities takes place. + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @param input pixel type + * @see A. C. Ruifrok and + * D. A. Johnston, "Quantification of histochemical staining by color + * deconvolution", Anal. Quant. Cytol. Histol. 23(4):291-299, 2001 + * @implNote op names='color.deconvolveStains' + */ +public class DeconvolveStains> implements + Functions.Arity4, double[][], Integer, Double, RandomAccessibleInterval> +{ + + @OpDependency(name = "create.img") + private BiFunction> createOp; + + /** + * Deconvolves the stains of an RGB image. + * + * @param input the RGB image, with the red, green and blue channels along + * one dimension + * @param stains the OD vectors of two or three stains, one row of red, green + * and blue absorbances per stain (need not be normalized) + * @param channelDim the dimension holding the RGB channels (default: the last + * dimension of size 3) + * @param i0 the incident light intensity, i.e. the intensity of unstained + * background (default: the maximum value of the pixel type for + * integer types, otherwise the maximum intensity of the image) + * @return the stain amounts in OD units, with the same dimensions as the + * input; index k along the channel dimension holds stain k + */ + @Override + public RandomAccessibleInterval apply( + final RandomAccessibleInterval input, final double[][] stains, + @Nullable final Integer channelDim, @Nullable final Double i0) + { + final int cd = channelDim == null ? ColorUtils.defaultChannelDim( + input) : channelDim; + final double intensity = i0 == null ? ColourDeconvolution.defaultI0(input) + : i0; + final var output = createOp.apply(input, new DoubleType()); + ColourDeconvolution.deconvolve(input, stains, cd, intensity, output); + return output; + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/color/deconvolve/DeconvolveStainsARGB.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/color/deconvolve/DeconvolveStainsARGB.java new file mode 100644 index 000000000..613f41a37 --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/color/deconvolve/DeconvolveStainsARGB.java @@ -0,0 +1,93 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.color.deconvolve; + +import java.util.function.BiFunction; + +import net.imglib2.Dimensions; +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.converter.Converters; +import net.imglib2.type.numeric.ARGBType; +import net.imglib2.type.numeric.real.DoubleType; + +import org.scijava.function.Functions; +import org.scijava.ops.spi.Nullable; +import org.scijava.ops.spi.OpDependency; + +/** + * Separates the stains of a packed {@link ARGBType} brightfield image by + * colour deconvolution (Ruifrok and Johnston, 2001). The alpha channel is + * ignored. See {@link DeconvolveStains} for details. + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @see A. C. Ruifrok and + * D. A. Johnston, "Quantification of histochemical staining by color + * deconvolution", Anal. Quant. Cytol. Histol. 23(4):291-299, 2001 + * @implNote op names='color.deconvolveStains' + */ +public class DeconvolveStainsARGB implements + Functions.Arity3, double[][], Double, RandomAccessibleInterval> +{ + + @OpDependency(name = "create.img") + private BiFunction> createOp; + + /** + * Deconvolves the stains of an ARGB image. + * + * @param input the ARGB image + * @param stains the OD vectors of two or three stains, one row of red, green + * and blue absorbances per stain (need not be normalized) + * @param i0 the incident light intensity, i.e. the intensity of unstained + * background (default: 255) + * @return the stain amounts in OD units, with one more dimension than the + * input; index k along the last dimension holds stain k + */ + @Override + public RandomAccessibleInterval apply( + final RandomAccessibleInterval input, final double[][] stains, + @Nullable final Double i0) + { + return deconvolve(createOp, input, stains, i0); + } + + static RandomAccessibleInterval deconvolve( + final BiFunction> createOp, + final RandomAccessibleInterval input, final double[][] stains, + final Double i0) + { + final var rgb = Converters.argbChannels(input, 1, 2, 3); + final var output = createOp.apply(rgb, new DoubleType()); + ColourDeconvolution.deconvolve(rgb, stains, rgb.numDimensions() - 1, + i0 == null ? 255 : i0, output); + return output; + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/color/deconvolve/DeconvolveStainsARGBByName.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/color/deconvolve/DeconvolveStainsARGBByName.java new file mode 100644 index 000000000..9339299cd --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/color/deconvolve/DeconvolveStainsARGBByName.java @@ -0,0 +1,82 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.color.deconvolve; + +import java.util.function.BiFunction; + +import net.imglib2.Dimensions; +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.type.numeric.ARGBType; +import net.imglib2.type.numeric.real.DoubleType; + +import org.scijava.function.Functions; +import org.scijava.ops.spi.Nullable; +import org.scijava.ops.spi.OpDependency; + +/** + * Separates the stains of a packed {@link ARGBType} brightfield image by + * colour deconvolution (Ruifrok and Johnston, 2001), using a named set of + * published stain vectors from {@link StainVectors}. The alpha channel is + * ignored. See {@link DeconvolveStains} for details. + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @see A. C. Ruifrok and + * D. A. Johnston, "Quantification of histochemical staining by color + * deconvolution", Anal. Quant. Cytol. Histol. 23(4):291-299, 2001 + * @implNote op names='color.deconvolveStains' + */ +public class DeconvolveStainsARGBByName implements + Functions.Arity3, String, Double, RandomAccessibleInterval> +{ + + @OpDependency(name = "create.img") + private BiFunction> createOp; + + /** + * Deconvolves the stains of an ARGB image. + * + * @param input the ARGB image + * @param stainName the name of the stain combination, e.g. "H&E", "H + * DAB" or "H&E DAB" (see {@link StainVectors}) + * @param i0 the incident light intensity, i.e. the intensity of unstained + * background (default: 255) + * @return the stain amounts in OD units, with one more dimension than the + * input; index k along the last dimension holds stain k + */ + @Override + public RandomAccessibleInterval apply( + final RandomAccessibleInterval input, final String stainName, + @Nullable final Double i0) + { + return DeconvolveStainsARGB.deconvolve(createOp, input, StainVectors + .fromName(stainName).vectors(), i0); + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/color/deconvolve/DeconvolveStainsByName.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/color/deconvolve/DeconvolveStainsByName.java new file mode 100644 index 000000000..3e61cd6b4 --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/color/deconvolve/DeconvolveStainsByName.java @@ -0,0 +1,96 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.color.deconvolve; + +import java.util.function.BiFunction; + +import net.imglib2.Dimensions; +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.type.numeric.RealType; +import net.imglib2.type.numeric.real.DoubleType; + +import org.scijava.function.Functions; +import org.scijava.ops.spi.Nullable; +import org.scijava.ops.spi.OpDependency; +import org.scijava.ops.image.color.ColorUtils; + +/** + * Separates the stains of a brightfield RGB image by colour deconvolution + * (Ruifrok and Johnston, 2001), using a named set of published stain vectors + * from {@link StainVectors}. See {@link DeconvolveStains} for details. + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @param input pixel type + * @see A. C. Ruifrok and + * D. A. Johnston, "Quantification of histochemical staining by color + * deconvolution", Anal. Quant. Cytol. Histol. 23(4):291-299, 2001 + * @implNote op names='color.deconvolveStains' + */ +public class DeconvolveStainsByName> implements + Functions.Arity4, String, Integer, Double, RandomAccessibleInterval> +{ + + @OpDependency(name = "create.img") + private BiFunction> createOp; + + /** + * Deconvolves the stains of an RGB image. + * + * @param input the RGB image, with the red, green and blue channels along + * one dimension + * @param stainName the name of the stain combination, e.g. "H&E", "H + * DAB", "H&E DAB", "H AEC", "H PAS", "Giemsa", "Feulgen Light + * Green", "FastRed FastBlue DAB", "Methyl Green DAB", "Azan-Mallory", + * "Masson Trichrome" or "Alcian blue & H" (case, spaces and + * punctuation are ignored) + * @param channelDim the dimension holding the RGB channels (default: the last + * dimension of size 3) + * @param i0 the incident light intensity, i.e. the intensity of unstained + * background (default: the maximum value of the pixel type for + * integer types, otherwise the maximum intensity of the image) + * @return the stain amounts in OD units, with the same dimensions as the + * input; index k along the channel dimension holds stain k + */ + @Override + public RandomAccessibleInterval apply( + final RandomAccessibleInterval input, final String stainName, + @Nullable final Integer channelDim, @Nullable final Double i0) + { + final var stains = StainVectors.fromName(stainName).vectors(); + final int cd = channelDim == null ? ColorUtils.defaultChannelDim( + input) : channelDim; + final double intensity = i0 == null ? ColourDeconvolution.defaultI0(input) + : i0; + final var output = createOp.apply(input, new DoubleType()); + ColourDeconvolution.deconvolve(input, stains, cd, intensity, output); + return output; + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/color/deconvolve/StainVectors.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/color/deconvolve/StainVectors.java new file mode 100644 index 000000000..67f8fc25e --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/color/deconvolve/StainVectors.java @@ -0,0 +1,182 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.color.deconvolve; + +import java.util.Locale; + +/** + * Published optical density (OD) vectors of common histological stain + * combinations, for use with {@code color.deconvolveStains}. + *

+ * Each stain is a vector of its relative absorbance in the red, green and blue + * channels. The hematoxylin, eosin and DAB vectors are those determined by + * Ruifrok and Johnston (2001); note that {@link #H_E} therefore uses the + * published hematoxylin and eosin vectors, not the measured "H&E" set of + * ImageJ's Colour Deconvolution plugin. The other combinations are the + * measured values distributed with G. Landini's Colour Deconvolution plugin + * for ImageJ. Combinations of two stains leave the third vector unspecified; + * it is then completed as described in {@link ColourDeconvolution#stainMatrix}. + *

+ *

+ * Stain vectors depend on the staining protocol and the imaging system, so for + * quantitative work it is best to measure them from single-stained samples and + * pass them to {@code color.deconvolveStains} directly. + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @see A. C. Ruifrok and + * D. A. Johnston, "Quantification of histochemical staining by color + * deconvolution", Anal. Quant. Cytol. Histol. 23(4):291-299, 2001 + * @see Colour + * Deconvolution (G. Landini) + */ +public enum StainVectors { + + /** Hematoxylin and eosin. */ + H_E("H&E", Stain.HEMATOXYLIN, Stain.EOSIN), + + /** Hematoxylin and DAB (3,3-diaminobenzidine). */ + H_DAB("H DAB", Stain.HEMATOXYLIN, Stain.DAB), + + /** Hematoxylin, eosin and DAB. */ + H_E_DAB("H&E DAB", Stain.HEMATOXYLIN, Stain.EOSIN, Stain.DAB), + + /** Hematoxylin and AEC (3-amino-9-ethylcarbazole). */ + H_AEC("H AEC", Stain.HEMATOXYLIN, // + new double[] { 0.2743, 0.6796, 0.6803 }), + + /** Hematoxylin and periodic acid-Schiff. */ + H_PAS("H PAS", // + new double[] { 0.644211, 0.716556, 0.266844 }, // + new double[] { 0.175411, 0.972178, 0.154589 }), + + /** Alcian blue and hematoxylin. */ + ALCIAN_BLUE_H("Alcian blue & H", // + new double[] { 0.874622, 0.457711, 0.158256 }, // + new double[] { 0.552556, 0.7544, 0.353744 }), + + /** Feulgen and light green. */ + FEULGEN_LIGHT_GREEN("Feulgen Light Green", // + new double[] { 0.46420921, 0.83008335, 0.30827187 }, // + new double[] { 0.94705542, 0.25373821, 0.19650764 }), + + /** Giemsa: methylene blue and eosin. */ + GIEMSA("Giemsa", // + new double[] { 0.834750233, 0.513556283, 0.196330403 }, // + new double[] { 0.092789, 0.954111, 0.283111 }), + + /** Fast red, fast blue and DAB. */ + FASTRED_FASTBLUE_DAB("FastRed FastBlue DAB", // + new double[] { 0.21393921, 0.85112669, 0.47794022 }, // + new double[] { 0.74890292, 0.60624161, 0.26731082 }, // + Stain.DAB), + + /** Methyl green and DAB. */ + METHYL_GREEN_DAB("Methyl Green DAB", // + new double[] { 0.98, 0.144316, 0.133146 }, Stain.DAB), + + /** Azan-Mallory: aniline blue, azocarmine and orange G. */ + AZAN_MALLORY("Azan-Mallory", // + new double[] { 0.853033, 0.508733, 0.112656 }, // + new double[] { 0.09289875, 0.8662008, 0.49098468 }, // + new double[] { 0.10732849, 0.36765403, 0.9237484 }), + + /** Masson trichrome: methyl blue and ponceau fuchsin. */ + MASSON_TRICHROME("Masson Trichrome", // + new double[] { 0.7995107, 0.5913521, 0.10528667 }, // + new double[] { 0.09997159, 0.73738605, 0.6680326 }); + + /** Single-stain vectors of Ruifrok and Johnston (2001). */ + private static final class Stain { + + private static final double[] HEMATOXYLIN = { 0.650, 0.704, 0.286 }; + private static final double[] EOSIN = { 0.072, 0.990, 0.105 }; + private static final double[] DAB = { 0.268, 0.570, 0.776 }; + } + + private final String label; + private final double[][] vectors; + + StainVectors(final String label, final double[]... vectors) { + this.label = label; + this.vectors = vectors; + } + + /** + * Gets the human-readable name of the stain combination. + * + * @return the name, e.g. {@code "H&E"} + */ + public String label() { + return label; + } + + /** + * Gets a copy of the stain OD vectors. + * + * @return one row of length 3 (red, green, blue) per stain; there are two or + * three rows + */ + public double[][] vectors() { + final var copy = new double[vectors.length][]; + for (var i = 0; i < vectors.length; i++) + copy[i] = vectors[i].clone(); + return copy; + } + + /** + * Looks up a stain combination by name. Matching ignores case and all + * characters other than letters and digits, and accepts both the + * {@link #label()} (e.g. {@code "H&E"}, {@code "H DAB"}) and the constant + * name (e.g. {@code "H_E"}). + * + * @param name the name of the stain combination + * @return the matching stain combination + * @throws IllegalArgumentException if no stain combination matches + */ + public static StainVectors fromName(final String name) { + final var key = normalize(name); + for (final var s : values()) { + if (normalize(s.label).equals(key) || normalize(s.name()).equals(key)) { + return s; + } + } + final var sb = new StringBuilder(); + for (final var s : values()) + sb.append(sb.length() == 0 ? "" : ", ").append(s.label); + throw new IllegalArgumentException("Unknown stain combination: " + name + + " (known: " + sb + ")"); + } + + private static String normalize(final String s) { + return s.toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9]", ""); + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/color/deltae/DeltaE.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/color/deltae/DeltaE.java new file mode 100644 index 000000000..8387cbeea --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/color/deltae/DeltaE.java @@ -0,0 +1,212 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.color.deltae; + +import java.util.Locale; + +/** + * Colour difference formulas between two CIELAB colours, shared by the + * {@code color.deltaE} ops. + *
    + *
  • {@link #CIE76}: the Euclidean distance in CIELAB (CIE 1976).
  • + *
  • {@link #CIE94}: CIE 1994, with the graphic arts weights + * {@code kL = 1, K1 = 0.045, K2 = 0.015}.
  • + *
  • {@link #CIE94_TEXTILES}: CIE 1994, with the textile weights + * {@code kL = 2, K1 = 0.048, K2 = 0.014}.
  • + *
  • {@link #CIEDE2000}: CIEDE2000 (CIE 142-2001), with + * {@code kL = kC = kH = 1}, as specified by Sharma, Wu and Dalal (2005).
  • + *
+ *

+ * CIE94 is not symmetric: the first colour is the reference, whose chroma + * weights the chroma and hue differences. + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @see G. Sharma, W. Wu and E. N. + * Dalal, "The CIEDE2000 color-difference formula: Implementation notes, + * supplementary test data, and mathematical observations", Color Res. + * Appl. 30(1):21-30, 2005 + */ +public enum DeltaE { + + /** CIE 1976 colour difference. */ + CIE76("cie76", "cie1976", "de76"), + + /** CIE 1994 colour difference, graphic arts weights. */ + CIE94("cie94", "cie1994", "de94"), + + /** CIE 1994 colour difference, textile weights. */ + CIE94_TEXTILES("cie94textiles", "cie1994textiles", "de94textiles"), + + /** CIEDE2000 colour difference. */ + CIEDE2000("ciede2000", "cie2000", "de2000", "de00"); + + private static final double POW25_7 = Math.pow(25, 7); + + private final String[] names; + + DeltaE(final String... names) { + this.names = names; + } + + /** + * Looks up a formula by name, ignoring case, spaces, dashes and + * underscores: {@code CIE76}, {@code CIE94}, {@code CIE94textiles} or + * {@code CIEDE2000}. + * + * @param name the name of the formula + * @return the formula + * @throws IllegalArgumentException if the name is unknown + */ + public static DeltaE byName(final String name) { + if (name == null) throw new IllegalArgumentException( + "Formula name is null"); + final var key = name.toLowerCase(Locale.ROOT).replaceAll("[\\s_\\-]", ""); + for (final var f : values()) { + for (final var n : f.names) { + if (n.equals(key)) return f; + } + } + throw new IllegalArgumentException("Unknown colour difference formula: " + + name); + } + + /** + * Computes the colour difference between two CIELAB colours. + * + * @param l1 L* of the first (reference) colour + * @param a1 a* of the first colour + * @param b1 b* of the first colour + * @param l2 L* of the second colour + * @param a2 a* of the second colour + * @param b2 b* of the second colour + * @return the colour difference + */ + public double compute(final double l1, final double a1, final double b1, + final double l2, final double a2, final double b2) + { + switch (this) { + case CIE76: + return cie76(l1, a1, b1, l2, a2, b2); + case CIE94: + return cie94(l1, a1, b1, l2, a2, b2, 1, 0.045, 0.015); + case CIE94_TEXTILES: + return cie94(l1, a1, b1, l2, a2, b2, 2, 0.048, 0.014); + case CIEDE2000: + return ciede2000(l1, a1, b1, l2, a2, b2); + default: + throw new IllegalStateException(toString()); + } + } + + private static double cie76(final double l1, final double a1, + final double b1, final double l2, final double a2, final double b2) + { + final double dl = l1 - l2, da = a1 - a2, db = b1 - b2; + return Math.sqrt(dl * dl + da * da + db * db); + } + + private static double cie94(final double l1, final double a1, + final double b1, final double l2, final double a2, final double b2, + final double kL, final double k1, final double k2) + { + final var c1 = Math.hypot(a1, b1); + final var c2 = Math.hypot(a2, b2); + final double dl = l1 - l2, da = a1 - a2, db = b1 - b2; + final var dc = c1 - c2; + final var dh2 = Math.max(0, da * da + db * db - dc * dc); + final var tl = dl / kL; + final var tc = dc / (1 + k1 * c1); + final var sh = 1 + k2 * c1; + return Math.sqrt(tl * tl + tc * tc + dh2 / (sh * sh)); + } + + private static double ciede2000(final double l1, final double a1, + final double b1, final double l2, final double a2, final double b2) + { + final var cBar = (Math.hypot(a1, b1) + Math.hypot(a2, b2)) / 2; + final var cBar7 = Math.pow(cBar, 7); + final var g = 0.5 * (1 - Math.sqrt(cBar7 / (cBar7 + POW25_7))); + final var ap1 = (1 + g) * a1; + final var ap2 = (1 + g) * a2; + final var cp1 = Math.hypot(ap1, b1); + final var cp2 = Math.hypot(ap2, b2); + final var hp1 = hueAngle(ap1, b1); + final var hp2 = hueAngle(ap2, b2); + + final var dL = l2 - l1; + final var dC = cp2 - cp1; + final var chromaProduct = cp1 * cp2; + double dh = 0; + if (chromaProduct != 0) { + dh = hp2 - hp1; + if (dh > 180) dh -= 360; + else if (dh < -180) dh += 360; + } + final var dH = 2 * Math.sqrt(chromaProduct) * Math.sin(Math.toRadians(dh / + 2)); + + final var lBar = (l1 + l2) / 2; + final var cpBar = (cp1 + cp2) / 2; + double hBar = hp1 + hp2; + if (chromaProduct != 0) { + if (Math.abs(hp1 - hp2) <= 180) hBar /= 2; + else if (hBar < 360) hBar = (hBar + 360) / 2; + else hBar = (hBar - 360) / 2; + } + final var t = 1 - 0.17 * cosDeg(hBar - 30) + 0.24 * cosDeg(2 * hBar) + + 0.32 * cosDeg(3 * hBar + 6) - 0.20 * cosDeg(4 * hBar - 63); + final var dTheta = 30 * Math.exp(-Math.pow((hBar - 275) / 25, 2)); + final var cpBar7 = Math.pow(cpBar, 7); + final var rc = 2 * Math.sqrt(cpBar7 / (cpBar7 + POW25_7)); + final var l50 = (lBar - 50) * (lBar - 50); + final var sl = 1 + 0.015 * l50 / Math.sqrt(20 + l50); + final var sc = 1 + 0.045 * cpBar; + final var sh = 1 + 0.015 * cpBar * t; + final var rt = -Math.sin(Math.toRadians(2 * dTheta)) * rc; + + final var tl = dL / sl; + final var tc = dC / sc; + final var th = dH / sh; + return Math.sqrt(tl * tl + tc * tc + th * th + rt * tc * th); + } + + /** Hue angle in degrees within {@code [0, 360)}; 0 for a neutral colour. */ + private static double hueAngle(final double a, final double b) { + if (a == 0 && b == 0) return 0; + final var h = Math.toDegrees(Math.atan2(b, a)); + return h < 0 ? h + 360 : h; + } + + private static double cosDeg(final double deg) { + return Math.cos(Math.toRadians(deg)); + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/color/deltae/DeltaEImage.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/color/deltae/DeltaEImage.java new file mode 100644 index 000000000..047d3d729 --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/color/deltae/DeltaEImage.java @@ -0,0 +1,110 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.color.deltae; + +import java.util.function.BiFunction; + +import net.imglib2.Dimensions; +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.loops.LoopBuilder; +import net.imglib2.type.numeric.RealType; +import net.imglib2.type.numeric.real.DoubleType; +import net.imglib2.util.Intervals; +import net.imglib2.view.Views; + +import org.scijava.function.Functions; +import org.scijava.ops.spi.Nullable; +import org.scijava.ops.spi.OpDependency; +import org.scijava.ops.image.color.ColorUtils; + +/** + * Computes the per-pixel colour difference between two CIELAB images, whose + * L*, a* and b* components lie along one dimension (as produced by + * {@code color.convert} to Lab). See {@link DeltaE} for the formulas. + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @param pixel type of the first image + * @param pixel type of the second image + * @see G. Sharma, W. Wu and E. N. + * Dalal, "The CIEDE2000 color-difference formula: Implementation notes, + * supplementary test data, and mathematical observations", Color Res. + * Appl. 30(1):21-30, 2005 + * @implNote op names='color.deltaE' + */ +public class DeltaEImage, U extends RealType> + implements + Functions.Arity4, RandomAccessibleInterval, String, Integer, RandomAccessibleInterval> +{ + + @OpDependency(name = "create.img") + private BiFunction> createOp; + + /** + * Computes the colour difference of each pair of pixels. + * + * @param lab1 the first (reference) CIELAB image + * @param lab2 the second CIELAB image, with the same dimensions + * @param formula the colour difference formula: CIE76, CIE94, + * CIE94textiles or CIEDE2000 (default: CIEDE2000) + * @param channelDim the dimension holding the L*, a* and b* components + * (default: the last dimension of size 3) + * @return the colour differences, with the channel dimension removed + */ + @Override + public RandomAccessibleInterval apply( + final RandomAccessibleInterval lab1, + final RandomAccessibleInterval lab2, @Nullable final String formula, + @Nullable final Integer channelDim) + { + if (!Intervals.equalDimensions(lab1, lab2)) { + throw new IllegalArgumentException( + "Both images must have the same dimensions"); + } + final var f = formula == null ? DeltaE.CIEDE2000 : DeltaE.byName(formula); + final int cd = channelDim == null ? ColorUtils.defaultChannelDim( + lab1) : channelDim; + if (cd < 0 || cd >= lab1.numDimensions() || lab1.dimension(cd) != 3) { + throw new IllegalArgumentException("Channel dimension " + cd + + " must exist and have size 3"); + } + final var last = lab1.numDimensions() - 1; + final var c1 = Views.collapse(Views.moveAxis(lab1, cd, last)); + final var c2 = Views.collapse(Views.moveAxis(lab2, cd, last)); + final var output = createOp.apply(c1, new DoubleType()); + LoopBuilder.setImages(c1, c2, output).multiThreaded().forEachPixel((p, q, + out) -> out.set(f.compute( // + p.get(0).getRealDouble(), p.get(1).getRealDouble(), p.get(2) + .getRealDouble(), // + q.get(0).getRealDouble(), q.get(1).getRealDouble(), q.get(2) + .getRealDouble()))); + return output; + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/color/deltae/DeltaEValues.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/color/deltae/DeltaEValues.java new file mode 100644 index 000000000..97e5d7232 --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/color/deltae/DeltaEValues.java @@ -0,0 +1,71 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.color.deltae; + +import org.scijava.function.Functions; +import org.scijava.ops.spi.Nullable; + +/** + * Computes the colour difference between two CIELAB colours given as + * {@code double[]} triplets. See {@link DeltaE} for the formulas. + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @see G. Sharma, W. Wu and E. N. + * Dalal, "The CIEDE2000 color-difference formula: Implementation notes, + * supplementary test data, and mathematical observations", Color Res. + * Appl. 30(1):21-30, 2005 + * @implNote op names='color.deltaE' + */ +public class DeltaEValues implements + Functions.Arity3 +{ + + /** + * Computes a colour difference. + * + * @param lab1 the first (reference) colour, as L*, a*, b* + * @param lab2 the second colour, as L*, a*, b* + * @param formula the colour difference formula: CIE76, CIE94, + * CIE94textiles or CIEDE2000 (default: CIEDE2000) + * @return the colour difference + */ + @Override + public Double apply(final double[] lab1, final double[] lab2, + @Nullable final String formula) + { + if (lab1.length != 3 || lab2.length != 3) { + throw new IllegalArgumentException( + "Colours must have three components (L*, a*, b*)"); + } + final var f = formula == null ? DeltaE.CIEDE2000 : DeltaE.byName(formula); + return f.compute(lab1[0], lab1[1], lab1[2], lab2[0], lab2[1], lab2[2]); + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/deconvolve/landweber/Landweber.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/deconvolve/landweber/Landweber.java new file mode 100644 index 000000000..7d45a1e78 --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/deconvolve/landweber/Landweber.java @@ -0,0 +1,309 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.deconvolve.landweber; + +import java.util.function.BiFunction; + +import net.imglib2.Dimensions; +import net.imglib2.FinalDimensions; +import net.imglib2.FinalInterval; +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.img.Img; +import net.imglib2.loops.LoopBuilder; +import net.imglib2.outofbounds.OutOfBoundsConstantValueFactory; +import net.imglib2.outofbounds.OutOfBoundsFactory; +import net.imglib2.outofbounds.OutOfBoundsMirrorFactory; +import net.imglib2.type.NativeType; +import net.imglib2.type.numeric.ComplexType; +import net.imglib2.type.numeric.RealType; +import net.imglib2.type.numeric.real.DoubleType; +import net.imglib2.util.Util; +import net.imglib2.view.Views; + +import org.scijava.function.Computers; +import org.scijava.function.Functions; +import org.scijava.ops.spi.Nullable; +import org.scijava.ops.spi.OpDependency; +import org.scijava.progress.Progress; + +/** + * Landweber deconvolution: gradient descent on the least squares misfit + * {@code ||h * x - y||^2 / 2}, iterating + * {@code x <- x + tau h^T (y - h * x)}, where {@code h^T} is correlation with + * the PSF. The iteration converges for step sizes {@code 0 < tau < 2 / max|H|^2} + * (that is, {@code tau < 2} for a PSF normalized to unit sum), and the + * iteration count acts as the regularization parameter. With the + * nonnegativity constraint, each iterate is projected onto {@code x >= 0} + * (projected Landweber). + *

+ * The image is padded as in {@code deconvolve.richardsonLucy}. In the default + * circulant mode, the image is padded (by default with single-boundary + * mirroring) and the iteration starts from the padded image. In non-circulant + * mode, the object is estimated on the padded domain while the residual is + * only evaluated inside the measured window, which is the least squares + * analogue of the boundary handling of Bertero and Boccacci used by + * {@code deconvolve.richardsonLucy}; the iteration then starts from a + * constant image equal to the mean of the input. + *

+ *

+ * The iteration is computed in double precision for any output type; the + * result is then clamped to the range of the output type. + *

+ *

+ * References: + *

+ *
    + *
  • Landweber, L. (1951). An iteration formula for Fredholm integral + * equations of the first kind. American Journal of Mathematics 73(3), + * 615-624.
  • + *
  • Eicke, B. (1992). Iteration methods for convexly constrained ill-posed + * problems in Hilbert space. Numerical Functional Analysis and Optimization + * 13(5-6), 413-429.
  • + *
  • Bertero, M., and Boccacci, P. (1998). Introduction to Inverse Problems + * in Imaging. IOP Publishing.
  • + *
  • Bertero, M., and Boccacci, P. (2005). A simple method for the reduction + * of boundary effects in the Richardson-Lucy approach to image deconvolution. + * Astronomy and Astrophysics 437, 369-374.
  • + *
+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @param type of the input image + * @param type of the output image + * @param type of the kernel + * @param complex type used in frequency space + * @implNote op names='deconvolve.landweber' + */ +public class Landweber & NativeType, O extends RealType & NativeType, K extends RealType & NativeType, C extends ComplexType & NativeType> + implements + Functions.Arity10, RandomAccessibleInterval, O, C, Integer, Double, Boolean, Boolean, long[], OutOfBoundsFactory>, RandomAccessibleInterval> +{ + + @OpDependency(name = "create.img") + private BiFunction> createImg; + + @OpDependency(name = "create.img") + private BiFunction> createDoubleImg; + + @OpDependency(name = "filter.padInputFFTMethods") + private Functions.Arity4, Dimensions, Boolean, OutOfBoundsFactory>, RandomAccessibleInterval> padOp; + + @OpDependency(name = "filter.padShiftKernelFFTMethods") + private BiFunction, Dimensions, RandomAccessibleInterval> padKernelOp; + + @OpDependency(name = "filter.createFFTOutput") + private Functions.Arity3> createFFTOp; + + @OpDependency(name = "filter.fft") + private Computers.Arity1, RandomAccessibleInterval> fftKernelOp; + + @OpDependency(name = "filter.linearFilter") + private Computers.Arity7, RandomAccessibleInterval, Boolean, Boolean, Computers.Arity2, RandomAccessibleInterval, RandomAccessibleInterval>, RandomAccessibleInterval, RandomAccessibleInterval, RandomAccessibleInterval> linearFilter; + + @OpDependency(name = "stats.mean") + private Computers.Arity1, DoubleType> meanOp; + + /** + * Deconvolves an image with the (projected) Landweber iteration. + * + * @param input the image to deconvolve + * @param kernel the PSF + * @param outType the type of the output image + * @param complexType the complex type used in frequency space + * @param maxIterations number of iterations to perform + * @param stepSize the step size tau, in (0, 2 / max|H|^2) for convergence + * (default 1 / max|H|^2, which is 1 for a PSF normalized to unit + * sum) + * @param nonNegative whether to project each iterate onto nonnegative values + * (default true) + * @param nonCirculant whether to use non-circulant edge handling (default + * false) + * @param borderSize the padding added to each side in each dimension + * (default: pad by the kernel size) + * @param obfInput the out-of-bounds strategy used to pad the input (default: + * single-boundary mirroring when circulant, zero when + * non-circulant) + * @return the deconvolved image, with the dimensions of the input + */ + @Override + public RandomAccessibleInterval apply( // + final RandomAccessibleInterval input, // + final RandomAccessibleInterval kernel, // + final O outType, // + final C complexType, // + final Integer maxIterations, // + @Nullable Double stepSize, // + @Nullable Boolean nonNegative, // + @Nullable Boolean nonCirculant, // + @Nullable final long[] borderSize, // + @Nullable OutOfBoundsFactory> obfInput // + ) { + if (maxIterations < 0) { + throw new IllegalArgumentException( + "Iteration count must be nonnegative: " + maxIterations); + } + if (stepSize != null && !(stepSize > 0)) { + throw new IllegalArgumentException("Step size must be positive: " + + stepSize); + } + if (nonNegative == null) nonNegative = true; + if (nonCirculant == null) nonCirculant = false; + if (obfInput == null) { + obfInput = nonCirculant ? // + new OutOfBoundsConstantValueFactory<>(Util.getTypeFromInterval(input) + .createVariable()) : // + new OutOfBoundsMirrorFactory<>(OutOfBoundsMirrorFactory.Boundary.SINGLE); + } + + // Pad the input and kernel to a fast FFT size. + final var n = input.numDimensions(); + final var paddedSize = new long[n]; + for (var d = 0; d < n; d++) { + paddedSize[d] = borderSize == null ? // + input.dimension(d) + kernel.dimension(d) - 1 : // + Math.max(kernel.dimension(d), input.dimension(d)) + 2 * borderSize[d]; + } + final var paddedInput = padOp.apply(input, new FinalDimensions( + paddedSize), true, obfInput); + final var paddedKernel = padKernelOp.apply(kernel, new FinalDimensions( + paddedSize)); + final var paddedDims = new FinalDimensions(paddedInput + .dimensionsAsLongArray()); + + // Location of the measured window within the zero-min padded domain. + final var winMin = new long[n]; + final var winMax = new long[n]; + for (var d = 0; d < n; d++) { + winMin[d] = input.min(d) - paddedInput.min(d); + winMax[d] = winMin[d] + input.dimension(d) - 1; + } + final var window = new FinalInterval(winMin, winMax); + + // Transform the kernel once, and find the Lipschitz constant max|H|^2. + final var fftBuffer = createFFTOp.apply(paddedDims, complexType, true); + final var fftKernel = createFFTOp.apply(paddedDims, complexType, true); + fftKernelOp.compute(paddedKernel, fftKernel); + var maxPower = 0.0; + for (final var h : Views.iterable(fftKernel)) { + // Note: ImgLib2's getPowerDouble() is the magnitude |H|, not |H|^2. + final var re = h.getRealDouble(); + final var im = h.getImaginaryDouble(); + maxPower = Math.max(maxPower, re * re + im * im); + } + if (!(maxPower > 0)) { + throw new IllegalArgumentException("Kernel has no nonzero frequencies"); + } + final double tau = stepSize == null ? 1 / maxPower : stepSize; + + // Measured data on the padded domain, and the first estimate. + // Note: The iterate and the (signed) residual are kept in double + // precision whatever the output type, which is only used for the result. + final var y = createDoubleImg.apply(paddedDims, new DoubleType()); + LoopBuilder.setImages(Views.zeroMin(paddedInput), y).forEachPixel((in, + out) -> out.set(in.getRealDouble())); + final var x = createDoubleImg.apply(paddedDims, new DoubleType()); + if (nonCirculant) { + final var mean = new DoubleType(); + meanOp.compute(Views.iterable(input), mean); + final var m = mean.getRealDouble(); + for (final var v : x) + v.setReal(m); + } + else LoopBuilder.setImages(y, x).forEachPixel((in, out) -> out.set(in)); + if (nonNegative) clampNonNegative(x); + + final Computers.Arity2, RandomAccessibleInterval, RandomAccessibleInterval> convolve = + (fx, fh, out) -> multiply(fx, fh, out, false); + final Computers.Arity2, RandomAccessibleInterval, RandomAccessibleInterval> correlate = + (fx, fh, out) -> multiply(fx, fh, out, true); + + final var residual = createDoubleImg.apply(paddedDims, new DoubleType()); + final var reblurred = nonCirculant ? // + createDoubleImg.apply(paddedDims, new DoubleType()) : residual; + final boolean project = nonNegative; + + Progress.defineTotal(maxIterations); + for (var i = 0; i < maxIterations; i++) { + // residual = y - h * x, restricted to the window if non-circulant + linearFilter.compute(x, null, true, false, convolve, fftBuffer, + fftKernel, reblurred); + if (nonCirculant) { + for (final var v : residual) + v.setZero(); + LoopBuilder.setImages(Views.interval(residual, window), Views.interval( + y, window), Views.interval(reblurred, window)).forEachPixel((r, yv, + hx) -> r.setReal(yv.getRealDouble() - hx.getRealDouble())); + } + else { + LoopBuilder.setImages(residual, y).forEachPixel((r, yv) -> r.setReal( + yv.getRealDouble() - r.getRealDouble())); + } + + // x = x + tau h^T residual, optionally projected onto x >= 0 + linearFilter.compute(residual, null, true, false, correlate, fftBuffer, + fftKernel, residual); + LoopBuilder.setImages(x, residual).forEachPixel((xv, g) -> { + final var v = xv.getRealDouble() + tau * g.getRealDouble(); + xv.setReal(project && v < 0 ? 0 : v); + }); + Progress.update(); + } + + // Crop the measured window, clamping to the range of the output type. + final var output = createImg.apply(input, outType); + LoopBuilder.setImages(Views.zeroMin(Views.interval(x, window)), output) + .forEachPixel((in, out) -> out.setReal(Math.max(out.getMinValue(), Math + .min(out.getMaxValue(), in.get())))); + return output; + } + + private void clampNonNegative(final Img img) { + for (final var v : img) { + if (v.getRealDouble() < 0) v.setZero(); + } + } + + /** Computes {@code out = X H} or {@code out = X conj(H)}. */ + private void multiply(final RandomAccessibleInterval fftX, + final RandomAccessibleInterval fftH, + final RandomAccessibleInterval out, final boolean conjugate) + { + LoopBuilder.setImages(fftX, fftH, out).forEachPixel((xc, hc, o) -> { + final var xr = xc.getRealDouble(); + final var xi = xc.getImaginaryDouble(); + final var hr = hc.getRealDouble(); + final var hi = conjugate ? -hc.getImaginaryDouble() : hc + .getImaginaryDouble(); + o.setReal(xr * hr - xi * hi); + o.setImaginary(xr * hi + xi * hr); + }); + } +} + diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/deconvolve/landweber/SimpleLandweber.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/deconvolve/landweber/SimpleLandweber.java new file mode 100644 index 000000000..639c50735 --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/deconvolve/landweber/SimpleLandweber.java @@ -0,0 +1,84 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.deconvolve.landweber; + +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.outofbounds.OutOfBoundsFactory; +import net.imglib2.type.NativeType; +import net.imglib2.type.numeric.RealType; +import net.imglib2.type.numeric.complex.ComplexFloatType; +import net.imglib2.type.numeric.real.FloatType; + +import org.scijava.function.Functions; +import org.scijava.ops.spi.Nullable; +import org.scijava.ops.spi.OpDependency; + +/** + * Landweber deconvolution with float output, a convenience for + * {@link Landweber}. + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @implNote op names='deconvolve.landweber', priority='100.' + */ +public class SimpleLandweber & NativeType, K extends RealType & NativeType> + implements + Functions.Arity5, RandomAccessibleInterval, Integer, Double, Boolean, RandomAccessibleInterval> +{ + + @OpDependency(name = "deconvolve.landweber") + private Functions.Arity10, RandomAccessibleInterval, FloatType, ComplexFloatType, Integer, Double, Boolean, Boolean, long[], OutOfBoundsFactory>, RandomAccessibleInterval> landweberOp; + + /** + * Deconvolves an image with the (projected) Landweber iteration. + * + * @param input the image to deconvolve + * @param kernel the PSF + * @param maxIterations number of iterations to perform + * @param stepSize the step size tau, in (0, 2 / max|H|^2) for convergence + * (default 1 / max|H|^2, which is 1 for a PSF normalized to unit + * sum) + * @param nonNegative whether to project each iterate onto nonnegative values + * (default true) + * @return the deconvolved image, with the dimensions of the input + */ + @Override + public RandomAccessibleInterval apply( // + final RandomAccessibleInterval input, // + final RandomAccessibleInterval kernel, // + final Integer maxIterations, // + @Nullable final Double stepSize, // + @Nullable final Boolean nonNegative // + ) { + return landweberOp.apply(input, kernel, new FloatType(), + new ComplexFloatType(), maxIterations, stepSize, nonNegative, null, null, + null); + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/deconvolve/wiener/AbstractInverseFilter.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/deconvolve/wiener/AbstractInverseFilter.java new file mode 100644 index 000000000..3acf1a3c4 --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/deconvolve/wiener/AbstractInverseFilter.java @@ -0,0 +1,198 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.deconvolve.wiener; + +import java.util.function.BiFunction; + +import net.imglib2.Dimensions; +import net.imglib2.FinalDimensions; +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.outofbounds.OutOfBoundsFactory; +import net.imglib2.outofbounds.OutOfBoundsMirrorFactory; +import net.imglib2.type.NativeType; +import net.imglib2.type.numeric.ComplexType; +import net.imglib2.type.numeric.RealType; +import net.imglib2.view.Views; + +import org.scijava.function.Computers; +import org.scijava.function.Functions; +import org.scijava.ops.spi.OpDependency; + +/** + * Base class for direct (non-iterative) regularized inverse filters, which + * compute the estimate {@code X = conj(H) Y / (|H|^2 + lambda W)} in frequency + * space, where {@code Y} and {@code H} are the Fourier transforms of the + * padded image and PSF, and {@code W} is a nonnegative per-frequency + * regularization weight. + *

+ * Padding, kernel shifting and the FFTs reuse the same ops as + * {@code deconvolve.richardsonLucy} and {@code filter.convolve}. + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @param type of the input image + * @param type of the output image + * @param type of the kernel + * @param complex type used in frequency space + */ +public abstract class AbstractInverseFilter & NativeType, O extends RealType & NativeType, K extends RealType & NativeType, C extends ComplexType & NativeType> +{ + + @OpDependency(name = "create.img") + private BiFunction> outputCreator; + + @OpDependency(name = "filter.padInputFFTMethods") + private Functions.Arity4, Dimensions, Boolean, OutOfBoundsFactory>, RandomAccessibleInterval> padOp; + + @OpDependency(name = "filter.padShiftKernelFFTMethods") + private BiFunction, Dimensions, RandomAccessibleInterval> padKernelOp; + + @OpDependency(name = "filter.createFFTOutput") + private Functions.Arity3> createOp; + + @OpDependency(name = "filter.linearFilter") + private Computers.Arity7, RandomAccessibleInterval, Boolean, Boolean, Computers.Arity2, RandomAccessibleInterval, RandomAccessibleInterval>, RandomAccessibleInterval, RandomAccessibleInterval, RandomAccessibleInterval> linearFilter; + + /** Regularization weight {@code W} as a function of frequency. */ + @FunctionalInterface + protected interface Weight { + + /** + * Gets the regularization weight at one frequency. + * + * @param pos position of the frequency sample in the (real-to-complex) + * FFT array + * @param realSize size of the padded image in real space + * @return the regularization weight + */ + double at(long[] pos, long[] realSize); + } + + /** + * Pads the input and kernel, then applies the inverse filter. + * + * @param input the image to deconvolve + * @param kernel the PSF + * @param outType the type of the output image + * @param complexType the complex type used in frequency space + * @param lambda the regularization parameter + * @param weight the regularization weight, or null for a constant 1 + * @param borderSize the border size in each dimension, or null to pad by + * the kernel size + * @param obfInput the out-of-bounds strategy for the input, or null for + * single-boundary mirroring + * @return the deconvolved image, with the same dimensions as the input + */ + protected RandomAccessibleInterval filter( + final RandomAccessibleInterval input, + final RandomAccessibleInterval kernel, final O outType, + final C complexType, final double lambda, final Weight weight, + final long[] borderSize, OutOfBoundsFactory> obfInput) + { + if (!(lambda >= 0)) { + throw new IllegalArgumentException( + "Regularization parameter must be nonnegative: " + lambda); + } + if (obfInput == null) { + obfInput = new OutOfBoundsMirrorFactory<>( + OutOfBoundsMirrorFactory.Boundary.SINGLE); + } + + final var paddedSize = paddedSize(input, kernel, borderSize); + final var paddedInput = padOp.apply(input, new FinalDimensions( + paddedSize), true, obfInput); + final var paddedKernel = padKernelOp.apply(kernel, new FinalDimensions( + paddedSize)); + + final var fftSize = new FinalDimensions(paddedSize); + final var fftInput = createOp.apply(fftSize, complexType, true); + final var fftKernel = createOp.apply(fftSize, complexType, true); + + final var realSize = paddedInput.dimensionsAsLongArray(); + final Computers.Arity2, RandomAccessibleInterval, RandomAccessibleInterval> frequencyOp = + (fftY, fftH, out) -> invert(fftY, fftH, out, lambda, weight, realSize); + + // Note: filter.ifft crops the centered region of the padded result, which + // is where filter.padInputFFTMethods placed the input. + final var output = outputCreator.apply(input, outType); + linearFilter.compute(paddedInput, paddedKernel, true, true, frequencyOp, + fftInput, fftKernel, output); + return output; + } + + private void invert(final RandomAccessibleInterval fftY, + final RandomAccessibleInterval fftH, + final RandomAccessibleInterval out, final double lambda, + final Weight weight, final long[] realSize) + { + final var pos = new long[fftY.numDimensions()]; + final var cy = Views.flatIterable(fftY).localizingCursor(); + final var ch = Views.flatIterable(fftH).cursor(); + final var co = Views.flatIterable(out).cursor(); + while (cy.hasNext()) { + final var y = cy.next(); + final var h = ch.next(); + final var o = co.next(); + cy.localize(pos); + final var hr = h.getRealDouble(); + final var hi = h.getImaginaryDouble(); + final var yr = y.getRealDouble(); + final var yi = y.getImaginaryDouble(); + final var denom = hr * hr + hi * hi + lambda * // + (weight == null ? 1 : weight.at(pos, realSize)); + if (denom > 0) { + // conj(H) * Y / denom + o.setReal((hr * yr + hi * yi) / denom); + o.setImaginary((hr * yi - hi * yr) / denom); + } + else { + o.setReal(0); + o.setImaginary(0); + } + } + } + + /** + * Computes the padded size the same way as {@code deconvolve.richardsonLucy} + * and {@code filter.convolve}. + */ + static long[] paddedSize(final Dimensions input, final Dimensions kernel, + final long[] borderSize) + { + final var paddedSize = new long[input.numDimensions()]; + for (var d = 0; d < paddedSize.length; d++) { + paddedSize[d] = borderSize == null ? // + input.dimension(d) + kernel.dimension(d) - 1 : // + Math.max(kernel.dimension(d), input.dimension(d)) + 2 * borderSize[d]; + } + return paddedSize; + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/deconvolve/wiener/SimpleTikhonovFilter.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/deconvolve/wiener/SimpleTikhonovFilter.java new file mode 100644 index 000000000..ca1742668 --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/deconvolve/wiener/SimpleTikhonovFilter.java @@ -0,0 +1,79 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.deconvolve.wiener; + +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.outofbounds.OutOfBoundsFactory; +import net.imglib2.type.NativeType; +import net.imglib2.type.numeric.RealType; +import net.imglib2.type.numeric.complex.ComplexFloatType; +import net.imglib2.type.numeric.real.FloatType; + +import org.scijava.function.Functions; +import org.scijava.ops.spi.Nullable; +import org.scijava.ops.spi.OpDependency; + +/** + * Tikhonov-regularized inverse filter with float output, a convenience for + * {@link TikhonovFilter}. + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @implNote op names='deconvolve.tikhonov', priority='100.' + */ +public class SimpleTikhonovFilter & NativeType, K extends RealType & NativeType> + implements + Functions.Arity4, RandomAccessibleInterval, Double, Integer, RandomAccessibleInterval> +{ + + @OpDependency(name = "deconvolve.tikhonov") + private Functions.Arity8, RandomAccessibleInterval, FloatType, ComplexFloatType, Double, Integer, long[], OutOfBoundsFactory>, RandomAccessibleInterval> tikhonovOp; + + /** + * Deconvolves an image with a Tikhonov-regularized inverse filter. + * + * @param input the image to deconvolve + * @param kernel the PSF, normalized to unit sum + * @param lambda the regularization parameter, at least 0 (default 1e-3) + * @param order the derivative order of the regularization operator: 0 for + * identity, 1 for gradient, or 2 for Laplacian (default 2) + * @return the deconvolved image, with the dimensions of the input + */ + @Override + public RandomAccessibleInterval apply( // + final RandomAccessibleInterval input, // + final RandomAccessibleInterval kernel, // + @Nullable final Double lambda, // + @Nullable final Integer order // + ) { + return tikhonovOp.apply(input, kernel, new FloatType(), + new ComplexFloatType(), lambda, order, null, null); + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/deconvolve/wiener/SimpleWienerFilter.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/deconvolve/wiener/SimpleWienerFilter.java new file mode 100644 index 000000000..1ef8ef0d8 --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/deconvolve/wiener/SimpleWienerFilter.java @@ -0,0 +1,77 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.deconvolve.wiener; + +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.outofbounds.OutOfBoundsFactory; +import net.imglib2.type.NativeType; +import net.imglib2.type.numeric.RealType; +import net.imglib2.type.numeric.complex.ComplexFloatType; +import net.imglib2.type.numeric.real.FloatType; + +import org.scijava.function.Functions; +import org.scijava.ops.spi.Nullable; +import org.scijava.ops.spi.OpDependency; + +/** + * Wiener deconvolution with float output, a convenience for + * {@link WienerFilter}. + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @implNote op names='deconvolve.wiener', priority='100.' + */ +public class SimpleWienerFilter & NativeType, K extends RealType & NativeType> + implements + Functions.Arity3, RandomAccessibleInterval, Double, RandomAccessibleInterval> +{ + + @OpDependency(name = "deconvolve.wiener") + private Functions.Arity7, RandomAccessibleInterval, FloatType, ComplexFloatType, Double, long[], OutOfBoundsFactory>, RandomAccessibleInterval> wienerOp; + + /** + * Deconvolves an image with the Wiener filter. + * + * @param input the image to deconvolve + * @param kernel the PSF, normalized to unit sum + * @param nsr the noise-to-signal power ratio K, at least 0 (default 1e-3); + * 0 yields the plain inverse filter + * @return the deconvolved image, with the dimensions of the input + */ + @Override + public RandomAccessibleInterval apply( // + final RandomAccessibleInterval input, // + final RandomAccessibleInterval kernel, // + @Nullable final Double nsr // + ) { + return wienerOp.apply(input, kernel, new FloatType(), + new ComplexFloatType(), nsr, null, null); + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/deconvolve/wiener/TikhonovFilter.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/deconvolve/wiener/TikhonovFilter.java new file mode 100644 index 000000000..d8fb24775 --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/deconvolve/wiener/TikhonovFilter.java @@ -0,0 +1,142 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.deconvolve.wiener; + +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.outofbounds.OutOfBoundsFactory; +import net.imglib2.type.NativeType; +import net.imglib2.type.numeric.ComplexType; +import net.imglib2.type.numeric.RealType; + +import org.scijava.function.Functions; +import org.scijava.ops.spi.Nullable; + +/** + * Tikhonov-regularized inverse filter: the minimizer of + * {@code ||h * x - y||^2 + lambda ||L x||^2}, computed directly in frequency + * space as {@code X = conj(H) Y / (|H|^2 + lambda |L|^2)}, where {@code L} is + * a discrete derivative operator of the given order: + *
    + *
  • order 0: the identity, so this equals {@code deconvolve.wiener} with + * {@code nsr = lambda};
  • + *
  • order 1: the forward-difference gradient, penalizing + * {@code ||grad x||^2};
  • + *
  • order 2 (default): the discrete Laplacian, also known as the + * Tikhonov-Miller or constrained least squares filter.
  • + *
+ * In frequency space, {@code |L|^2 = P^order}, where + * {@code P = sum_d 4 sin^2(pi k_d / N_d)} is the symbol of the negative + * discrete Laplacian on the padded image of size {@code N}. + *

+ * Padding conventions are those of {@code deconvolve.wiener}. + *

+ *

+ * References: + *

+ *
    + *
  • Tikhonov, A. N., and Arsenin, V. Y. (1977). Solutions of Ill-Posed + * Problems. Winston.
  • + *
  • Hunt, B. R. (1973). The application of constrained least squares + * estimation to image restoration by digital computer. IEEE Transactions on + * Computers C-22(9), 805-812.
  • + *
  • Gonzalez, R. C., and Woods, R. E. (2008). Digital Image Processing, 3rd + * ed., section 5.9. Prentice Hall.
  • + *
+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @param type of the input image + * @param type of the output image + * @param type of the kernel + * @param complex type used in frequency space + * @implNote op names='deconvolve.tikhonov' + */ +public class TikhonovFilter & NativeType, O extends RealType & NativeType, K extends RealType & NativeType, C extends ComplexType & NativeType> + extends AbstractInverseFilter implements + Functions.Arity8, RandomAccessibleInterval, O, C, Double, Integer, long[], OutOfBoundsFactory>, RandomAccessibleInterval> +{ + + /** Default regularization parameter. */ + public static final double DEFAULT_LAMBDA = 1e-3; + + /** Default derivative order of the regularization operator. */ + public static final int DEFAULT_ORDER = 2; + + /** + * Deconvolves an image with a Tikhonov-regularized inverse filter. + * + * @param input the image to deconvolve + * @param kernel the PSF, normalized to unit sum + * @param outType the type of the output image + * @param complexType the complex type used in frequency space + * @param lambda the regularization parameter, at least 0 (default 1e-3) + * @param order the derivative order of the regularization operator: 0 for + * identity, 1 for gradient, or 2 for Laplacian (default 2) + * @param borderSize the padding added to each side in each dimension + * (default: pad by the kernel size) + * @param obfInput the out-of-bounds strategy used to pad the input (default: + * single-boundary mirroring) + * @return the deconvolved image, with the dimensions of the input + */ + @Override + public RandomAccessibleInterval apply( // + final RandomAccessibleInterval input, // + final RandomAccessibleInterval kernel, // + final O outType, // + final C complexType, // + @Nullable Double lambda, // + @Nullable Integer order, // + @Nullable final long[] borderSize, // + @Nullable final OutOfBoundsFactory> obfInput // + ) { + if (lambda == null) lambda = DEFAULT_LAMBDA; + if (order == null) order = DEFAULT_ORDER; + if (order < 0 || order > 2) { + throw new IllegalArgumentException( + "Regularization order must be 0, 1 or 2: " + order); + } + final int o = order; + return filter(input, kernel, outType, complexType, lambda, // + (pos, realSize) -> weight(o, pos, realSize), borderSize, obfInput); + } + + private static double weight(final int order, final long[] pos, + final long[] realSize) + { + if (order == 0) return 1; + var p = 0.0; + for (var d = 0; d < pos.length; d++) { + final var s = Math.sin(Math.PI * pos[d] / realSize[d]); + p += 4 * s * s; + } + return order == 1 ? p : p * p; + } +} + diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/deconvolve/wiener/WienerFilter.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/deconvolve/wiener/WienerFilter.java new file mode 100644 index 000000000..d02ff1ff1 --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/deconvolve/wiener/WienerFilter.java @@ -0,0 +1,118 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.deconvolve.wiener; + +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.outofbounds.OutOfBoundsFactory; +import net.imglib2.type.NativeType; +import net.imglib2.type.numeric.ComplexType; +import net.imglib2.type.numeric.RealType; + +import org.scijava.function.Functions; +import org.scijava.ops.spi.Nullable; + +/** + * Wiener deconvolution with a constant noise-to-signal power ratio (the + * parametric Wiener filter): {@code X = conj(H) Y / (|H|^2 + K)}, where + * {@code Y} and {@code H} are the Fourier transforms of the image and PSF and + * {@code K} is the noise-to-signal ratio. With {@code K = 0} this is the plain + * inverse filter {@code Y / H}, restricted to frequencies where {@code H} is + * nonzero. + *

+ * This is also zeroth-order Tikhonov regularization of the inverse filter; see + * {@code deconvolve.tikhonov} for smoothness (gradient or Laplacian) + * regularization. The scikit-image {@code wiener} function reduces to this + * filter when its {@code reg} operator is the identity (its default is a + * Laplacian, which corresponds to {@code deconvolve.tikhonov}). + *

+ *

+ * The image is padded as in {@code deconvolve.richardsonLucy} (circulant + * model), by default by the kernel size using single-boundary mirroring, and + * the result is cropped back to the input size. The PSF is expected to be + * normalized to unit sum, so that {@code K} is relative to the zero-frequency + * response. + *

+ *

+ * References: + *

+ *
    + *
  • Wiener, N. (1949). Extrapolation, Interpolation, and Smoothing of + * Stationary Time Series. MIT Press.
  • + *
  • Gonzalez, R. C., and Woods, R. E. (2008). Digital Image Processing, 3rd + * ed., section 5.8. Prentice Hall.
  • + *
+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @param type of the input image + * @param type of the output image + * @param type of the kernel + * @param complex type used in frequency space + * @implNote op names='deconvolve.wiener' + */ +public class WienerFilter & NativeType, O extends RealType & NativeType, K extends RealType & NativeType, C extends ComplexType & NativeType> + extends AbstractInverseFilter implements + Functions.Arity7, RandomAccessibleInterval, O, C, Double, long[], OutOfBoundsFactory>, RandomAccessibleInterval> +{ + + /** Default noise-to-signal power ratio. */ + public static final double DEFAULT_NSR = 1e-3; + + /** + * Deconvolves an image with the Wiener filter. + * + * @param input the image to deconvolve + * @param kernel the PSF, normalized to unit sum + * @param outType the type of the output image + * @param complexType the complex type used in frequency space + * @param nsr the noise-to-signal power ratio K, at least 0 (default 1e-3); + * 0 yields the plain inverse filter + * @param borderSize the padding added to each side in each dimension + * (default: pad by the kernel size) + * @param obfInput the out-of-bounds strategy used to pad the input (default: + * single-boundary mirroring) + * @return the deconvolved image, with the dimensions of the input + */ + @Override + public RandomAccessibleInterval apply( // + final RandomAccessibleInterval input, // + final RandomAccessibleInterval kernel, // + final O outType, // + final C complexType, // + @Nullable Double nsr, // + @Nullable final long[] borderSize, // + @Nullable final OutOfBoundsFactory> obfInput // + ) { + if (nsr == null) nsr = DEFAULT_NSR; + return filter(input, kernel, outType, complexType, nsr, null, borderSize, + obfInput); + } +} + diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/features/haralick/DefaultAutocorrelation.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/features/haralick/DefaultAutocorrelation.java new file mode 100644 index 000000000..c88985ba8 --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/features/haralick/DefaultAutocorrelation.java @@ -0,0 +1,86 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.features.haralick; + +import org.scijava.ops.image.image.cooccurrenceMatrix.MatrixOrientation; +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.type.numeric.RealType; +import net.imglib2.type.numeric.real.DoubleType; + +/** + * Implementation of the autocorrelation (IBSI QWB0) haralick feature: {@code sum_ij i j p_ij}, where the gray levels {@code i, j} are counted + * from 1 as in IBSI (the co-occurrence matrix rows are 0-based). + *

+ * Implemented from the feature definition in the IBSI reference manual. The + * same feature is also computed by {@code GLCMFeatures.java} of RadiomicsJ + * (https://github.com/tatsunidas/RadiomicsJ) by Tatsuaki Kobayashi; this + * implementation was not derived from that code. + *

+ *

+ * Reference: Zwanenburg A, Vallieres M, et al. The Image Biomarker + * Standardization Initiative: Standardized Quantitative Radiomics for + * High-Throughput Image-based Phenotyping. Radiology. 2020;295(2):328-338. + * doi:10.1148/radiol.2020191145. + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @implNote op names='features.haralick.autocorrelation' + */ +public class DefaultAutocorrelation> extends + AbstractHaralickFeature +{ + + /** + * Computes the autocorrelation of the co-occurrence matrix. + * + * @param input the input image + * @param numGreyLevels the number of gray levels of the co-occurrence matrix + * @param distance the distance between co-occurring pixels + * @param orientation the orientation of co-occurring pixels + * @return the autocorrelation + */ + @Override + public DoubleType apply(final RandomAccessibleInterval input, + final Integer numGreyLevels, final Integer distance, + final MatrixOrientation orientation) + { + final var matrix = getCooccurrenceMatrix(input, numGreyLevels, + distance, orientation); + double res = 0; + for (var i = 0; i < matrix.length; i++) { + for (var j = 0; j < matrix.length; j++) { + res += (i + 1) * (j + 1) * matrix[i][j]; + } + } + return new DoubleType(res); + } + +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/features/haralick/DefaultClusterTendency.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/features/haralick/DefaultClusterTendency.java new file mode 100644 index 000000000..edfd76f2f --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/features/haralick/DefaultClusterTendency.java @@ -0,0 +1,97 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.features.haralick; + +import java.util.function.Function; + +import org.scijava.ops.image.image.cooccurrenceMatrix.MatrixOrientation; +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.type.numeric.RealType; +import net.imglib2.type.numeric.real.DoubleType; + +import org.scijava.ops.spi.OpDependency; + +/** + * Implementation of the cluster tendency (IBSI DG8W) haralick feature: {@code sum_ij (i + j - mu_x - mu_y)^2 p_ij}. + *

+ * Implemented from the feature definition in the IBSI reference manual. The + * same feature is also computed by {@code GLCMFeatures.java} of RadiomicsJ + * (https://github.com/tatsunidas/RadiomicsJ) by Tatsuaki Kobayashi; this + * implementation was not derived from that code. + *

+ *

+ * Reference: Zwanenburg A, Vallieres M, et al. The Image Biomarker + * Standardization Initiative: Standardized Quantitative Radiomics for + * High-Throughput Image-based Phenotyping. Radiology. 2020;295(2):328-338. + * doi:10.1148/radiol.2020191145. + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @implNote op names='features.haralick.clusterTendency' + */ +public class DefaultClusterTendency> extends + AbstractHaralickFeature +{ + + @OpDependency(name = "features.haralick.coocMeanX") + private Function coocMeanXFunc; + + @OpDependency(name = "features.haralick.coocMeanY") + private Function coocMeanYFunc; + + /** + * Computes the cluster tendency of the co-occurrence matrix. + * + * @param input the input image + * @param numGreyLevels the number of gray levels of the co-occurrence matrix + * @param distance the distance between co-occurring pixels + * @param orientation the orientation of co-occurring pixels + * @return the cluster tendency + */ + @Override + public DoubleType apply(final RandomAccessibleInterval input, + final Integer numGreyLevels, final Integer distance, + final MatrixOrientation orientation) + { + final var matrix = getCooccurrenceMatrix(input, numGreyLevels, + distance, orientation); + final var mux = coocMeanXFunc.apply(matrix).getRealDouble(); + final var muy = coocMeanYFunc.apply(matrix).getRealDouble(); + double res = 0; + for (var i = 0; i < matrix.length; i++) { + for (var j = 0; j < matrix.length; j++) { + res += Math.pow(i + j - mux - muy, 2) * matrix[i][j]; + } + } + return new DoubleType(res); + } + +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/features/haralick/DefaultDissimilarity.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/features/haralick/DefaultDissimilarity.java new file mode 100644 index 000000000..38ca5e332 --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/features/haralick/DefaultDissimilarity.java @@ -0,0 +1,85 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.features.haralick; + +import org.scijava.ops.image.image.cooccurrenceMatrix.MatrixOrientation; +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.type.numeric.RealType; +import net.imglib2.type.numeric.real.DoubleType; + +/** + * Implementation of the dissimilarity (IBSI 8S9J) haralick feature: {@code sum_ij |i - j| p_ij}, which equals the IBSI difference average. + *

+ * Implemented from the feature definition in the IBSI reference manual. The + * same feature is also computed by {@code GLCMFeatures.java} of RadiomicsJ + * (https://github.com/tatsunidas/RadiomicsJ) by Tatsuaki Kobayashi; this + * implementation was not derived from that code. + *

+ *

+ * Reference: Zwanenburg A, Vallieres M, et al. The Image Biomarker + * Standardization Initiative: Standardized Quantitative Radiomics for + * High-Throughput Image-based Phenotyping. Radiology. 2020;295(2):328-338. + * doi:10.1148/radiol.2020191145. + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @implNote op names='features.haralick.dissimilarity' + */ +public class DefaultDissimilarity> extends + AbstractHaralickFeature +{ + + /** + * Computes the dissimilarity of the co-occurrence matrix. + * + * @param input the input image + * @param numGreyLevels the number of gray levels of the co-occurrence matrix + * @param distance the distance between co-occurring pixels + * @param orientation the orientation of co-occurring pixels + * @return the dissimilarity + */ + @Override + public DoubleType apply(final RandomAccessibleInterval input, + final Integer numGreyLevels, final Integer distance, + final MatrixOrientation orientation) + { + final var matrix = getCooccurrenceMatrix(input, numGreyLevels, + distance, orientation); + double res = 0; + for (var i = 0; i < matrix.length; i++) { + for (var j = 0; j < matrix.length; j++) { + res += Math.abs(i - j) * matrix[i][j]; + } + } + return new DoubleType(res); + } + +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/features/haralick/DefaultInverseDifferenceMomentNormalized.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/features/haralick/DefaultInverseDifferenceMomentNormalized.java new file mode 100644 index 000000000..7a6ceca5f --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/features/haralick/DefaultInverseDifferenceMomentNormalized.java @@ -0,0 +1,86 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.features.haralick; + +import org.scijava.ops.image.image.cooccurrenceMatrix.MatrixOrientation; +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.type.numeric.RealType; +import net.imglib2.type.numeric.real.DoubleType; + +/** + * Implementation of the normalized inverse difference moment (IBSI 1QCO) haralick feature: {@code sum_ij p_ij / (1 + (i - j)^2 / N_g^2)}. + *

+ * Implemented from the feature definition in the IBSI reference manual. The + * same feature is also computed by {@code GLCMFeatures.java} of RadiomicsJ + * (https://github.com/tatsunidas/RadiomicsJ) by Tatsuaki Kobayashi; this + * implementation was not derived from that code. + *

+ *

+ * Reference: Zwanenburg A, Vallieres M, et al. The Image Biomarker + * Standardization Initiative: Standardized Quantitative Radiomics for + * High-Throughput Image-based Phenotyping. Radiology. 2020;295(2):328-338. + * doi:10.1148/radiol.2020191145. + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @implNote op names='features.haralick.inverseDifferenceMomentNormalized' + */ +public class DefaultInverseDifferenceMomentNormalized> extends + AbstractHaralickFeature +{ + + /** + * Computes the normalized inverse difference moment of the co-occurrence matrix. + * + * @param input the input image + * @param numGreyLevels the number of gray levels of the co-occurrence matrix + * @param distance the distance between co-occurring pixels + * @param orientation the orientation of co-occurring pixels + * @return the normalized inverse difference moment + */ + @Override + public DoubleType apply(final RandomAccessibleInterval input, + final Integer numGreyLevels, final Integer distance, + final MatrixOrientation orientation) + { + final var matrix = getCooccurrenceMatrix(input, numGreyLevels, + distance, orientation); + final double ng = matrix.length; + double res = 0; + for (var i = 0; i < matrix.length; i++) { + for (var j = 0; j < matrix.length; j++) { + res += matrix[i][j] / (1 + (i - j) * (i - j) / (ng * ng)); + } + } + return new DoubleType(res); + } + +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/features/haralick/DefaultInverseDifferenceNormalized.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/features/haralick/DefaultInverseDifferenceNormalized.java new file mode 100644 index 000000000..dccc4337c --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/features/haralick/DefaultInverseDifferenceNormalized.java @@ -0,0 +1,86 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.features.haralick; + +import org.scijava.ops.image.image.cooccurrenceMatrix.MatrixOrientation; +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.type.numeric.RealType; +import net.imglib2.type.numeric.real.DoubleType; + +/** + * Implementation of the normalized inverse difference (IBSI NDRX) haralick feature: {@code sum_ij p_ij / (1 + |i - j| / N_g)}. + *

+ * Implemented from the feature definition in the IBSI reference manual. The + * same feature is also computed by {@code GLCMFeatures.java} of RadiomicsJ + * (https://github.com/tatsunidas/RadiomicsJ) by Tatsuaki Kobayashi; this + * implementation was not derived from that code. + *

+ *

+ * Reference: Zwanenburg A, Vallieres M, et al. The Image Biomarker + * Standardization Initiative: Standardized Quantitative Radiomics for + * High-Throughput Image-based Phenotyping. Radiology. 2020;295(2):328-338. + * doi:10.1148/radiol.2020191145. + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @implNote op names='features.haralick.inverseDifferenceNormalized' + */ +public class DefaultInverseDifferenceNormalized> extends + AbstractHaralickFeature +{ + + /** + * Computes the normalized inverse difference of the co-occurrence matrix. + * + * @param input the input image + * @param numGreyLevels the number of gray levels of the co-occurrence matrix + * @param distance the distance between co-occurring pixels + * @param orientation the orientation of co-occurring pixels + * @return the normalized inverse difference + */ + @Override + public DoubleType apply(final RandomAccessibleInterval input, + final Integer numGreyLevels, final Integer distance, + final MatrixOrientation orientation) + { + final var matrix = getCooccurrenceMatrix(input, numGreyLevels, + distance, orientation); + final double ng = matrix.length; + double res = 0; + for (var i = 0; i < matrix.length; i++) { + for (var j = 0; j < matrix.length; j++) { + res += matrix[i][j] / (1 + Math.abs(i - j) / ng); + } + } + return new DoubleType(res); + } + +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/features/haralick/DefaultInverseVariance.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/features/haralick/DefaultInverseVariance.java new file mode 100644 index 000000000..2b72bf262 --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/features/haralick/DefaultInverseVariance.java @@ -0,0 +1,85 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.features.haralick; + +import org.scijava.ops.image.image.cooccurrenceMatrix.MatrixOrientation; +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.type.numeric.RealType; +import net.imglib2.type.numeric.real.DoubleType; + +/** + * Implementation of the inverse variance (IBSI E8JP) haralick feature: {@code sum_(i != j) p_ij / (i - j)^2}. + *

+ * Implemented from the feature definition in the IBSI reference manual. The + * same feature is also computed by {@code GLCMFeatures.java} of RadiomicsJ + * (https://github.com/tatsunidas/RadiomicsJ) by Tatsuaki Kobayashi; this + * implementation was not derived from that code. + *

+ *

+ * Reference: Zwanenburg A, Vallieres M, et al. The Image Biomarker + * Standardization Initiative: Standardized Quantitative Radiomics for + * High-Throughput Image-based Phenotyping. Radiology. 2020;295(2):328-338. + * doi:10.1148/radiol.2020191145. + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @implNote op names='features.haralick.inverseVariance' + */ +public class DefaultInverseVariance> extends + AbstractHaralickFeature +{ + + /** + * Computes the inverse variance of the co-occurrence matrix. + * + * @param input the input image + * @param numGreyLevels the number of gray levels of the co-occurrence matrix + * @param distance the distance between co-occurring pixels + * @param orientation the orientation of co-occurring pixels + * @return the inverse variance + */ + @Override + public DoubleType apply(final RandomAccessibleInterval input, + final Integer numGreyLevels, final Integer distance, + final MatrixOrientation orientation) + { + final var matrix = getCooccurrenceMatrix(input, numGreyLevels, + distance, orientation); + double res = 0; + for (var i = 0; i < matrix.length; i++) { + for (var j = 0; j < matrix.length; j++) { + if (i != j) res += matrix[i][j] / ((i - j) * (i - j)); + } + } + return new DoubleType(res); + } + +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/features/haralick/DefaultJointAverage.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/features/haralick/DefaultJointAverage.java new file mode 100644 index 000000000..51b60da65 --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/features/haralick/DefaultJointAverage.java @@ -0,0 +1,88 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.features.haralick; + +import org.scijava.ops.image.image.cooccurrenceMatrix.MatrixOrientation; +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.type.numeric.RealType; +import net.imglib2.type.numeric.real.DoubleType; + +/** + * Implementation of the joint average (IBSI 60VM) haralick feature: {@code sum_ij i p_ij}, where the gray levels {@code i} are counted from 1 + * as in IBSI (the co-occurrence matrix rows are 0-based). It therefore equals + * {@code features.haralick.coocMeanX} plus 1, since that feature counts the + * gray levels from 0. + *

+ * Implemented from the feature definition in the IBSI reference manual. The + * same feature is also computed by {@code GLCMFeatures.java} of RadiomicsJ + * (https://github.com/tatsunidas/RadiomicsJ) by Tatsuaki Kobayashi; this + * implementation was not derived from that code. + *

+ *

+ * Reference: Zwanenburg A, Vallieres M, et al. The Image Biomarker + * Standardization Initiative: Standardized Quantitative Radiomics for + * High-Throughput Image-based Phenotyping. Radiology. 2020;295(2):328-338. + * doi:10.1148/radiol.2020191145. + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @implNote op names='features.haralick.jointAverage' + */ +public class DefaultJointAverage> extends + AbstractHaralickFeature +{ + + /** + * Computes the joint average of the co-occurrence matrix. + * + * @param input the input image + * @param numGreyLevels the number of gray levels of the co-occurrence matrix + * @param distance the distance between co-occurring pixels + * @param orientation the orientation of co-occurring pixels + * @return the joint average + */ + @Override + public DoubleType apply(final RandomAccessibleInterval input, + final Integer numGreyLevels, final Integer distance, + final MatrixOrientation orientation) + { + final var matrix = getCooccurrenceMatrix(input, numGreyLevels, + distance, orientation); + double res = 0; + for (var i = 0; i < matrix.length; i++) { + for (var j = 0; j < matrix.length; j++) { + res += (i + 1) * matrix[i][j]; + } + } + return new DoubleType(res); + } + +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/features/texture/MatrixFeatures.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/features/texture/MatrixFeatures.java new file mode 100644 index 000000000..19fc0fd6d --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/features/texture/MatrixFeatures.java @@ -0,0 +1,217 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.features.texture; + +/** + * Generic computations over a gray level texture matrix {@code m}, where + * {@code m[i - 1][j - 1]} counts the entities (runs, zones, neighborhoods) of + * gray level {@code i} with property value {@code j} (run length, zone size, + * zone distance, dependence count). These are the building blocks of the IBSI + * features of the GLRLM, GLSZM, GLDZM and NGLDM families. All functions return + * {@link Double#NaN} for an empty matrix. + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +public final class MatrixFeatures { + + private MatrixFeatures() { + // Prevent instantiation of utility class. + } + + /** + * @param m the matrix + * @return the sum of all matrix elements (the number of entities) + */ + public static double total(final double[][] m) { + var sum = 0.0; + for (final var row : m) + for (final var v : row) + sum += v; + return sum; + } + + /** + * Computes {@code 1/N sum_ij m_ij i^a j^b}. + * + * @param m the matrix + * @param a exponent of the gray level + * @param b exponent of the column value + * @return the emphasis + */ + public static double emphasis(final double[][] m, final int a, final int b) { + var sum = 0.0; + for (var i = 0; i < m.length; i++) { + final var wi = Math.pow(i + 1, a); + for (var j = 0; j < m[i].length; j++) { + if (m[i][j] == 0) continue; + sum += m[i][j] * wi * Math.pow(j + 1, b); + } + } + return sum / total(m); + } + + /** + * Computes the gray level non-uniformity {@code 1/N sum_i (sum_j m_ij)^2}. + * + * @param m the matrix + * @param normalized if true, divide by {@code N^2} instead of {@code N} + * @return the gray level non-uniformity + */ + public static double rowNonUniformity(final double[][] m, + final boolean normalized) + { + var sum = 0.0; + for (final var row : m) { + var r = 0.0; + for (final var v : row) + r += v; + sum += r * r; + } + final var n = total(m); + return sum / (normalized ? n * n : n); + } + + /** + * Computes the column non-uniformity {@code 1/N sum_j (sum_i m_ij)^2}. + * + * @param m the matrix + * @param normalized if true, divide by {@code N^2} instead of {@code N} + * @return the column (run length, zone size, ...) non-uniformity + */ + public static double columnNonUniformity(final double[][] m, + final boolean normalized) + { + final var cols = columnSums(m); + var sum = 0.0; + for (final var c : cols) + sum += c * c; + final var n = total(m); + return sum / (normalized ? n * n : n); + } + + /** + * Computes the variance of the gray level, with each matrix element + * weighted by its probability {@code m_ij / N}. + * + * @param m the matrix + * @return the gray level variance + */ + public static double rowVariance(final double[][] m) { + return variance(m, true); + } + + /** + * Computes the variance of the column value, with each matrix element + * weighted by its probability {@code m_ij / N}. + * + * @param m the matrix + * @return the column (run length, zone size, ...) variance + */ + public static double columnVariance(final double[][] m) { + return variance(m, false); + } + + /** + * Computes the entropy {@code -sum_ij p_ij log2 p_ij}. + * + * @param m the matrix + * @return the entropy + */ + public static double entropy(final double[][] m) { + final var n = total(m); + var sum = 0.0; + for (final var row : m) { + for (final var v : row) { + if (v == 0) continue; + final var p = v / n; + sum -= p * Math.log(p); + } + } + return n == 0 ? Double.NaN : sum / Math.log(2); + } + + /** + * Computes the energy {@code sum_ij p_ij^2}. + * + * @param m the matrix + * @return the energy + */ + public static double energy(final double[][] m) { + final var n = total(m); + var sum = 0.0; + for (final var row : m) + for (final var v : row) + sum += v * v; + return sum / (n * n); + } + + /** + * Computes {@code sum_ij j m_ij}, which is the number of voxels for run + * length and size zone matrices. + * + * @param m the matrix + * @return the column-weighted sum + */ + public static double columnWeightedSum(final double[][] m) { + var sum = 0.0; + for (final var row : m) + for (var j = 0; j < row.length; j++) + sum += (j + 1) * row[j]; + return sum; + } + + private static double[] columnSums(final double[][] m) { + var width = 0; + for (final var row : m) + width = Math.max(width, row.length); + final var cols = new double[width]; + for (final var row : m) + for (var j = 0; j < row.length; j++) + cols[j] += row[j]; + return cols; + } + + private static double variance(final double[][] m, final boolean rows) { + final var n = total(m); + var mu = 0.0; + for (var i = 0; i < m.length; i++) + for (var j = 0; j < m[i].length; j++) + mu += (rows ? i + 1 : j + 1) * m[i][j] / n; + var sum = 0.0; + for (var i = 0; i < m.length; i++) { + for (var j = 0; j < m[i].length; j++) { + final var dv = (rows ? i + 1 : j + 1) - mu; + sum += dv * dv * m[i][j] / n; + } + } + return sum; + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/features/texture/TextureUtils.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/features/texture/TextureUtils.java new file mode 100644 index 000000000..ffef93316 --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/features/texture/TextureUtils.java @@ -0,0 +1,327 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.features.texture; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import net.imglib2.Interval; +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.type.BooleanType; +import net.imglib2.type.numeric.RealType; +import net.imglib2.util.Intervals; +import net.imglib2.view.Views; + +/** + * Utility methods shared by the texture matrix Ops: gray level discretization + * of a region of interest, neighbor offsets, and connected zone labeling. + *

+ * All texture matrices of this package (except the NGTDM) are indexed as + * {@code matrix[i - 1][j - 1]}, where {@code i} is the (1-based) discretized + * gray level and {@code j} is the (1-based) run length, zone size, zone + * distance or dependence count, following the conventions of the Image + * Biomarker Standardisation Initiative (IBSI). + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +public final class TextureUtils { + + private TextureUtils() { + // Prevent instantiation of utility class. + } + + /** + * A discretized region of interest, stored as a flat array in which + * dimension 0 varies fastest. + */ + public static final class GrayLevels { + + /** Discretized gray level of each voxel; 0 for voxels outside the ROI. */ + public final int[] levels; + /** Dimensions of the flattened interval. */ + public final int[] dims; + /** Strides of each dimension in {@link #levels}. */ + public final int[] strides; + /** Number of gray levels (the maximum possible level). */ + public final int nLevels; + /** Number of voxels inside the ROI. */ + public final long count; + + GrayLevels(final int[] levels, final int[] dims, final int nLevels, + final long count) + { + this.levels = levels; + this.dims = dims; + this.nLevels = nLevels; + this.count = count; + strides = new int[dims.length]; + var s = 1; + for (var d = 0; d < dims.length; d++) { + strides[d] = s; + s *= dims[d]; + } + } + + /** @return the number of dimensions. */ + public int numDimensions() { + return dims.length; + } + + /** + * Gets the flat index of the voxel at {@code index + offset}, or -1 if + * that voxel lies outside the interval. + * + * @param index flat index of the reference voxel + * @param offset per-dimension offset + * @return the flat index of the offset voxel, or -1 + */ + public int offset(final int index, final int[] offset) { + var result = index; + for (var d = 0; d < dims.length; d++) { + if (offset[d] == 0) continue; + final var p = (index / strides[d]) % dims[d] + offset[d]; + if (p < 0 || p >= dims[d]) return -1; + result += offset[d] * strides[d]; + } + return result; + } + + /** + * Gets the gray level at the given flat index, or 0 if the index is -1. + * + * @param index flat index, or -1 + * @return the gray level (0 when outside the ROI) + */ + public int level(final int index) { + return index < 0 ? 0 : levels[index]; + } + } + + /** + * Discretizes the gray levels of a region of interest. + *

+ * If {@code nGrayLevels} is {@code null}, the input values are taken as the + * gray levels directly (rounded to the nearest integer); they must then be + * at least 1, and the number of gray levels is the maximum value inside the + * ROI. Otherwise, the ROI intensities are discretized into + * {@code nGrayLevels} bins of equal width spanning the ROI intensity range + * (the IBSI fixed bin number method). + *

+ * + * @param input the input image + * @param mask the ROI mask, or {@code null} to use the whole image. Only the + * intersection of the image and mask intervals is considered, + * which allows e.g. a {@code LabelRegion} to be used as the mask. + * @param nGrayLevels the number of gray levels, or {@code null} + * @return the discretized ROI + */ + public static , B extends BooleanType> GrayLevels + discretize(final RandomAccessibleInterval input, + final RandomAccessibleInterval mask, final Integer nGrayLevels) + { + if (nGrayLevels != null && nGrayLevels < 1) { + throw new IllegalArgumentException( + "Number of gray levels must be positive: " + nGrayLevels); + } + final Interval interval = mask == null ? input : Intervals.intersect( + input, mask); + if (Intervals.isEmpty(interval)) { + throw new IllegalArgumentException( + "Input and mask intervals do not overlap"); + } + final var dims = new int[interval.numDimensions()]; + long total = 1; + for (var d = 0; d < dims.length; d++) { + dims[d] = (int) interval.dimension(d); + total *= dims[d]; + } + if (total > Integer.MAX_VALUE) { + throw new IllegalArgumentException("Region of interest is too large"); + } + final var values = new double[(int) total]; + final var inRoi = new boolean[values.length]; + final var in = Views.flatIterable(Views.interval(input, interval)) + .cursor(); + final var m = mask == null ? null : Views.flatIterable(Views.interval( + mask, interval)).cursor(); + var min = Double.POSITIVE_INFINITY; + var max = Double.NEGATIVE_INFINITY; + long count = 0; + for (var i = 0; i < values.length; i++) { + final var v = in.next().getRealDouble(); + final var inside = m == null ? true : m.next().get(); + if (!inside || Double.isNaN(v)) continue; + values[i] = v; + inRoi[i] = true; + count++; + if (v < min) min = v; + if (v > max) max = v; + } + final var levels = new int[values.length]; + if (count == 0) return new GrayLevels(levels, dims, nGrayLevels == null + ? 1 : nGrayLevels, 0); + int nLevels; + if (nGrayLevels == null) { + nLevels = 0; + for (var i = 0; i < values.length; i++) { + if (!inRoi[i]) continue; + final var level = Math.round(values[i]); + if (level < 1 || level > Integer.MAX_VALUE) { + throw new IllegalArgumentException("Gray level " + values[i] + + " out of range; specify the number of gray levels to discretize"); + } + levels[i] = (int) level; + if (levels[i] > nLevels) nLevels = levels[i]; + } + } + else { + nLevels = nGrayLevels; + final var range = max - min; + for (var i = 0; i < values.length; i++) { + if (!inRoi[i]) continue; + if (range == 0) levels[i] = 1; + else levels[i] = Math.min(nLevels, (int) Math.floor(nLevels * + (values[i] - min) / range) + 1); + } + } + return new GrayLevels(levels, dims, nLevels, count); + } + + /** + * Gets the offsets of all {@code numDims}-dimensional neighbors within the + * given Chebyshev distance, excluding the zero offset. + * + * @param numDims the dimensionality + * @param distance the Chebyshev distance + * @return the list of offsets + */ + public static List neighborOffsets(final int numDims, + final int distance) + { + final List offsets = new ArrayList<>(); + final var o = new int[numDims]; + Arrays.fill(o, -distance); + while (true) { + var zero = true; + for (final var v : o) + if (v != 0) zero = false; + if (!zero) offsets.add(o.clone()); + var d = 0; + while (d < numDims && o[d] == distance) { + o[d] = -distance; + d++; + } + if (d == numDims) break; + o[d]++; + } + return offsets; + } + + /** + * Gets one of each pair of opposite unit directions of the + * {@code numDims}-dimensional grid, i.e. the (3n-1)/2 offsets in + * {-1, 0, 1}n whose last nonzero component is positive. For 2D + * these are the 4 directions 0, 45, 90 and 135 degrees; for 3D these are the + * 13 standard directions. + * + * @param numDims the dimensionality + * @return the list of directions + */ + public static List directions(final int numDims) { + final List dirs = new ArrayList<>(); + for (final var o : neighborOffsets(numDims, 1)) { + var d = numDims - 1; + while (o[d] == 0) + d--; + if (o[d] > 0) dirs.add(o); + } + return dirs; + } + + /** + * Labels the connected zones of equal gray level within the ROI, using full + * (8-connectivity in 2D, 26-connectivity in 3D) connectivity. + * + * @param g the discretized ROI + * @return the zone label of each voxel (1-based; 0 outside the ROI), with + * the number of zones stored as the last array element + */ + public static int[] labelZones(final GrayLevels g) { + final var labels = new int[g.levels.length + 1]; + final var offsets = neighborOffsets(g.numDimensions(), 1); + final var stack = new int[g.levels.length]; + var nZones = 0; + for (var start = 0; start < g.levels.length; start++) { + if (g.levels[start] == 0 || labels[start] != 0) continue; + final var level = g.levels[start]; + nZones++; + labels[start] = nZones; + var top = 0; + stack[top++] = start; + while (top > 0) { + final var p = stack[--top]; + for (final var o : offsets) { + final var q = g.offset(p, o); + if (q < 0 || labels[q] != 0 || g.levels[q] != level) continue; + labels[q] = nZones; + stack[top++] = q; + } + } + } + labels[g.levels.length] = nZones; + return labels; + } + + /** + * Removes trailing all-zero columns from a matrix, keeping at least one + * column. + * + * @param matrix the matrix to trim + * @return the trimmed matrix + */ + public static double[][] trimColumns(final double[][] matrix) { + var width = 1; + for (final var row : matrix) { + for (var j = row.length - 1; j >= width; j--) { + if (row[j] != 0) { + width = j + 1; + break; + } + } + } + final var result = new double[matrix.length][]; + for (var i = 0; i < matrix.length; i++) + result[i] = Arrays.copyOf(matrix[i], width); + return result; + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/features/texture/gldzm/DistanceZoneMatrix.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/features/texture/gldzm/DistanceZoneMatrix.java new file mode 100644 index 000000000..b63514107 --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/features/texture/gldzm/DistanceZoneMatrix.java @@ -0,0 +1,189 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.features.texture.gldzm; + +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.type.BooleanType; +import net.imglib2.type.logic.BitType; +import net.imglib2.type.numeric.RealType; + +import org.scijava.ops.image.features.texture.TextureUtils; + +/** + * Computes the gray level distance zone matrix (GLDZM) of an n-dimensional + * region of interest, as defined by the Image Biomarker Standardisation + * Initiative (IBSI). Zones are connected groups of voxels of the same gray + * level, using full connectivity (8-connectivity in 2D, 26-connectivity in + * 3D). The distance of a zone is the smallest distance of its voxels to the + * edge of the region of interest, where the distance is measured in steps + * between face-adjacent voxels (city block distance) and voxels on the edge + * of the ROI have distance 1. Element {@code [i - 1][j - 1]} of the result + * counts the zones of gray level {@code i} and distance {@code j}. Trailing + * columns without any zone are removed. The {@code features.gldzm.*} Ops + * compute IBSI features from this matrix. + *

+ * These Ops are adapted from {@code GLDZMFeatures.java} of RadiomicsJ + * (https://github.com/tatsunidas/RadiomicsJ) by Tatsuaki Kobayashi, used + * under the Apache License, Version 2.0. The code was modified: it was + * translated to n-dimensional ImgLib2 ops. The original license notice + * follows. + *

+ * + *
+ * Copyright 2022 Tatsuaki Kobayashi
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * 
+ *

+ * References: Zwanenburg A, Vallieres M, et al. The Image Biomarker + * Standardization Initiative: Standardized Quantitative Radiomics for + * High-Throughput Image-based Phenotyping. Radiology. 2020;295(2):328-338. + * doi:10.1148/radiol.2020191145. + * Thibault G, et al. Advanced statistical matrices for texture + * characterization: application to cell classification. IEEE Transactions on + * Biomedical Engineering. 2014;61(3):630-637. + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @author Tatsuaki Kobayashi + */ +public final class DistanceZoneMatrix { + + private DistanceZoneMatrix() { + // Prevent instantiation of utility class. + } + + /** + * Computes the gray level distance zone matrix, + * with default values for the omitted parameters. + * + * @param input the input image + * @return the distance zone matrix, indexed by gray level and zone distance + * @implNote op names='image.distanceZoneMatrix', type=Function + */ + public static > double[][] + distanceZoneMatrix(final RandomAccessibleInterval input) + { + return DistanceZoneMatrix. distanceZoneMatrix(input, null, null); + } + + /** + * Computes the gray level distance zone matrix, + * with default values for the omitted parameters. + * + * @param input the input image + * @param mask the region of interest (may be null for the whole image); only + * the intersection of the image and mask intervals is considered + * @return the distance zone matrix, indexed by gray level and zone distance + * @implNote op names='image.distanceZoneMatrix', type=Function + */ + public static , B extends BooleanType> double[][] + distanceZoneMatrix(final RandomAccessibleInterval input, + final RandomAccessibleInterval mask) + { + return distanceZoneMatrix(input, mask, null); + } + + /** + * Computes the gray level distance zone matrix. + * + * @param input the input image + * @param mask the region of interest (may be null for the whole image); only + * the intersection of the image and mask intervals is considered + * @param nGrayLevels the number of gray levels into which the ROI + * intensities are discretized (IBSI fixed bin number), or null to + * use the input values, which must then be positive integers + * @return the distance zone matrix, indexed by gray level and zone distance + * @implNote op names='image.distanceZoneMatrix', type=Function + */ + public static , B extends BooleanType> double[][] + distanceZoneMatrix(final RandomAccessibleInterval input, + final RandomAccessibleInterval mask, + final Integer nGrayLevels) + { + final var g = TextureUtils.discretize(input, mask, nGrayLevels); + final var n = g.numDimensions(); + final var faces = new int[2 * n][n]; + for (var d = 0; d < n; d++) { + faces[2 * d][d] = -1; + faces[2 * d + 1][d] = 1; + } + final var dist = new int[g.levels.length]; + final var queue = new int[g.levels.length]; + var head = 0; + var tail = 0; + for (var p = 0; p < g.levels.length; p++) { + if (g.levels[p] == 0) continue; + for (final var f : faces) { + if (g.level(g.offset(p, f)) == 0) { + dist[p] = 1; + queue[tail++] = p; + break; + } + } + } + while (head < tail) { + final var p = queue[head++]; + for (final var f : faces) { + final var q = g.offset(p, f); + if (q < 0 || g.levels[q] == 0 || dist[q] != 0) continue; + dist[q] = dist[p] + 1; + queue[tail++] = q; + } + } + final var labels = TextureUtils.labelZones(g); + final var nZones = labels[labels.length - 1]; + final var zoneDist = new int[nZones + 1]; + final var zoneLevels = new int[nZones + 1]; + var maxDist = 1; + for (var p = 0; p < g.levels.length; p++) { + final var z = labels[p]; + if (z == 0) continue; + zoneLevels[z] = g.levels[p]; + if (zoneDist[z] == 0 || dist[p] < zoneDist[z]) zoneDist[z] = dist[p]; + maxDist = Math.max(maxDist, dist[p]); + } + final var matrix = new double[g.nLevels][maxDist]; + for (var z = 1; z <= nZones; z++) + matrix[zoneLevels[z] - 1][zoneDist[z] - 1]++; + return TextureUtils.trimColumns(matrix); + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/features/texture/gldzm/GLDZMFeatures.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/features/texture/gldzm/GLDZMFeatures.java new file mode 100644 index 000000000..ff9542cdf --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/features/texture/gldzm/GLDZMFeatures.java @@ -0,0 +1,266 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.features.texture.gldzm; + +import net.imglib2.type.numeric.real.DoubleType; + +import org.scijava.ops.image.features.texture.MatrixFeatures; + +/** + * IBSI features of the gray level distance zone matrix (GLDZM). + * The features are computed from a matrix as returned by + * {@code image.distanceZoneMatrix}, in which element {@code [i - 1][j - 1]} + * counts the entries with gray level {@code i} and zone distance {@code j}. + * Each feature is documented with its IBSI identifier. Features of an empty + * matrix are NaN. + *

+ * These Ops are adapted from {@code GLDZMFeatures.java} of RadiomicsJ + * (https://github.com/tatsunidas/RadiomicsJ) by Tatsuaki Kobayashi, used + * under the Apache License, Version 2.0. The code was modified: it was + * translated to n-dimensional ImgLib2 ops. The original license notice + * follows. + *

+ * + *
+ * Copyright 2022 Tatsuaki Kobayashi
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * 
+ *

+ * References: Zwanenburg A, Vallieres M, et al. The Image Biomarker + * Standardization Initiative: Standardized Quantitative Radiomics for + * High-Throughput Image-based Phenotyping. Radiology. 2020;295(2):328-338. + * doi:10.1148/radiol.2020191145. + * Thibault G, et al. Advanced statistical matrices for texture + * characterization: application to cell classification. IEEE Transactions on + * Biomedical Engineering. 2014;61(3):630-637. + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @author Tatsuaki Kobayashi + */ +public final class GLDZMFeatures { + + private GLDZMFeatures() { + // Prevent instantiation of utility class. + } + + /** + * Computes the small distance emphasis (IBSI 0GBI). + * + * @param matrix the matrix, as computed by {@code image.distanceZoneMatrix} + * @return the small distance emphasis + * @implNote op names='features.gldzm.smallDistanceEmphasis', type=Function + */ + public static DoubleType smallDistanceEmphasis(final double[][] matrix) { + return new DoubleType(MatrixFeatures.emphasis(matrix, 0, -2)); + } + + /** + * Computes the large distance emphasis (IBSI MB4I). + * + * @param matrix the matrix, as computed by {@code image.distanceZoneMatrix} + * @return the large distance emphasis + * @implNote op names='features.gldzm.largeDistanceEmphasis', type=Function + */ + public static DoubleType largeDistanceEmphasis(final double[][] matrix) { + return new DoubleType(MatrixFeatures.emphasis(matrix, 0, 2)); + } + + /** + * Computes the low gray level zone emphasis (IBSI S1RA). + * + * @param matrix the matrix, as computed by {@code image.distanceZoneMatrix} + * @return the low gray level zone emphasis + * @implNote op names='features.gldzm.lowGrayLevelZoneEmphasis', type=Function + */ + public static DoubleType lowGrayLevelZoneEmphasis(final double[][] matrix) { + return new DoubleType(MatrixFeatures.emphasis(matrix, -2, 0)); + } + + /** + * Computes the high gray level zone emphasis (IBSI K26C). + * + * @param matrix the matrix, as computed by {@code image.distanceZoneMatrix} + * @return the high gray level zone emphasis + * @implNote op names='features.gldzm.highGrayLevelZoneEmphasis', type=Function + */ + public static DoubleType highGrayLevelZoneEmphasis(final double[][] matrix) { + return new DoubleType(MatrixFeatures.emphasis(matrix, 2, 0)); + } + + /** + * Computes the small distance low gray level emphasis (IBSI RUVG). + * + * @param matrix the matrix, as computed by {@code image.distanceZoneMatrix} + * @return the small distance low gray level emphasis + * @implNote op names='features.gldzm.smallDistanceLowGrayLevelEmphasis', type=Function + */ + public static DoubleType smallDistanceLowGrayLevelEmphasis(final double[][] matrix) { + return new DoubleType(MatrixFeatures.emphasis(matrix, -2, -2)); + } + + /** + * Computes the small distance high gray level emphasis (IBSI DKNJ). + * + * @param matrix the matrix, as computed by {@code image.distanceZoneMatrix} + * @return the small distance high gray level emphasis + * @implNote op names='features.gldzm.smallDistanceHighGrayLevelEmphasis', type=Function + */ + public static DoubleType smallDistanceHighGrayLevelEmphasis(final double[][] matrix) { + return new DoubleType(MatrixFeatures.emphasis(matrix, 2, -2)); + } + + /** + * Computes the large distance low gray level emphasis (IBSI A7WM). + * + * @param matrix the matrix, as computed by {@code image.distanceZoneMatrix} + * @return the large distance low gray level emphasis + * @implNote op names='features.gldzm.largeDistanceLowGrayLevelEmphasis', type=Function + */ + public static DoubleType largeDistanceLowGrayLevelEmphasis(final double[][] matrix) { + return new DoubleType(MatrixFeatures.emphasis(matrix, -2, 2)); + } + + /** + * Computes the large distance high gray level emphasis (IBSI KLTH). + * + * @param matrix the matrix, as computed by {@code image.distanceZoneMatrix} + * @return the large distance high gray level emphasis + * @implNote op names='features.gldzm.largeDistanceHighGrayLevelEmphasis', type=Function + */ + public static DoubleType largeDistanceHighGrayLevelEmphasis(final double[][] matrix) { + return new DoubleType(MatrixFeatures.emphasis(matrix, 2, 2)); + } + + /** + * Computes the gray level non-uniformity (IBSI VFT7). + * + * @param matrix the matrix, as computed by {@code image.distanceZoneMatrix} + * @return the gray level non-uniformity + * @implNote op names='features.gldzm.grayLevelNonUniformity', type=Function + */ + public static DoubleType grayLevelNonUniformity(final double[][] matrix) { + return new DoubleType(MatrixFeatures.rowNonUniformity(matrix, false)); + } + + /** + * Computes the normalized gray level non-uniformity (IBSI 7HP3). + * + * @param matrix the matrix, as computed by {@code image.distanceZoneMatrix} + * @return the normalized gray level non-uniformity + * @implNote op names='features.gldzm.grayLevelNonUniformityNormalized', type=Function + */ + public static DoubleType grayLevelNonUniformityNormalized(final double[][] matrix) { + return new DoubleType(MatrixFeatures.rowNonUniformity(matrix, true)); + } + + /** + * Computes the zone distance non-uniformity (IBSI V294). + * + * @param matrix the matrix, as computed by {@code image.distanceZoneMatrix} + * @return the zone distance non-uniformity + * @implNote op names='features.gldzm.zoneDistanceNonUniformity', type=Function + */ + public static DoubleType zoneDistanceNonUniformity(final double[][] matrix) { + return new DoubleType(MatrixFeatures.columnNonUniformity(matrix, false)); + } + + /** + * Computes the normalized zone distance non-uniformity (IBSI IATH). + * + * @param matrix the matrix, as computed by {@code image.distanceZoneMatrix} + * @return the normalized zone distance non-uniformity + * @implNote op names='features.gldzm.zoneDistanceNonUniformityNormalized', type=Function + */ + public static DoubleType zoneDistanceNonUniformityNormalized(final double[][] matrix) { + return new DoubleType(MatrixFeatures.columnNonUniformity(matrix, true)); + } + + /** + * Computes the zone percentage (IBSI VIWW). + * Unlike the other features, this requires the number of voxels in the + * region of interest, which the matrix does not record. + * + * @param matrix the matrix, as computed by {@code image.distanceZoneMatrix} + * @param nVoxels the number of voxels in the region of interest + * @return the zone percentage + * @implNote op names='features.gldzm.zonePercentage', type=Function + */ + public static DoubleType zonePercentage(final double[][] matrix, + final Long nVoxels) + { + return new DoubleType(MatrixFeatures.total(matrix) / nVoxels); + } + + /** + * Computes the gray level variance (IBSI QK93). + * + * @param matrix the matrix, as computed by {@code image.distanceZoneMatrix} + * @return the gray level variance + * @implNote op names='features.gldzm.grayLevelVariance', type=Function + */ + public static DoubleType grayLevelVariance(final double[][] matrix) { + return new DoubleType(MatrixFeatures.rowVariance(matrix)); + } + + /** + * Computes the zone distance variance (IBSI 7WT1). + * + * @param matrix the matrix, as computed by {@code image.distanceZoneMatrix} + * @return the zone distance variance + * @implNote op names='features.gldzm.zoneDistanceVariance', type=Function + */ + public static DoubleType zoneDistanceVariance(final double[][] matrix) { + return new DoubleType(MatrixFeatures.columnVariance(matrix)); + } + + /** + * Computes the zone distance entropy (IBSI GBDU). + * + * @param matrix the matrix, as computed by {@code image.distanceZoneMatrix} + * @return the zone distance entropy + * @implNote op names='features.gldzm.zoneDistanceEntropy', type=Function + */ + public static DoubleType zoneDistanceEntropy(final double[][] matrix) { + return new DoubleType(MatrixFeatures.entropy(matrix)); + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/features/texture/glrlm/GLRLMFeatures.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/features/texture/glrlm/GLRLMFeatures.java new file mode 100644 index 000000000..eb764133c --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/features/texture/glrlm/GLRLMFeatures.java @@ -0,0 +1,261 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.features.texture.glrlm; + +import net.imglib2.type.numeric.real.DoubleType; + +import org.scijava.ops.image.features.texture.MatrixFeatures; + +/** + * IBSI features of the gray level run length matrix (GLRLM). + * The features are computed from a matrix as returned by + * {@code image.runLengthMatrix}, in which element {@code [i - 1][j - 1]} + * counts the entries with gray level {@code i} and run length {@code j}. + * Each feature is documented with its IBSI identifier. Features of an empty + * matrix are NaN. + *

+ * These Ops are adapted from {@code GLRLMFeatures.java} of RadiomicsJ + * (https://github.com/tatsunidas/RadiomicsJ) by Tatsuaki Kobayashi, used + * under the Apache License, Version 2.0. The code was modified: it was + * translated to n-dimensional ImgLib2 ops. The original license notice + * follows. + *

+ * + *
+ * Copyright 2022 Tatsuaki Kobayashi
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * 
+ *

+ * References: Zwanenburg A, Vallieres M, et al. The Image Biomarker + * Standardization Initiative: Standardized Quantitative Radiomics for + * High-Throughput Image-based Phenotyping. Radiology. 2020;295(2):328-338. + * doi:10.1148/radiol.2020191145. + * Galloway MM. Texture analysis using gray level run lengths. + * Computer Graphics and Image Processing. 1975;4(2):172-179. + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @author Tatsuaki Kobayashi + */ +public final class GLRLMFeatures { + + private GLRLMFeatures() { + // Prevent instantiation of utility class. + } + + /** + * Computes the short runs emphasis (IBSI 22OV). + * + * @param matrix the matrix, as computed by {@code image.runLengthMatrix} + * @return the short runs emphasis + * @implNote op names='features.glrlm.shortRunsEmphasis', type=Function + */ + public static DoubleType shortRunsEmphasis(final double[][] matrix) { + return new DoubleType(MatrixFeatures.emphasis(matrix, 0, -2)); + } + + /** + * Computes the long runs emphasis (IBSI W4KF). + * + * @param matrix the matrix, as computed by {@code image.runLengthMatrix} + * @return the long runs emphasis + * @implNote op names='features.glrlm.longRunsEmphasis', type=Function + */ + public static DoubleType longRunsEmphasis(final double[][] matrix) { + return new DoubleType(MatrixFeatures.emphasis(matrix, 0, 2)); + } + + /** + * Computes the low gray level run emphasis (IBSI V3SW). + * + * @param matrix the matrix, as computed by {@code image.runLengthMatrix} + * @return the low gray level run emphasis + * @implNote op names='features.glrlm.lowGrayLevelRunEmphasis', type=Function + */ + public static DoubleType lowGrayLevelRunEmphasis(final double[][] matrix) { + return new DoubleType(MatrixFeatures.emphasis(matrix, -2, 0)); + } + + /** + * Computes the high gray level run emphasis (IBSI G3QZ). + * + * @param matrix the matrix, as computed by {@code image.runLengthMatrix} + * @return the high gray level run emphasis + * @implNote op names='features.glrlm.highGrayLevelRunEmphasis', type=Function + */ + public static DoubleType highGrayLevelRunEmphasis(final double[][] matrix) { + return new DoubleType(MatrixFeatures.emphasis(matrix, 2, 0)); + } + + /** + * Computes the short run low gray level emphasis (IBSI HTZT). + * + * @param matrix the matrix, as computed by {@code image.runLengthMatrix} + * @return the short run low gray level emphasis + * @implNote op names='features.glrlm.shortRunLowGrayLevelEmphasis', type=Function + */ + public static DoubleType shortRunLowGrayLevelEmphasis(final double[][] matrix) { + return new DoubleType(MatrixFeatures.emphasis(matrix, -2, -2)); + } + + /** + * Computes the short run high gray level emphasis (IBSI GD3A). + * + * @param matrix the matrix, as computed by {@code image.runLengthMatrix} + * @return the short run high gray level emphasis + * @implNote op names='features.glrlm.shortRunHighGrayLevelEmphasis', type=Function + */ + public static DoubleType shortRunHighGrayLevelEmphasis(final double[][] matrix) { + return new DoubleType(MatrixFeatures.emphasis(matrix, 2, -2)); + } + + /** + * Computes the long run low gray level emphasis (IBSI IVPO). + * + * @param matrix the matrix, as computed by {@code image.runLengthMatrix} + * @return the long run low gray level emphasis + * @implNote op names='features.glrlm.longRunLowGrayLevelEmphasis', type=Function + */ + public static DoubleType longRunLowGrayLevelEmphasis(final double[][] matrix) { + return new DoubleType(MatrixFeatures.emphasis(matrix, -2, 2)); + } + + /** + * Computes the long run high gray level emphasis (IBSI 3KUM). + * + * @param matrix the matrix, as computed by {@code image.runLengthMatrix} + * @return the long run high gray level emphasis + * @implNote op names='features.glrlm.longRunHighGrayLevelEmphasis', type=Function + */ + public static DoubleType longRunHighGrayLevelEmphasis(final double[][] matrix) { + return new DoubleType(MatrixFeatures.emphasis(matrix, 2, 2)); + } + + /** + * Computes the gray level non-uniformity (IBSI R5YN). + * + * @param matrix the matrix, as computed by {@code image.runLengthMatrix} + * @return the gray level non-uniformity + * @implNote op names='features.glrlm.grayLevelNonUniformity', type=Function + */ + public static DoubleType grayLevelNonUniformity(final double[][] matrix) { + return new DoubleType(MatrixFeatures.rowNonUniformity(matrix, false)); + } + + /** + * Computes the normalized gray level non-uniformity (IBSI OVBL). + * + * @param matrix the matrix, as computed by {@code image.runLengthMatrix} + * @return the normalized gray level non-uniformity + * @implNote op names='features.glrlm.grayLevelNonUniformityNormalized', type=Function + */ + public static DoubleType grayLevelNonUniformityNormalized(final double[][] matrix) { + return new DoubleType(MatrixFeatures.rowNonUniformity(matrix, true)); + } + + /** + * Computes the run length non-uniformity (IBSI W92Y). + * + * @param matrix the matrix, as computed by {@code image.runLengthMatrix} + * @return the run length non-uniformity + * @implNote op names='features.glrlm.runLengthNonUniformity', type=Function + */ + public static DoubleType runLengthNonUniformity(final double[][] matrix) { + return new DoubleType(MatrixFeatures.columnNonUniformity(matrix, false)); + } + + /** + * Computes the normalized run length non-uniformity (IBSI IC23). + * + * @param matrix the matrix, as computed by {@code image.runLengthMatrix} + * @return the normalized run length non-uniformity + * @implNote op names='features.glrlm.runLengthNonUniformityNormalized', type=Function + */ + public static DoubleType runLengthNonUniformityNormalized(final double[][] matrix) { + return new DoubleType(MatrixFeatures.columnNonUniformity(matrix, true)); + } + + /** + * Computes the run percentage (IBSI 9ZK5). + * + * @param matrix the matrix, as computed by {@code image.runLengthMatrix} + * @return the run percentage + * @implNote op names='features.glrlm.runPercentage', type=Function + */ + public static DoubleType runPercentage(final double[][] matrix) { + return new DoubleType(MatrixFeatures.total(matrix) / MatrixFeatures + .columnWeightedSum(matrix)); + } + + /** + * Computes the gray level variance (IBSI 8CE5). + * + * @param matrix the matrix, as computed by {@code image.runLengthMatrix} + * @return the gray level variance + * @implNote op names='features.glrlm.grayLevelVariance', type=Function + */ + public static DoubleType grayLevelVariance(final double[][] matrix) { + return new DoubleType(MatrixFeatures.rowVariance(matrix)); + } + + /** + * Computes the run length variance (IBSI SXLW). + * + * @param matrix the matrix, as computed by {@code image.runLengthMatrix} + * @return the run length variance + * @implNote op names='features.glrlm.runLengthVariance', type=Function + */ + public static DoubleType runLengthVariance(final double[][] matrix) { + return new DoubleType(MatrixFeatures.columnVariance(matrix)); + } + + /** + * Computes the run entropy (IBSI HJ9O). + * + * @param matrix the matrix, as computed by {@code image.runLengthMatrix} + * @return the run entropy + * @implNote op names='features.glrlm.runEntropy', type=Function + */ + public static DoubleType runEntropy(final double[][] matrix) { + return new DoubleType(MatrixFeatures.entropy(matrix)); + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/features/texture/glrlm/RunLengthMatrix.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/features/texture/glrlm/RunLengthMatrix.java new file mode 100644 index 000000000..c28c588b7 --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/features/texture/glrlm/RunLengthMatrix.java @@ -0,0 +1,200 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.features.texture.glrlm; + +import java.util.List; + +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.type.BooleanType; +import net.imglib2.type.logic.BitType; +import net.imglib2.type.numeric.RealType; + +import org.scijava.ops.image.features.texture.TextureUtils; + +/** + * Computes the gray level run length matrix (GLRLM) of an n-dimensional region + * of interest, as defined by the Image Biomarker Standardisation Initiative + * (IBSI). Element {@code [i - 1][j - 1]} of the result counts the runs of gray + * level {@code i} and length {@code j} along the requested direction (or, if + * no direction is given, along all (3n-1)/2 grid directions, with + * the per-direction matrices merged by summation). The matrix holds raw run + * counts; trailing columns without any run are removed. The + * {@code features.glrlm.*} Ops compute IBSI features from this matrix. + *

+ * These Ops are adapted from {@code GLRLMFeatures.java} of RadiomicsJ + * (https://github.com/tatsunidas/RadiomicsJ) by Tatsuaki Kobayashi, used + * under the Apache License, Version 2.0. The code was modified: it was + * translated to n-dimensional ImgLib2 ops. The original license notice + * follows. + *

+ * + *
+ * Copyright 2022 Tatsuaki Kobayashi
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * 
+ *

+ * References: Zwanenburg A, Vallieres M, et al. The Image Biomarker + * Standardization Initiative: Standardized Quantitative Radiomics for + * High-Throughput Image-based Phenotyping. Radiology. 2020;295(2):328-338. + * doi:10.1148/radiol.2020191145. Galloway MM. Texture analysis using gray + * level run lengths. Computer Graphics and Image Processing. 1975;4(2):172-179. + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @author Tatsuaki Kobayashi + */ +public final class RunLengthMatrix { + + private RunLengthMatrix() { + // Prevent instantiation of utility class. + } + + /** + * Computes the gray level run length matrix of a whole image, using its + * values as gray levels, merged over all grid directions. + * + * @param input the input image, whose values must be positive integers + * @return the run length matrix, indexed by gray level and run length + * @implNote op names='image.runLengthMatrix', type=Function + */ + public static > double[][] runLengthMatrix( + final RandomAccessibleInterval input) + { + return RunLengthMatrix. runLengthMatrix(input, null, null, + null); + } + + /** + * Computes the gray level run length matrix of a region of interest, using + * the image values as gray levels, merged over all grid directions. + * + * @param input the input image, whose values must be positive integers + * @param mask the region of interest; only the intersection of the image + * and mask intervals is considered + * @return the run length matrix, indexed by gray level and run length + * @implNote op names='image.runLengthMatrix', type=Function + */ + public static , B extends BooleanType> double[][] + runLengthMatrix(final RandomAccessibleInterval input, + final RandomAccessibleInterval mask) + { + return runLengthMatrix(input, mask, null, null); + } + + /** + * Computes the gray level run length matrix of a region of interest, merged + * over all grid directions. + * + * @param input the input image + * @param mask the region of interest (may be null for the whole image); only + * the intersection of the image and mask intervals is considered + * @param nGrayLevels the number of gray levels into which the ROI + * intensities are discretized (IBSI fixed bin number), or null to + * use the input values, which must then be positive integers + * @return the run length matrix, indexed by gray level and run length + * @implNote op names='image.runLengthMatrix', type=Function + */ + public static , B extends BooleanType> double[][] + runLengthMatrix(final RandomAccessibleInterval input, + final RandomAccessibleInterval mask, final Integer nGrayLevels) + { + return runLengthMatrix(input, mask, nGrayLevels, null); + } + + /** + * Computes the gray level run length matrix of a region of interest. + * + * @param input the input image + * @param mask the region of interest (may be null for the whole image); only + * the intersection of the image and mask intervals is considered + * @param nGrayLevels the number of gray levels into which the ROI + * intensities are discretized (IBSI fixed bin number), or null to + * use the input values, which must then be positive integers + * @param direction the run direction, e.g. [1, 0] for runs along the first + * axis, or null to merge the matrices of all grid directions + * @return the run length matrix, indexed by gray level and run length + * @implNote op names='image.runLengthMatrix', type=Function + */ + public static , B extends BooleanType> double[][] + runLengthMatrix(final RandomAccessibleInterval input, + final RandomAccessibleInterval mask, final Integer nGrayLevels, + final int[] direction) + { + final var g = TextureUtils.discretize(input, mask, nGrayLevels); + final var n = g.numDimensions(); + final var dirs = direction == null ? TextureUtils.directions(n) : List + .of(checkDirection(direction, n)); + var maxLength = 1; + for (final var d : g.dims) + maxLength = Math.max(maxLength, d); + final var matrix = new double[g.nLevels][maxLength]; + final var back = new int[n]; + for (final var dir : dirs) { + for (var d = 0; d < n; d++) + back[d] = -dir[d]; + for (var p = 0; p < g.levels.length; p++) { + final var level = g.levels[p]; + if (level == 0) continue; + // Note: only start counting at the first voxel of each run. + if (g.level(g.offset(p, back)) == level) continue; + var length = 1; + var q = g.offset(p, dir); + while (g.level(q) == level) { + length++; + q = g.offset(q, dir); + } + matrix[level - 1][length - 1]++; + } + } + return TextureUtils.trimColumns(matrix); + } + + private static int[] checkDirection(final int[] direction, final int n) { + if (direction.length != n) { + throw new IllegalArgumentException("Direction has " + + direction.length + " dimensions but the input has " + n); + } + for (final var v : direction) + if (v != 0) return direction; + throw new IllegalArgumentException("Direction must be nonzero"); + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/features/texture/glszm/GLSZMFeatures.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/features/texture/glszm/GLSZMFeatures.java new file mode 100644 index 000000000..4f0936717 --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/features/texture/glszm/GLSZMFeatures.java @@ -0,0 +1,262 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.features.texture.glszm; + +import net.imglib2.type.numeric.real.DoubleType; + +import org.scijava.ops.image.features.texture.MatrixFeatures; + +/** + * IBSI features of the gray level size zone matrix (GLSZM). + * The features are computed from a matrix as returned by + * {@code image.sizeZoneMatrix}, in which element {@code [i - 1][j - 1]} + * counts the entries with gray level {@code i} and zone size {@code j}. + * Each feature is documented with its IBSI identifier. Features of an empty + * matrix are NaN. + *

+ * These Ops are adapted from {@code GLSZMFeatures.java} of RadiomicsJ + * (https://github.com/tatsunidas/RadiomicsJ) by Tatsuaki Kobayashi, used + * under the Apache License, Version 2.0. The code was modified: it was + * translated to n-dimensional ImgLib2 ops. The original license notice + * follows. + *

+ * + *
+ * Copyright 2022 Tatsuaki Kobayashi
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * 
+ *

+ * References: Zwanenburg A, Vallieres M, et al. The Image Biomarker + * Standardization Initiative: Standardized Quantitative Radiomics for + * High-Throughput Image-based Phenotyping. Radiology. 2020;295(2):328-338. + * doi:10.1148/radiol.2020191145. + * Thibault G, et al. Texture indexes and gray level size zone matrix. + * Application to cell nuclei classification. Pattern Recognition and + * Information Processing. 2009:140-145. + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @author Tatsuaki Kobayashi + */ +public final class GLSZMFeatures { + + private GLSZMFeatures() { + // Prevent instantiation of utility class. + } + + /** + * Computes the small zone emphasis (IBSI 5QRC). + * + * @param matrix the matrix, as computed by {@code image.sizeZoneMatrix} + * @return the small zone emphasis + * @implNote op names='features.glszm.smallZoneEmphasis', type=Function + */ + public static DoubleType smallZoneEmphasis(final double[][] matrix) { + return new DoubleType(MatrixFeatures.emphasis(matrix, 0, -2)); + } + + /** + * Computes the large zone emphasis (IBSI 48P8). + * + * @param matrix the matrix, as computed by {@code image.sizeZoneMatrix} + * @return the large zone emphasis + * @implNote op names='features.glszm.largeZoneEmphasis', type=Function + */ + public static DoubleType largeZoneEmphasis(final double[][] matrix) { + return new DoubleType(MatrixFeatures.emphasis(matrix, 0, 2)); + } + + /** + * Computes the low gray level zone emphasis (IBSI XMSY). + * + * @param matrix the matrix, as computed by {@code image.sizeZoneMatrix} + * @return the low gray level zone emphasis + * @implNote op names='features.glszm.lowGrayLevelZoneEmphasis', type=Function + */ + public static DoubleType lowGrayLevelZoneEmphasis(final double[][] matrix) { + return new DoubleType(MatrixFeatures.emphasis(matrix, -2, 0)); + } + + /** + * Computes the high gray level zone emphasis (IBSI 5GN9). + * + * @param matrix the matrix, as computed by {@code image.sizeZoneMatrix} + * @return the high gray level zone emphasis + * @implNote op names='features.glszm.highGrayLevelZoneEmphasis', type=Function + */ + public static DoubleType highGrayLevelZoneEmphasis(final double[][] matrix) { + return new DoubleType(MatrixFeatures.emphasis(matrix, 2, 0)); + } + + /** + * Computes the small zone low gray level emphasis (IBSI 5RAI). + * + * @param matrix the matrix, as computed by {@code image.sizeZoneMatrix} + * @return the small zone low gray level emphasis + * @implNote op names='features.glszm.smallZoneLowGrayLevelEmphasis', type=Function + */ + public static DoubleType smallZoneLowGrayLevelEmphasis(final double[][] matrix) { + return new DoubleType(MatrixFeatures.emphasis(matrix, -2, -2)); + } + + /** + * Computes the small zone high gray level emphasis (IBSI HW1V). + * + * @param matrix the matrix, as computed by {@code image.sizeZoneMatrix} + * @return the small zone high gray level emphasis + * @implNote op names='features.glszm.smallZoneHighGrayLevelEmphasis', type=Function + */ + public static DoubleType smallZoneHighGrayLevelEmphasis(final double[][] matrix) { + return new DoubleType(MatrixFeatures.emphasis(matrix, 2, -2)); + } + + /** + * Computes the large zone low gray level emphasis (IBSI YH51). + * + * @param matrix the matrix, as computed by {@code image.sizeZoneMatrix} + * @return the large zone low gray level emphasis + * @implNote op names='features.glszm.largeZoneLowGrayLevelEmphasis', type=Function + */ + public static DoubleType largeZoneLowGrayLevelEmphasis(final double[][] matrix) { + return new DoubleType(MatrixFeatures.emphasis(matrix, -2, 2)); + } + + /** + * Computes the large zone high gray level emphasis (IBSI J17V). + * + * @param matrix the matrix, as computed by {@code image.sizeZoneMatrix} + * @return the large zone high gray level emphasis + * @implNote op names='features.glszm.largeZoneHighGrayLevelEmphasis', type=Function + */ + public static DoubleType largeZoneHighGrayLevelEmphasis(final double[][] matrix) { + return new DoubleType(MatrixFeatures.emphasis(matrix, 2, 2)); + } + + /** + * Computes the gray level non-uniformity (IBSI JNSA). + * + * @param matrix the matrix, as computed by {@code image.sizeZoneMatrix} + * @return the gray level non-uniformity + * @implNote op names='features.glszm.grayLevelNonUniformity', type=Function + */ + public static DoubleType grayLevelNonUniformity(final double[][] matrix) { + return new DoubleType(MatrixFeatures.rowNonUniformity(matrix, false)); + } + + /** + * Computes the normalized gray level non-uniformity (IBSI Y1RO). + * + * @param matrix the matrix, as computed by {@code image.sizeZoneMatrix} + * @return the normalized gray level non-uniformity + * @implNote op names='features.glszm.grayLevelNonUniformityNormalized', type=Function + */ + public static DoubleType grayLevelNonUniformityNormalized(final double[][] matrix) { + return new DoubleType(MatrixFeatures.rowNonUniformity(matrix, true)); + } + + /** + * Computes the zone size non-uniformity (IBSI 4JP3). + * + * @param matrix the matrix, as computed by {@code image.sizeZoneMatrix} + * @return the zone size non-uniformity + * @implNote op names='features.glszm.zoneSizeNonUniformity', type=Function + */ + public static DoubleType zoneSizeNonUniformity(final double[][] matrix) { + return new DoubleType(MatrixFeatures.columnNonUniformity(matrix, false)); + } + + /** + * Computes the normalized zone size non-uniformity (IBSI VB3A). + * + * @param matrix the matrix, as computed by {@code image.sizeZoneMatrix} + * @return the normalized zone size non-uniformity + * @implNote op names='features.glszm.zoneSizeNonUniformityNormalized', type=Function + */ + public static DoubleType zoneSizeNonUniformityNormalized(final double[][] matrix) { + return new DoubleType(MatrixFeatures.columnNonUniformity(matrix, true)); + } + + /** + * Computes the zone percentage (IBSI P30P). + * + * @param matrix the matrix, as computed by {@code image.sizeZoneMatrix} + * @return the zone percentage + * @implNote op names='features.glszm.zonePercentage', type=Function + */ + public static DoubleType zonePercentage(final double[][] matrix) { + return new DoubleType(MatrixFeatures.total(matrix) / MatrixFeatures + .columnWeightedSum(matrix)); + } + + /** + * Computes the gray level variance (IBSI BYLV). + * + * @param matrix the matrix, as computed by {@code image.sizeZoneMatrix} + * @return the gray level variance + * @implNote op names='features.glszm.grayLevelVariance', type=Function + */ + public static DoubleType grayLevelVariance(final double[][] matrix) { + return new DoubleType(MatrixFeatures.rowVariance(matrix)); + } + + /** + * Computes the zone size variance (IBSI 3NSA). + * + * @param matrix the matrix, as computed by {@code image.sizeZoneMatrix} + * @return the zone size variance + * @implNote op names='features.glszm.zoneSizeVariance', type=Function + */ + public static DoubleType zoneSizeVariance(final double[][] matrix) { + return new DoubleType(MatrixFeatures.columnVariance(matrix)); + } + + /** + * Computes the zone size entropy (IBSI GU8N). + * + * @param matrix the matrix, as computed by {@code image.sizeZoneMatrix} + * @return the zone size entropy + * @implNote op names='features.glszm.zoneSizeEntropy', type=Function + */ + public static DoubleType zoneSizeEntropy(final double[][] matrix) { + return new DoubleType(MatrixFeatures.entropy(matrix)); + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/features/texture/glszm/SizeZoneMatrix.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/features/texture/glszm/SizeZoneMatrix.java new file mode 100644 index 000000000..19c6b7650 --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/features/texture/glszm/SizeZoneMatrix.java @@ -0,0 +1,155 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.features.texture.glszm; + +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.type.BooleanType; +import net.imglib2.type.logic.BitType; +import net.imglib2.type.numeric.RealType; + +import org.scijava.ops.image.features.texture.TextureUtils; + +/** + * Computes the gray level size zone matrix (GLSZM) of an n-dimensional region + * of interest, as defined by the Image Biomarker Standardisation Initiative + * (IBSI). A zone is a connected group of voxels of the same gray level, using + * full connectivity (8-connectivity in 2D, 26-connectivity in 3D). Element + * {@code [i - 1][j - 1]} of the result counts the zones of gray level + * {@code i} and size {@code j} voxels. Trailing columns without any zone are + * removed. The {@code features.glszm.*} Ops compute IBSI features from this + * matrix. + *

+ * These Ops are adapted from {@code GLSZMFeatures.java} of RadiomicsJ + * (https://github.com/tatsunidas/RadiomicsJ) by Tatsuaki Kobayashi, used + * under the Apache License, Version 2.0. The code was modified: it was + * translated to n-dimensional ImgLib2 ops. The original license notice + * follows. + *

+ * + *
+ * Copyright 2022 Tatsuaki Kobayashi
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * 
+ *

+ * References: Zwanenburg A, Vallieres M, et al. The Image Biomarker + * Standardization Initiative: Standardized Quantitative Radiomics for + * High-Throughput Image-based Phenotyping. Radiology. 2020;295(2):328-338. + * doi:10.1148/radiol.2020191145. + * Thibault G, et al. Texture indexes and gray level size zone matrix. + * Application to cell nuclei classification. Pattern Recognition and + * Information Processing. 2009:140-145. + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @author Tatsuaki Kobayashi + */ +public final class SizeZoneMatrix { + + private SizeZoneMatrix() { + // Prevent instantiation of utility class. + } + + /** + * Computes the gray level size zone matrix, + * with default values for the omitted parameters. + * + * @param input the input image + * @return the size zone matrix, indexed by gray level and zone size + * @implNote op names='image.sizeZoneMatrix', type=Function + */ + public static > double[][] + sizeZoneMatrix(final RandomAccessibleInterval input) + { + return SizeZoneMatrix. sizeZoneMatrix(input, null, null); + } + + /** + * Computes the gray level size zone matrix, + * with default values for the omitted parameters. + * + * @param input the input image + * @param mask the region of interest (may be null for the whole image); only + * the intersection of the image and mask intervals is considered + * @return the size zone matrix, indexed by gray level and zone size + * @implNote op names='image.sizeZoneMatrix', type=Function + */ + public static , B extends BooleanType> double[][] + sizeZoneMatrix(final RandomAccessibleInterval input, + final RandomAccessibleInterval mask) + { + return sizeZoneMatrix(input, mask, null); + } + + /** + * Computes the gray level size zone matrix. + * + * @param input the input image + * @param mask the region of interest (may be null for the whole image); only + * the intersection of the image and mask intervals is considered + * @param nGrayLevels the number of gray levels into which the ROI + * intensities are discretized (IBSI fixed bin number), or null to + * use the input values, which must then be positive integers + * @return the size zone matrix, indexed by gray level and zone size + * @implNote op names='image.sizeZoneMatrix', type=Function + */ + public static , B extends BooleanType> double[][] + sizeZoneMatrix(final RandomAccessibleInterval input, + final RandomAccessibleInterval mask, + final Integer nGrayLevels) + { + final var g = TextureUtils.discretize(input, mask, nGrayLevels); + final var labels = TextureUtils.labelZones(g); + final var nZones = labels[labels.length - 1]; + final var sizes = new int[nZones + 1]; + final var zoneLevels = new int[nZones + 1]; + var maxSize = 1; + for (var p = 0; p < g.levels.length; p++) { + if (labels[p] == 0) continue; + zoneLevels[labels[p]] = g.levels[p]; + maxSize = Math.max(maxSize, ++sizes[labels[p]]); + } + final var matrix = new double[g.nLevels][maxSize]; + for (var z = 1; z <= nZones; z++) + matrix[zoneLevels[z] - 1][sizes[z] - 1]++; + return matrix; + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/features/texture/ngldm/NGLDMFeatures.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/features/texture/ngldm/NGLDMFeatures.java new file mode 100644 index 000000000..27fafcf55 --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/features/texture/ngldm/NGLDMFeatures.java @@ -0,0 +1,277 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.features.texture.ngldm; + +import net.imglib2.type.numeric.real.DoubleType; + +import org.scijava.ops.image.features.texture.MatrixFeatures; + +/** + * IBSI features of the neighboring gray level dependence matrix (NGLDM). + * The features are computed from a matrix as returned by + * {@code image.neighboringGrayLevelDependenceMatrix}, in which element {@code [i - 1][j - 1]} + * counts the entries with gray level {@code i} and dependence count {@code j}. + * Each feature is documented with its IBSI identifier. Features of an empty + * matrix are NaN. + *

+ * These Ops are adapted from {@code NGLDMFeatures.java} of RadiomicsJ + * (https://github.com/tatsunidas/RadiomicsJ) by Tatsuaki Kobayashi, used + * under the Apache License, Version 2.0. The code was modified: it was + * translated to n-dimensional ImgLib2 ops. The original license notice + * follows. + *

+ * + *
+ * Copyright 2022 Tatsuaki Kobayashi
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * 
+ *

+ * References: Zwanenburg A, Vallieres M, et al. The Image Biomarker + * Standardization Initiative: Standardized Quantitative Radiomics for + * High-Throughput Image-based Phenotyping. Radiology. 2020;295(2):328-338. + * doi:10.1148/radiol.2020191145. + * Sun C, Wee WG. Neighboring gray level dependence matrix for texture + * classification. Computer Vision, Graphics, and Image Processing. + * 1983;23(3):341-352. + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @author Tatsuaki Kobayashi + */ +public final class NGLDMFeatures { + + private NGLDMFeatures() { + // Prevent instantiation of utility class. + } + + /** + * Computes the low dependence emphasis (IBSI SODN). + * + * @param matrix the matrix, as computed by {@code image.neighboringGrayLevelDependenceMatrix} + * @return the low dependence emphasis + * @implNote op names='features.ngldm.lowDependenceEmphasis', type=Function + */ + public static DoubleType lowDependenceEmphasis(final double[][] matrix) { + return new DoubleType(MatrixFeatures.emphasis(matrix, 0, -2)); + } + + /** + * Computes the high dependence emphasis (IBSI IMOQ). + * + * @param matrix the matrix, as computed by {@code image.neighboringGrayLevelDependenceMatrix} + * @return the high dependence emphasis + * @implNote op names='features.ngldm.highDependenceEmphasis', type=Function + */ + public static DoubleType highDependenceEmphasis(final double[][] matrix) { + return new DoubleType(MatrixFeatures.emphasis(matrix, 0, 2)); + } + + /** + * Computes the low gray level count emphasis (IBSI TL9H). + * + * @param matrix the matrix, as computed by {@code image.neighboringGrayLevelDependenceMatrix} + * @return the low gray level count emphasis + * @implNote op names='features.ngldm.lowGrayLevelCountEmphasis', type=Function + */ + public static DoubleType lowGrayLevelCountEmphasis(final double[][] matrix) { + return new DoubleType(MatrixFeatures.emphasis(matrix, -2, 0)); + } + + /** + * Computes the high gray level count emphasis (IBSI OAE7). + * + * @param matrix the matrix, as computed by {@code image.neighboringGrayLevelDependenceMatrix} + * @return the high gray level count emphasis + * @implNote op names='features.ngldm.highGrayLevelCountEmphasis', type=Function + */ + public static DoubleType highGrayLevelCountEmphasis(final double[][] matrix) { + return new DoubleType(MatrixFeatures.emphasis(matrix, 2, 0)); + } + + /** + * Computes the low dependence low gray level emphasis (IBSI EQ3F). + * + * @param matrix the matrix, as computed by {@code image.neighboringGrayLevelDependenceMatrix} + * @return the low dependence low gray level emphasis + * @implNote op names='features.ngldm.lowDependenceLowGrayLevelEmphasis', type=Function + */ + public static DoubleType lowDependenceLowGrayLevelEmphasis(final double[][] matrix) { + return new DoubleType(MatrixFeatures.emphasis(matrix, -2, -2)); + } + + /** + * Computes the low dependence high gray level emphasis (IBSI JA6D). + * + * @param matrix the matrix, as computed by {@code image.neighboringGrayLevelDependenceMatrix} + * @return the low dependence high gray level emphasis + * @implNote op names='features.ngldm.lowDependenceHighGrayLevelEmphasis', type=Function + */ + public static DoubleType lowDependenceHighGrayLevelEmphasis(final double[][] matrix) { + return new DoubleType(MatrixFeatures.emphasis(matrix, 2, -2)); + } + + /** + * Computes the high dependence low gray level emphasis (IBSI NBZI). + * + * @param matrix the matrix, as computed by {@code image.neighboringGrayLevelDependenceMatrix} + * @return the high dependence low gray level emphasis + * @implNote op names='features.ngldm.highDependenceLowGrayLevelEmphasis', type=Function + */ + public static DoubleType highDependenceLowGrayLevelEmphasis(final double[][] matrix) { + return new DoubleType(MatrixFeatures.emphasis(matrix, -2, 2)); + } + + /** + * Computes the high dependence high gray level emphasis (IBSI 9QMG). + * + * @param matrix the matrix, as computed by {@code image.neighboringGrayLevelDependenceMatrix} + * @return the high dependence high gray level emphasis + * @implNote op names='features.ngldm.highDependenceHighGrayLevelEmphasis', type=Function + */ + public static DoubleType highDependenceHighGrayLevelEmphasis(final double[][] matrix) { + return new DoubleType(MatrixFeatures.emphasis(matrix, 2, 2)); + } + + /** + * Computes the gray level non-uniformity (IBSI FP8K). + * + * @param matrix the matrix, as computed by {@code image.neighboringGrayLevelDependenceMatrix} + * @return the gray level non-uniformity + * @implNote op names='features.ngldm.grayLevelNonUniformity', type=Function + */ + public static DoubleType grayLevelNonUniformity(final double[][] matrix) { + return new DoubleType(MatrixFeatures.rowNonUniformity(matrix, false)); + } + + /** + * Computes the normalized gray level non-uniformity (IBSI 5SPA). + * + * @param matrix the matrix, as computed by {@code image.neighboringGrayLevelDependenceMatrix} + * @return the normalized gray level non-uniformity + * @implNote op names='features.ngldm.grayLevelNonUniformityNormalized', type=Function + */ + public static DoubleType grayLevelNonUniformityNormalized(final double[][] matrix) { + return new DoubleType(MatrixFeatures.rowNonUniformity(matrix, true)); + } + + /** + * Computes the dependence count non-uniformity (IBSI Z87G). + * + * @param matrix the matrix, as computed by {@code image.neighboringGrayLevelDependenceMatrix} + * @return the dependence count non-uniformity + * @implNote op names='features.ngldm.dependenceCountNonUniformity', type=Function + */ + public static DoubleType dependenceCountNonUniformity(final double[][] matrix) { + return new DoubleType(MatrixFeatures.columnNonUniformity(matrix, false)); + } + + /** + * Computes the normalized dependence count non-uniformity (IBSI OKJI). + * + * @param matrix the matrix, as computed by {@code image.neighboringGrayLevelDependenceMatrix} + * @return the normalized dependence count non-uniformity + * @implNote op names='features.ngldm.dependenceCountNonUniformityNormalized', type=Function + */ + public static DoubleType dependenceCountNonUniformityNormalized(final double[][] matrix) { + return new DoubleType(MatrixFeatures.columnNonUniformity(matrix, true)); + } + + /** + * Computes the dependence count percentage (IBSI 6XV8). + * Since every voxel of the region of interest contributes one + * neighborhood to the matrix, this is 1 unless the matrix was subsampled. + * + * @param matrix the matrix, as computed by {@code image.neighboringGrayLevelDependenceMatrix} + * @param nVoxels the number of voxels in the region of interest + * @return the dependence count percentage + * @implNote op names='features.ngldm.dependenceCountPercentage', type=Function + */ + public static DoubleType dependenceCountPercentage(final double[][] matrix, + final Long nVoxels) + { + return new DoubleType(MatrixFeatures.total(matrix) / nVoxels); + } + + /** + * Computes the gray level variance (IBSI 1PFV). + * + * @param matrix the matrix, as computed by {@code image.neighboringGrayLevelDependenceMatrix} + * @return the gray level variance + * @implNote op names='features.ngldm.grayLevelVariance', type=Function + */ + public static DoubleType grayLevelVariance(final double[][] matrix) { + return new DoubleType(MatrixFeatures.rowVariance(matrix)); + } + + /** + * Computes the dependence count variance (IBSI DNX2). + * + * @param matrix the matrix, as computed by {@code image.neighboringGrayLevelDependenceMatrix} + * @return the dependence count variance + * @implNote op names='features.ngldm.dependenceCountVariance', type=Function + */ + public static DoubleType dependenceCountVariance(final double[][] matrix) { + return new DoubleType(MatrixFeatures.columnVariance(matrix)); + } + + /** + * Computes the dependence count entropy (IBSI FCBV). + * + * @param matrix the matrix, as computed by {@code image.neighboringGrayLevelDependenceMatrix} + * @return the dependence count entropy + * @implNote op names='features.ngldm.dependenceCountEntropy', type=Function + */ + public static DoubleType dependenceCountEntropy(final double[][] matrix) { + return new DoubleType(MatrixFeatures.entropy(matrix)); + } + + /** + * Computes the dependence count energy (IBSI CAS9). + * + * @param matrix the matrix, as computed by {@code image.neighboringGrayLevelDependenceMatrix} + * @return the dependence count energy + * @implNote op names='features.ngldm.dependenceCountEnergy', type=Function + */ + public static DoubleType dependenceCountEnergy(final double[][] matrix) { + return new DoubleType(MatrixFeatures.energy(matrix)); + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/features/texture/ngldm/NeighboringGrayLevelDependenceMatrix.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/features/texture/ngldm/NeighboringGrayLevelDependenceMatrix.java new file mode 100644 index 000000000..6b952e3f2 --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/features/texture/ngldm/NeighboringGrayLevelDependenceMatrix.java @@ -0,0 +1,208 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.features.texture.ngldm; + +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.type.BooleanType; +import net.imglib2.type.logic.BitType; +import net.imglib2.type.numeric.RealType; + +import org.scijava.ops.image.features.texture.TextureUtils; + +/** + * Computes the neighboring gray level dependence matrix (NGLDM) of an + * n-dimensional region of interest, as defined by the Image Biomarker + * Standardisation Initiative (IBSI). For each voxel of the ROI, the dependence + * count is the number of neighbors within the ROI (at Chebyshev distance up to + * {@code distance}) whose gray level differs from that of the voxel by at most + * the coarseness {@code alpha}. Element {@code [i - 1][j - 1]} of the result + * counts the voxels of gray level {@code i} with {@code j - 1} dependent + * neighbors. Trailing columns without any entry are removed. The + * {@code features.ngldm.*} Ops compute IBSI features from this matrix. + *

+ * These Ops are adapted from {@code NGLDMFeatures.java} of RadiomicsJ + * (https://github.com/tatsunidas/RadiomicsJ) by Tatsuaki Kobayashi, used + * under the Apache License, Version 2.0. The code was modified: it was + * translated to n-dimensional ImgLib2 ops. The original license notice + * follows. + *

+ * + *
+ * Copyright 2022 Tatsuaki Kobayashi
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * 
+ *

+ * References: Zwanenburg A, Vallieres M, et al. The Image Biomarker + * Standardization Initiative: Standardized Quantitative Radiomics for + * High-Throughput Image-based Phenotyping. Radiology. 2020;295(2):328-338. + * doi:10.1148/radiol.2020191145. + * Sun C, Wee WG. Neighboring gray level dependence matrix for texture + * classification. Computer Vision, Graphics, and Image Processing. + * 1983;23(3):341-352. + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @author Tatsuaki Kobayashi + */ +public final class NeighboringGrayLevelDependenceMatrix { + + private NeighboringGrayLevelDependenceMatrix() { + // Prevent instantiation of utility class. + } + + /** + * Computes the neighboring gray level dependence matrix, + * with default values for the omitted parameters. + * + * @param input the input image + * @return the dependence matrix, indexed by gray level and dependence count + 1 + * @implNote op names='image.neighboringGrayLevelDependenceMatrix', type=Function + */ + public static > double[][] + neighboringGrayLevelDependenceMatrix(final RandomAccessibleInterval input) + { + return NeighboringGrayLevelDependenceMatrix. neighboringGrayLevelDependenceMatrix(input, null, null, null, null); + } + + /** + * Computes the neighboring gray level dependence matrix, + * with default values for the omitted parameters. + * + * @param input the input image + * @param mask the region of interest (may be null for the whole image); only + * the intersection of the image and mask intervals is considered + * @return the dependence matrix, indexed by gray level and dependence count + 1 + * @implNote op names='image.neighboringGrayLevelDependenceMatrix', type=Function + */ + public static , B extends BooleanType> double[][] + neighboringGrayLevelDependenceMatrix(final RandomAccessibleInterval input, + final RandomAccessibleInterval mask) + { + return neighboringGrayLevelDependenceMatrix(input, mask, null, null, null); + } + + /** + * Computes the neighboring gray level dependence matrix, + * with default values for the omitted parameters. + * + * @param input the input image + * @param mask the region of interest (may be null for the whole image); only + * the intersection of the image and mask intervals is considered + * @param nGrayLevels the number of gray levels into which the ROI + * intensities are discretized (IBSI fixed bin number), or null to + * use the input values, which must then be positive integers + * @return the dependence matrix, indexed by gray level and dependence count + 1 + * @implNote op names='image.neighboringGrayLevelDependenceMatrix', type=Function + */ + public static , B extends BooleanType> double[][] + neighboringGrayLevelDependenceMatrix(final RandomAccessibleInterval input, + final RandomAccessibleInterval mask, + final Integer nGrayLevels) + { + return neighboringGrayLevelDependenceMatrix(input, mask, nGrayLevels, null, null); + } + + /** + * Computes the neighboring gray level dependence matrix, + * with default values for the omitted parameters. + * + * @param input the input image + * @param mask the region of interest (may be null for the whole image); only + * the intersection of the image and mask intervals is considered + * @param nGrayLevels the number of gray levels into which the ROI + * intensities are discretized (IBSI fixed bin number), or null to + * use the input values, which must then be positive integers + * @param distance the Chebyshev neighborhood distance (default 1) + * @return the dependence matrix, indexed by gray level and dependence count + 1 + * @implNote op names='image.neighboringGrayLevelDependenceMatrix', type=Function + */ + public static , B extends BooleanType> double[][] + neighboringGrayLevelDependenceMatrix(final RandomAccessibleInterval input, + final RandomAccessibleInterval mask, + final Integer nGrayLevels, + final Integer distance) + { + return neighboringGrayLevelDependenceMatrix(input, mask, nGrayLevels, distance, null); + } + + /** + * Computes the neighboring gray level dependence matrix. + * + * @param input the input image + * @param mask the region of interest (may be null for the whole image); only + * the intersection of the image and mask intervals is considered + * @param nGrayLevels the number of gray levels into which the ROI + * intensities are discretized (IBSI fixed bin number), or null to + * use the input values, which must then be positive integers + * @param distance the Chebyshev neighborhood distance (default 1) + * @param alpha the coarseness: the maximum gray level difference for + * a neighbor to be dependent (default 0) + * @return the dependence matrix, indexed by gray level and dependence count + 1 + * @implNote op names='image.neighboringGrayLevelDependenceMatrix', type=Function + */ + public static , B extends BooleanType> double[][] + neighboringGrayLevelDependenceMatrix(final RandomAccessibleInterval input, + final RandomAccessibleInterval mask, + final Integer nGrayLevels, + final Integer distance, + final Integer alpha) + { + final var g = TextureUtils.discretize(input, mask, nGrayLevels); + final var delta = distance == null ? 1 : distance; + final var a = alpha == null ? 0 : alpha; + if (delta < 1) throw new IllegalArgumentException( + "Distance must be positive: " + delta); + final var offsets = TextureUtils.neighborOffsets(g.numDimensions(), delta); + final var matrix = new double[g.nLevels][offsets.size() + 1]; + for (var p = 0; p < g.levels.length; p++) { + final var level = g.levels[p]; + if (level == 0) continue; + var count = 0; + for (final var o : offsets) { + final var q = g.level(g.offset(p, o)); + if (q != 0 && Math.abs(q - level) <= a) count++; + } + matrix[level - 1][count]++; + } + return TextureUtils.trimColumns(matrix); + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/features/texture/ngtdm/NGTDMFeatures.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/features/texture/ngtdm/NGTDMFeatures.java new file mode 100644 index 000000000..565d38690 --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/features/texture/ngtdm/NGTDMFeatures.java @@ -0,0 +1,210 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.features.texture.ngtdm; + +import net.imglib2.type.numeric.real.DoubleType; + +/** + * IBSI features of the neighborhood gray tone difference matrix (NGTDM). The + * features are computed from a matrix as returned by + * {@code image.neighborhoodGrayToneDifferenceMatrix}, whose row {@code i - 1} + * holds the values {@code [n_i, p_i, s_i]} of gray level {@code i}. Each + * feature is documented with its IBSI identifier. Sums over pairs of gray + * levels only include levels present in the region of interest + * ({@code p_i > 0}). + *

+ * These Ops are adapted from {@code NGTDMFeatures.java} of RadiomicsJ + * (https://github.com/tatsunidas/RadiomicsJ) by Tatsuaki Kobayashi, used + * under the Apache License, Version 2.0. The code was modified: it was + * translated to n-dimensional ImgLib2 ops. The original license notice + * follows. + *

+ * + *
+ * Copyright 2022 Tatsuaki Kobayashi
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * 
+ *

+ * References: Zwanenburg A, Vallieres M, et al. The Image Biomarker + * Standardization Initiative: Standardized Quantitative Radiomics for + * High-Throughput Image-based Phenotyping. Radiology. 2020;295(2):328-338. + * doi:10.1148/radiol.2020191145. + * Amadasun M, King R. Textural features corresponding to textural + * properties. IEEE Transactions on Systems, Man, and Cybernetics. + * 1989;19(5):1264-1274. + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @author Tatsuaki Kobayashi + */ +public final class NGTDMFeatures { + + private NGTDMFeatures() { + // Prevent instantiation of utility class. + } + + /** + * Computes the coarseness (IBSI QCDE). If the sum of {@code p_i s_i} is 0, + * the result is 1e6, as recommended by IBSI. + * + * @param matrix the matrix, as computed by + * {@code image.neighborhoodGrayToneDifferenceMatrix} + * @return the coarseness + * @implNote op names='features.ngtdm.coarseness', type=Function + */ + public static DoubleType coarseness(final double[][] matrix) { + var sum = 0.0; + for (final var row : matrix) + sum += row[1] * row[2]; + return new DoubleType(sum == 0 ? 1e6 : 1 / sum); + } + + /** + * Computes the contrast (IBSI 65HE). + * + * @param matrix the matrix, as computed by + * {@code image.neighborhoodGrayToneDifferenceMatrix} + * @return the contrast + * @implNote op names='features.ngtdm.contrast', type=Function + */ + public static DoubleType contrast(final double[][] matrix) { + final var ngp = presentLevels(matrix); + if (ngp < 2) return new DoubleType(0); + var pairs = 0.0; + var sumS = 0.0; + for (var i = 0; i < matrix.length; i++) { + sumS += matrix[i][2]; + for (var j = 0; j < matrix.length; j++) { + final var d = i - j; + pairs += matrix[i][1] * matrix[j][1] * d * d; + } + } + return new DoubleType(pairs / (ngp * (ngp - 1.0)) * sumS / voxels( + matrix)); + } + + /** + * Computes the busyness (IBSI NQ30). + * + * @param matrix the matrix, as computed by + * {@code image.neighborhoodGrayToneDifferenceMatrix} + * @return the busyness + * @implNote op names='features.ngtdm.busyness', type=Function + */ + public static DoubleType busyness(final double[][] matrix) { + var numerator = 0.0; + var denominator = 0.0; + for (var i = 0; i < matrix.length; i++) { + if (matrix[i][1] == 0) continue; + numerator += matrix[i][1] * matrix[i][2]; + for (var j = 0; j < matrix.length; j++) { + if (matrix[j][1] == 0) continue; + denominator += Math.abs((i + 1) * matrix[i][1] - (j + 1) * + matrix[j][1]); + } + } + return new DoubleType(denominator == 0 ? 0 : numerator / denominator); + } + + /** + * Computes the complexity (IBSI HDEZ). + * + * @param matrix the matrix, as computed by + * {@code image.neighborhoodGrayToneDifferenceMatrix} + * @return the complexity + * @implNote op names='features.ngtdm.complexity', type=Function + */ + public static DoubleType complexity(final double[][] matrix) { + var sum = 0.0; + for (var i = 0; i < matrix.length; i++) { + final var pi = matrix[i][1]; + if (pi == 0) continue; + for (var j = 0; j < matrix.length; j++) { + final var pj = matrix[j][1]; + if (pj == 0) continue; + sum += Math.abs(i - j) * (pi * matrix[i][2] + pj * matrix[j][2]) / + (pi + pj); + } + } + return new DoubleType(sum / voxels(matrix)); + } + + /** + * Computes the strength (IBSI 1X9X). If the sum of {@code s_i} is 0, the + * result is 0. + * + * @param matrix the matrix, as computed by + * {@code image.neighborhoodGrayToneDifferenceMatrix} + * @return the strength + * @implNote op names='features.ngtdm.strength', type=Function + */ + public static DoubleType strength(final double[][] matrix) { + var sum = 0.0; + var sumS = 0.0; + for (var i = 0; i < matrix.length; i++) { + sumS += matrix[i][2]; + final var pi = matrix[i][1]; + if (pi == 0) continue; + for (var j = 0; j < matrix.length; j++) { + final var pj = matrix[j][1]; + if (pj == 0) continue; + final var d = i - j; + sum += (pi + pj) * d * d; + } + } + return new DoubleType(sumS == 0 ? 0 : sum / sumS); + } + + private static int presentLevels(final double[][] matrix) { + var count = 0; + for (final var row : matrix) + if (row[1] > 0) count++; + return count; + } + + private static double voxels(final double[][] matrix) { + var sum = 0.0; + for (final var row : matrix) + sum += row[0]; + return sum; + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/features/texture/ngtdm/NeighborhoodGrayToneDifferenceMatrix.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/features/texture/ngtdm/NeighborhoodGrayToneDifferenceMatrix.java new file mode 100644 index 000000000..d813badba --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/features/texture/ngtdm/NeighborhoodGrayToneDifferenceMatrix.java @@ -0,0 +1,188 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.features.texture.ngtdm; + +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.type.BooleanType; +import net.imglib2.type.logic.BitType; +import net.imglib2.type.numeric.RealType; + +import org.scijava.ops.image.features.texture.TextureUtils; + +/** + * Computes the neighborhood gray tone difference matrix (NGTDM) of an + * n-dimensional region of interest, as defined by the Image Biomarker + * Standardisation Initiative (IBSI). Row {@code i - 1} of the result describes + * gray level {@code i} with three columns: {@code n_i}, the number of ROI + * voxels with gray level {@code i}; {@code p_i}, the fraction of ROI voxels + * with gray level {@code i}; and {@code s_i}, the sum over these voxels of the + * absolute difference between {@code i} and the mean gray level of their + * neighbors within the ROI (at Chebyshev distance up to {@code distance}). + * Voxels without any neighbor in the ROI do not contribute to {@code s_i}. The + * {@code features.ngtdm.*} Ops compute IBSI features from this matrix. + *

+ * These Ops are adapted from {@code NGTDMFeatures.java} of RadiomicsJ + * (https://github.com/tatsunidas/RadiomicsJ) by Tatsuaki Kobayashi, used + * under the Apache License, Version 2.0. The code was modified: it was + * translated to n-dimensional ImgLib2 ops. The original license notice + * follows. + *

+ * + *
+ * Copyright 2022 Tatsuaki Kobayashi
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * 
+ *

+ * References: Zwanenburg A, Vallieres M, et al. The Image Biomarker + * Standardization Initiative: Standardized Quantitative Radiomics for + * High-Throughput Image-based Phenotyping. Radiology. 2020;295(2):328-338. + * doi:10.1148/radiol.2020191145. + * Amadasun M, King R. Textural features corresponding to textural + * properties. IEEE Transactions on Systems, Man, and Cybernetics. + * 1989;19(5):1264-1274. + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @author Tatsuaki Kobayashi + */ +public final class NeighborhoodGrayToneDifferenceMatrix { + + private NeighborhoodGrayToneDifferenceMatrix() { + // Prevent instantiation of utility class. + } + + /** + * Computes the neighborhood gray tone difference matrix, + * with default values for the omitted parameters. + * + * @param input the input image + * @return the matrix of [n_i, p_i, s_i] values, indexed by gray level + * @implNote op names='image.neighborhoodGrayToneDifferenceMatrix', type=Function + */ + public static > double[][] + neighborhoodGrayToneDifferenceMatrix(final RandomAccessibleInterval input) + { + return NeighborhoodGrayToneDifferenceMatrix. neighborhoodGrayToneDifferenceMatrix(input, null, null, null); + } + + /** + * Computes the neighborhood gray tone difference matrix, + * with default values for the omitted parameters. + * + * @param input the input image + * @param mask the region of interest (may be null for the whole image); only + * the intersection of the image and mask intervals is considered + * @return the matrix of [n_i, p_i, s_i] values, indexed by gray level + * @implNote op names='image.neighborhoodGrayToneDifferenceMatrix', type=Function + */ + public static , B extends BooleanType> double[][] + neighborhoodGrayToneDifferenceMatrix(final RandomAccessibleInterval input, + final RandomAccessibleInterval mask) + { + return neighborhoodGrayToneDifferenceMatrix(input, mask, null, null); + } + + /** + * Computes the neighborhood gray tone difference matrix, + * with default values for the omitted parameters. + * + * @param input the input image + * @param mask the region of interest (may be null for the whole image); only + * the intersection of the image and mask intervals is considered + * @param nGrayLevels the number of gray levels into which the ROI + * intensities are discretized (IBSI fixed bin number), or null to + * use the input values, which must then be positive integers + * @return the matrix of [n_i, p_i, s_i] values, indexed by gray level + * @implNote op names='image.neighborhoodGrayToneDifferenceMatrix', type=Function + */ + public static , B extends BooleanType> double[][] + neighborhoodGrayToneDifferenceMatrix(final RandomAccessibleInterval input, + final RandomAccessibleInterval mask, + final Integer nGrayLevels) + { + return neighborhoodGrayToneDifferenceMatrix(input, mask, nGrayLevels, null); + } + + /** + * Computes the neighborhood gray tone difference matrix. + * + * @param input the input image + * @param mask the region of interest (may be null for the whole image); only + * the intersection of the image and mask intervals is considered + * @param nGrayLevels the number of gray levels into which the ROI + * intensities are discretized (IBSI fixed bin number), or null to + * use the input values, which must then be positive integers + * @param distance the Chebyshev neighborhood distance (default 1) + * @return the matrix of [n_i, p_i, s_i] values, indexed by gray level + * @implNote op names='image.neighborhoodGrayToneDifferenceMatrix', type=Function + */ + public static , B extends BooleanType> double[][] + neighborhoodGrayToneDifferenceMatrix(final RandomAccessibleInterval input, + final RandomAccessibleInterval mask, + final Integer nGrayLevels, + final Integer distance) + { + final var g = TextureUtils.discretize(input, mask, nGrayLevels); + final var delta = distance == null ? 1 : distance; + if (delta < 1) throw new IllegalArgumentException( + "Distance must be positive: " + delta); + final var offsets = TextureUtils.neighborOffsets(g.numDimensions(), delta); + final var matrix = new double[g.nLevels][3]; + for (var p = 0; p < g.levels.length; p++) { + final var level = g.levels[p]; + if (level == 0) continue; + var sum = 0.0; + var count = 0; + for (final var o : offsets) { + final var q = g.level(g.offset(p, o)); + if (q == 0) continue; + sum += q; + count++; + } + matrix[level - 1][0]++; + if (count > 0) matrix[level - 1][2] += Math.abs(level - sum / count); + } + for (final var row : matrix) + row[1] = row[0] / g.count; + return matrix; + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/features/twoPointCorrelation/TwoPointCorrelation.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/features/twoPointCorrelation/TwoPointCorrelation.java new file mode 100644 index 000000000..b90e0300d --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/features/twoPointCorrelation/TwoPointCorrelation.java @@ -0,0 +1,181 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.features.twoPointCorrelation; + +import java.util.function.BiFunction; + +import net.imglib2.Dimensions; +import net.imglib2.FinalDimensions; +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.algorithm.fft2.FFTMethods; +import net.imglib2.loops.LoopBuilder; +import net.imglib2.type.BooleanType; +import net.imglib2.type.numeric.complex.ComplexDoubleType; +import net.imglib2.type.numeric.real.DoubleType; +import net.imglib2.view.Views; + +import org.scijava.function.Computers; +import org.scijava.function.Functions; +import org.scijava.ops.spi.Nullable; +import org.scijava.ops.spi.OpDependency; + +/** + * Computes the radially averaged two-point correlation function + * {@code S2(r)} of a binary n-D image (Berryman & Blair 1986; Torquato + * 2002). + *

+ * {@code S2(r)} is the probability that two points a distance {@code r} + * apart both lie in the foreground phase. For a displacement vector + * {@code v}, the autocorrelation {@code C(v) = Σ f(x) f(x + v)} of the + * indicator image {@code f} counts the foreground pairs, and + * {@code N(v) = Π (d_i - |v_i|)} counts all pixel pairs with that + * displacement inside the image, so no periodicity is assumed. {@code C} is + * computed with the FFT on a zero-padded image. For each integer radius + * {@code r}, the result pools all displacements with {@code round(|v|) = r}: + * {@code S2(r) = Σ C(v) / Σ N(v)}. + *

+ *

+ * {@code S2(0)} is the volume fraction {@code φ} of the foreground, and for + * a statistically homogeneous medium without long-range order, + * {@code S2(r)} tends to {@code φ²} for large {@code r}. Distances are in + * pixels. + *

+ *

+ * Implemented from the papers. + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @param the binary pixel type + * @see J. G. Berryman and S. C. + * Blair, "Use of digital image analysis to estimate fluid permeability of + * porous materials: Application of two-point correlation functions", J. + * Appl. Phys. 60(6):1930-1938, 1986 + * @see S. Torquato, + * "Random Heterogeneous Materials", Springer, 2002 + * @implNote op names='features.twoPointCorrelation' + */ +public class TwoPointCorrelation> implements + BiFunction, Integer, double[]> +{ + + @OpDependency(name = "create.img") + private BiFunction> createImg; + + @OpDependency(name = "filter.createFFTOutput") + private Functions.Arity3> createFFTOutput; + + @OpDependency(name = "filter.fft") + private Computers.Arity1, RandomAccessibleInterval> fftOp; + + @OpDependency(name = "filter.ifft") + private Computers.Arity1, RandomAccessibleInterval> ifftOp; + + /** + * Computes {@code S2(r)}. + * + * @param input the binary image + * @param maxRadius the largest radius, in pixels (default: half the smallest + * image dimension) + * @return the values {@code S2(0), S2(1), ..., S2(maxRadius)}; radii without + * any displacement inside the image yield NaN + */ + @Override + public double[] apply(final RandomAccessibleInterval input, + @Nullable final Integer maxRadius) + { + final var image = Views.zeroMin(input); + final var n = image.numDimensions(); + final var dims = image.dimensionsAsLongArray(); + var smallest = Long.MAX_VALUE; + for (final var d : dims) + smallest = Math.min(smallest, d); + final int rMax = maxRadius == null ? (int) (smallest / 2) : maxRadius; + if (rMax < 0) { + throw new IllegalArgumentException("Maximum radius must be " + + "non-negative: " + rMax); + } + + // Zero-pad to at least 2d - 1 per dimension, so that the circular + // autocorrelation equals the linear one. + final var minPadded = new long[n]; + for (var d = 0; d < n; d++) + minPadded[d] = 2 * dims[d] - 1; + final var padded = new long[n]; + FFTMethods.dimensionsRealToComplexFast(new FinalDimensions(minPadded), + padded, new long[n]); + final var paddedDims = new FinalDimensions(padded); + final var f = createImg.apply(paddedDims, new DoubleType()); + LoopBuilder.setImages(image, Views.interval(f, image)).forEachPixel((in, + out) -> out.set(in.get() ? 1 : 0)); + final var fft = createFFTOutput.apply(paddedDims, new ComplexDoubleType(), + true); + fftOp.compute(f, fft); + for (final var c : Views.iterable(fft)) { + final var re = c.getRealDouble(); + final var im = c.getImaginaryDouble(); + c.set(re * re + im * im, 0); + } + final var autocorrelation = createImg.apply(paddedDims, new DoubleType()); + ifftOp.compute(fft, autocorrelation); + + final var pairs = new double[rMax + 1]; + final var total = new double[rMax + 1]; + final var cursor = Views.iterable(autocorrelation).localizingCursor(); + final var pos = new long[n]; + while (cursor.hasNext()) { + cursor.fwd(); + cursor.localize(pos); + var r2 = 0.0; + var overlap = 1.0; + for (var d = 0; d < n; d++) { + // Note: Index p holds displacement p, or p - padded for the upper + // half (negative displacements). + final var v = pos[d] < dims[d] ? pos[d] : pos[d] - padded[d]; + final var a = Math.abs(v); + if (a >= dims[d]) { + overlap = 0; + break; + } + overlap *= dims[d] - a; + r2 += (double) v * v; + } + if (overlap == 0) continue; + final var r = Math.round(Math.sqrt(r2)); + if (r > rMax) continue; + pairs[(int) r] += cursor.get().get(); + total[(int) r] += overlap; + } + final var s2 = new double[rMax + 1]; + for (var r = 0; r <= rMax; r++) + s2[r] = total[r] > 0 ? Math.max(0, pairs[r]) / total[r] : Double.NaN; + return s2; + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/filter/FlatImage.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/filter/FlatImage.java new file mode 100644 index 000000000..5460148e7 --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/filter/FlatImage.java @@ -0,0 +1,143 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.filter; + +import net.imglib2.Dimensions; +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.type.numeric.RealType; +import net.imglib2.util.Intervals; +import net.imglib2.view.Views; + +/** + * Utility methods for filters that operate on an image copied into a flat + * {@code double[]} array, in flat iteration order (dimension 0 fastest). + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +public final class FlatImage { + + private FlatImage() { + // Prevent instantiation of static utility class. + } + + /** + * Gets the number of array elements needed to store {@code perPixel} + * values for each pixel of an image. + * + * @param dims the image dimensions + * @param perPixel the number of values stored per pixel + * @return the number of elements + * @throws IllegalArgumentException if the elements do not fit in an array + */ + public static int size(final Dimensions dims, final int perPixel) { + final var size = Intervals.numElements(dims); + if (size * perPixel > Integer.MAX_VALUE) { + throw new IllegalArgumentException("Image is too large: " + size + + " pixels"); + } + return (int) size; + } + + /** + * Computes the array stride of each dimension. + * + * @param dims the image dimensions + * @return the strides, with {@code strides[0] == 1} + */ + public static int[] strides(final long[] dims) { + final var strides = new int[dims.length]; + for (var d = 0; d < dims.length; d++) + strides[d] = d == 0 ? 1 : strides[d - 1] * (int) dims[d - 1]; + return strides; + } + + /** + * Copies an image into a new array, in flat iteration order. + * + * @param image the image + * @return the pixel values + * @throws IllegalArgumentException if the image does not fit in an array + */ + public static double[] read( + final RandomAccessibleInterval> image) + { + final var values = new double[size(image, 1)]; + var i = 0; + for (final RealType t : Views.flatIterable(image)) + values[i++] = t.getRealDouble(); + return values; + } + + /** + * Copies an array into an image, in flat iteration order, clamping the + * values to the range of the image type. + * + * @param values the pixel values + * @param image the image to write into + */ + public static void write(final double[] values, + final RandomAccessibleInterval> image) + { + var i = 0; + for (final RealType t : Views.flatIterable(image)) { + t.setReal(Math.max(t.getMinValue(), Math.min(t.getMaxValue(), + values[i++]))); + } + } + + /** + * Advances a position by one pixel in flat iteration order, wrapping to + * zero after the last pixel. + * + * @param pos the position to advance + * @param dims the image dimensions + */ + public static void increment(final long[] pos, final long[] dims) { + for (var d = 0; d < pos.length; d++) { + if (++pos[d] < dims[d]) return; + pos[d] = 0; + } + } + + /** + * Advances a position by one pixel in flat iteration order, wrapping to + * zero after the last pixel. + * + * @param pos the position to advance + * @param dims the image dimensions + */ + public static void increment(final int[] pos, final long[] dims) { + for (var d = 0; d < pos.length; d++) { + if (++pos[d] < dims[d]) return; + pos[d] = 0; + } + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/filter/convolvedBackground/ConvolvedBackgroundSubtraction.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/filter/convolvedBackground/ConvolvedBackgroundSubtraction.java new file mode 100644 index 000000000..d05f76214 --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/filter/convolvedBackground/ConvolvedBackgroundSubtraction.java @@ -0,0 +1,199 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.filter.convolvedBackground; + +import java.util.Arrays; +import java.util.Locale; +import java.util.function.BiFunction; + +import net.imglib2.Dimensions; +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.algorithm.neighborhood.HyperSphereShape; +import net.imglib2.algorithm.neighborhood.Shape; +import net.imglib2.loops.LoopBuilder; +import net.imglib2.outofbounds.OutOfBoundsBorderFactory; +import net.imglib2.outofbounds.OutOfBoundsFactory; +import net.imglib2.type.numeric.RealType; +import net.imglib2.type.numeric.real.FloatType; + +import org.scijava.function.Computers; +import org.scijava.ops.spi.Nullable; +import org.scijava.ops.spi.OpDependency; + +/** + * Convolved background subtraction: estimates the background by smoothing the + * image with a large Gaussian, median or mean filter, and subtracts it. + *

+ * The algorithm is: + *

+ *
    + *
  1. {@code bg = smooth(in, radius)}, where {@code smooth} is a Gaussian blur + * with sigma {@code radius}, or a median or mean filter over a + * {@link HyperSphereShape} of radius {@code floor(radius)}.
  2. + *
  3. {@code bg = max(bg, floor(floor(radius / 10) * 1.5))}, a maximum filter + * (grayscale dilation) over a {@link HyperSphereShape} that counteracts the + * shrinking of background features caused by the smoothing; it is skipped + * when its radius is zero, i.e. for {@code radius < 10}.
  4. + *
  5. {@code out = in - bg}, clamped to the range of the output type.
  6. + *
+ *

+ * Out-of-bounds values are taken from the image border. The image may have any + * number of dimensions; all filters are applied in n-D. + *

+ *

+ * Translated from the {@code Convoluted_Background_Subtraction} plugin of the + * BioVoxxel Toolbox by Jan Brocher / BioVoxxel (BSD 3-Clause license, + * Copyright (C) Jan Brocher / BioVoxxel). The original uses ImageJ's 2D + * circular rank-filter kernels (which include offsets with + * {@code d² <= r² + 1}); this op uses n-D hyperspheres ({@code d² <= r²}) and + * computes the background in floating point. + *

+ *

+ * The original BioVoxxel Toolbox license notice follows. + *

+ * + *
+ * Copyright (c) 2015, BioVoxxel / Jan Brocher
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ *
+ * * Redistributions of source code must retain the above copyright notice, this
+ *   list of conditions and the following disclaimer.
+ *
+ * * 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.
+ *
+ * * Neither the name of BioVoxxel nor the names of its
+ *   contributors may be used to endorse or promote products derived from
+ *   this software without specific prior written permission.
+ *
+ * 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 HOLDER 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.
+ * 
+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @author Jan Brocher (BioVoxxel) + * @param input pixel type + * @param output pixel type + * @see BioVoxxel + * Toolbox + * @implNote op names='filter.convolvedBackgroundSubtraction' + */ +public class ConvolvedBackgroundSubtraction, V extends RealType> + implements + Computers.Arity3, Double, String, RandomAccessibleInterval> +{ + + @OpDependency(name = "create.img") + private BiFunction> createOp; + + @OpDependency(name = "filter.gauss") + private Computers.Arity3, double[], OutOfBoundsFactory>, RandomAccessibleInterval> gaussOp; + + @OpDependency(name = "filter.median") + private Computers.Arity3, Shape, OutOfBoundsFactory>, RandomAccessibleInterval> medianOp; + + @OpDependency(name = "filter.mean") + private Computers.Arity3, Shape, OutOfBoundsFactory>, RandomAccessibleInterval> meanOp; + + @OpDependency(name = "filter.max") + private Computers.Arity3, Shape, OutOfBoundsFactory>, RandomAccessibleInterval> maxOp; + + /** + * Subtracts the convolved background from the input. + * + * @param input the image to correct + * @param radius the radius of the smoothing filter (the sigma for the + * Gaussian filter), in pixels; it should be larger than the + * largest foreground object + * @param filter the smoothing filter: one of {@code "gaussian"}, + * {@code "median"} or {@code "mean"} (default {@code "gaussian"}) + * @param output the background-subtracted image + */ + @Override + public void compute(final RandomAccessibleInterval input, + final Double radius, @Nullable final String filter, + final RandomAccessibleInterval output) + { + if (radius < 0) { + throw new IllegalArgumentException("Radius must be non-negative"); + } + final var background = createOp.apply(input, new FloatType()); + final var method = filter == null ? "gaussian" : filter.toLowerCase( + Locale.ROOT); + final var rankShape = new HyperSphereShape((long) Math.floor(radius)); + switch (method) { + case "gaussian": + final var sigmas = new double[input.numDimensions()]; + Arrays.fill(sigmas, radius); + gaussOp.compute(input, sigmas, new OutOfBoundsBorderFactory<>(), + background); + break; + case "median": + medianOp.compute(input, rankShape, new OutOfBoundsBorderFactory<>(), + background); + break; + case "mean": + meanOp.compute(input, rankShape, new OutOfBoundsBorderFactory<>(), + background); + break; + default: + throw new IllegalArgumentException("Unknown filter: " + filter + + " (expected gaussian, median or mean)"); + } + + // Counteract the size reduction of background features. + final var maxRadius = (long) Math.floor(Math.floor(radius / 10) * 1.5); + final RandomAccessibleInterval bg; + if (maxRadius > 0) { + bg = createOp.apply(input, new FloatType()); + maxOp.compute(background, new HyperSphereShape(maxRadius), + new OutOfBoundsBorderFactory<>(), bg); + } + else bg = background; + + LoopBuilder.setImages(input, bg, output).forEachPixel((i, b, o) -> { + final var v = i.getRealDouble() - b.getRealDouble(); + o.setReal(Math.max(o.getMinValue(), Math.min(o.getMaxValue(), v))); + }); + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/filter/deriche/DericheFilters.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/filter/deriche/DericheFilters.java new file mode 100644 index 000000000..73437c2d7 --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/filter/deriche/DericheFilters.java @@ -0,0 +1,400 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.filter.deriche; + +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.img.Img; +import net.imglib2.img.array.ArrayImgs; +import net.imglib2.type.logic.BitType; +import net.imglib2.type.numeric.RealType; +import net.imglib2.type.numeric.real.DoubleType; + +import org.scijava.function.Functions; +import org.scijava.ops.image.filter.FlatImage; +import org.scijava.ops.spi.Nullable; +import org.scijava.ops.spi.OpDependency; + +/** + * Edge detection with the recursive filters of Deriche, in any number of + * dimensions. + *

+ * R. Deriche, "Using Canny's criteria to derive a recursively implemented + * optimal edge detector", International Journal of Computer Vision 1(2), + * 1987, derived from J. Canny's criteria ("A computational approach to edge + * detection", IEEE PAMI 8(6), 1986) the optimal derivative filter of infinite + * extent {@code f(x) = -c x exp(-alpha |x|)}, and its integral, the smoothing + * filter {@code h(x) = k (alpha |x| + 1) exp(-alpha |x|)}. Both are computed + * exactly with second-order causal and anti-causal recursions, whatever the + * scale. The partial derivative along a dimension is the derivative filter + * along it and the smoothing filter along all other dimensions, which + * extends the detector to 3D and beyond as in O. Monga, R. Deriche, G. + * Malandain and J.-P. Cocquerez, "Recursive filtering and edge tracking: two + * primary tools for 3D edge detection", Image and Vision Computing 9(4), + * 1991. + *

+ *

+ * The parameter {@code alpha} (in inverse physical units) sets the scale: the + * smaller it is, the smoother the result; the filters roughly correspond to + * Gaussian derivatives of standard deviation {@code sqrt(pi) / alpha}. The + * smoothing filter is normalized to unit sum and the derivative filter to a + * unit response to a unit ramp, so derivatives are in intensity per physical + * unit. With a {@code calibration} (pixel spacing per dimension, default 1), + * the filters along dimension {@code d} use {@code alpha * calibration[d]} per + * pixel; it must have one value per dimension. The image is extended by + * border replication. Outputs have zero-based coordinates. The computation is + * single-threaded on flat arrays, so images are limited to 231 + * pixels. + *

+ *

+ * Implemented from the papers. The Canny edge filter of the 3D ImageJ Suite + * (mcib3d) was run only as a black box for comparison: it applies the 1D + * derivative filter along each axis without smoothing along the others, does + * not normalize the kernel, and does no non-maximum suppression. Its + * gradients therefore differ from {@code filter.dericheGradientMagnitude} by + * more than a constant factor, except for 1D signals. + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +public final class DericheFilters { + + private static final double TAN_22_5 = Math.tan(Math.PI / 8); + + private DericheFilters() { + // Prevent instantiation of static utility class. + } + + /** + * Computes the Deriche partial derivative of an image along one dimension: + * the Deriche derivative filter along that dimension, and the Deriche + * smoothing filter along all others. + * + * @param input the image + * @param alpha the (positive) scale parameter of the filters, in inverse + * physical units; smaller values smooth more + * @param dimension the dimension along which to differentiate + * @param calibration the pixel spacing along each dimension (default: 1 for + * every dimension) + * @return the partial derivative, in intensity per physical unit + * @implNote op names='filter.derichePartialDerivative' + */ + public static > RandomAccessibleInterval + partialDerivative( // + final RandomAccessibleInterval input, // + final Double alpha, // + final Integer dimension, // + @Nullable final double[] calibration // + ) { + final Grid grid = new Grid(input, alpha, calibration); + if (dimension < 0 || dimension >= grid.dims.length) { + throw new IllegalArgumentException("Invalid dimension: " + dimension); + } + return grid.image(grid.derivative(dimension)); + } + + /** + * Computes the magnitude of the Deriche gradient of an image: the Euclidean + * norm of its {@code filter.derichePartialDerivative} along all dimensions. + * This is not an edge map: for Canny edges on the same gradient, see + * {@code segment.cannyDericheEdges}. + * + * @param input the image + * @param alpha the (positive) scale parameter of the filters, in inverse + * physical units; smaller values smooth more + * @param calibration the pixel spacing along each dimension (default: 1 for + * every dimension) + * @return the gradient magnitude, in intensity per physical unit + * @implNote op names='filter.dericheGradientMagnitude' + */ + public static > RandomAccessibleInterval + gradientMagnitude( // + final RandomAccessibleInterval input, // + final Double alpha, // + @Nullable final double[] calibration // + ) { + final Grid grid = new Grid(input, alpha, calibration); + return grid.image(grid.magnitude(grid.gradient())); + } + + /** + * Detects edges with the Canny method on the Deriche gradient: the gradient + * magnitude is thinned by non-maximum suppression along the gradient + * direction, then thresholded with hysteresis. + *

+ * The gradient direction at each pixel is quantized to the nearest of its + * neighbors (a component counts if its magnitude is at least tan(22.5 + * degrees) times the largest one, which gives the classical four directions + * in 2D). A pixel survives the non-maximum suppression if its gradient + * magnitude is larger than that of the backward neighbor, and not smaller + * than that of the forward neighbor, which gives one-pixel-thick edges even + * where two pixels have the same magnitude, such as at an ideal step. The surviving pixels with + * a magnitude of at least {@code high}, and those of at least {@code low} + * connected to them (with full connectivity), are the edges. + *

+ * + * @param hysteresis the {@code threshold.hysteresis} op + * @param input the image + * @param alpha the (positive) scale parameter of the filters, in inverse + * physical units; smaller values smooth more + * @param low the low threshold of the gradient magnitude + * @param high the high threshold of the gradient magnitude; at least + * {@code low} + * @param calibration the pixel spacing along each dimension (default: 1 for + * every dimension) + * @return the edge mask + * @implNote op names='segment.cannyDericheEdges' + */ + public static > RandomAccessibleInterval + edges( // + @OpDependency( + name = "threshold.hysteresis") Functions.Arity3, Double, Double, RandomAccessibleInterval> hysteresis, + final RandomAccessibleInterval input, // + final Double alpha, // + final Double low, // + final Double high, // + @Nullable final double[] calibration // + ) { + final Grid grid = new Grid(input, alpha, calibration); + final double[][] gradient = grid.gradient(); + final double[] magnitude = grid.magnitude(gradient); + return hysteresis.apply(grid.image(grid.suppressNonMaxima(gradient, + magnitude)), low, high); + } + + /** The image as a flat array, with the filters applying to it. */ + private static final class Grid { + + private final int[] dims; + private final int[] strides; + private final double[] data; + private final double[] alphas; + private final double[] spacing; + + Grid(final RandomAccessibleInterval> input, + final double alpha, final double[] calibration) + { + if (!(alpha > 0) || Double.isInfinite(alpha)) { + throw new IllegalArgumentException("Alpha must be positive: " + alpha); + } + final int n = input.numDimensions(); + if (calibration != null && calibration.length != n) { + throw new IllegalArgumentException("Expected " + n + + " calibration values"); + } + data = FlatImage.read(input); + dims = new int[n]; + strides = FlatImage.strides(input.dimensionsAsLongArray()); + alphas = new double[n]; + spacing = new double[n]; + for (int d = 0; d < n; d++) { + dims[d] = (int) input.dimension(d); + spacing[d] = calibration == null ? 1 : calibration[d]; + if (!(spacing[d] > 0)) { + throw new IllegalArgumentException("Calibration must be positive"); + } + alphas[d] = alpha * spacing[d]; + } + } + + double[] derivative(final int dimension) { + final double[] out = data.clone(); + for (int d = 0; d < dims.length; d++) { + filterAlong(out, d, d == dimension); + } + final double scale = 1 / spacing[dimension]; + for (int i = 0; i < out.length; i++) + out[i] *= scale; + return out; + } + + double[][] gradient() { + final double[][] gradient = new double[dims.length][]; + for (int d = 0; d < dims.length; d++) + gradient[d] = derivative(d); + return gradient; + } + + double[] magnitude(final double[][] gradient) { + final double[] magnitude = new double[data.length]; + for (int i = 0; i < magnitude.length; i++) { + double sum = 0; + for (final double[] g : gradient) + sum += g[i] * g[i]; + magnitude[i] = Math.sqrt(sum); + } + return magnitude; + } + + double[] suppressNonMaxima(final double[][] gradient, + final double[] magnitude) + { + final int n = dims.length; + final double[] out = new double[magnitude.length]; + final int[] pos = new int[n]; + final double[] dir = new double[n]; + for (int i = 0; i < magnitude.length; i++) { + final double m = magnitude[i]; + if (m > 0) { + // Gradient direction in pixel coordinates, quantized to the + // nearest neighbor offset. + double max = 0; + for (int d = 0; d < n; d++) { + dir[d] = gradient[d][i] / spacing[d]; + max = Math.max(max, Math.abs(dir[d])); + } + int forward = i, backward = i; + for (int d = 0; d < n; d++) { + if (Math.abs(dir[d]) < TAN_22_5 * max) continue; + final int step = dir[d] > 0 ? 1 : -1; + if (pos[d] + step >= 0 && pos[d] + step < dims[d]) { + forward += step * strides[d]; + } + if (pos[d] - step >= 0 && pos[d] - step < dims[d]) { + backward -= step * strides[d]; + } + } + if ((backward == i || m > magnitude[backward]) && m >= + magnitude[forward]) out[i] = m; + } + for (int d = 0; d < n && ++pos[d] == dims[d]; d++) + pos[d] = 0; + } + return out; + } + + /** Applies the derivative or smoothing filter along one dimension. */ + private void filterAlong(final double[] values, final int d, + final boolean derivative) + { + final int len = dims[d]; + final int stride = strides[d]; + final int block = stride * len; + final double[] line = new double[len]; + final double[] causal = new double[len]; + for (int outer = 0; outer < values.length; outer += block) { + for (int inner = 0; inner < stride; inner++) { + final int start = outer + inner; + for (int k = 0; k < len; k++) + line[k] = values[start + k * stride]; + if (derivative) derive(line, causal, alphas[d]); + else smooth(line, causal, alphas[d]); + for (int k = 0; k < len; k++) + values[start + k * stride] = line[k]; + } + } + } + + Img image(final double[] values) { + final long[] size = new long[dims.length]; + for (int d = 0; d < dims.length; d++) + size[d] = dims[d]; + return ArrayImgs.doubles(values, size); + } + } + + /** + * Smooths a line in place with the recursive Deriche smoothing filter + * {@code h(x) = k (alpha |x| + 1) exp(-alpha |x|)}, normalized to unit sum, + * extending the line by border replication. + */ + static void smooth(final double[] x, final double[] causal, + final double alpha) + { + final int len = x.length; + final double a = Math.exp(-alpha); + final double k = (1 - a) * (1 - a) / (1 + 2 * alpha * a - a * a); + // h = k (alpha n + 1) a^n for n >= 0, and its mirror for n < 0. + final double b0 = k, b1 = k * a * (alpha - 1); + final double c1 = k * a * (alpha + 1), c2 = -k * a * a; + final double a1 = 2 * a, a2 = -a * a; + final double gain = 1 - a1 - a2; + + // Causal part, starting in the steady state of the first value. + double xPrev = x[0]; + double y1 = (b0 + b1) * x[0] / gain, y2 = y1; + for (int i = 0; i < len; i++) { + final double y = b0 * x[i] + b1 * xPrev + a1 * y1 + a2 * y2; + causal[i] = y; + y2 = y1; + y1 = y; + xPrev = x[i]; + } + // Anti-causal part, starting in the steady state of the last value. + double xNext1 = x[len - 1], xNext2 = x[len - 1]; + y1 = (c1 + c2) * x[len - 1] / gain; + y2 = y1; + for (int i = len - 1; i >= 0; i--) { + final double y = c1 * xNext1 + c2 * xNext2 + a1 * y1 + a2 * y2; + y2 = y1; + y1 = y; + xNext2 = xNext1; + xNext1 = x[i]; + x[i] = causal[i] + y; + } + } + + /** + * Differentiates a line in place with the recursive Deriche derivative + * filter {@code f(x) = -c x exp(-alpha |x|)}, normalized to a unit response + * to a unit ramp, extending the line by border replication. + */ + static void derive(final double[] x, final double[] causal, + final double alpha) + { + final int len = x.length; + final double a = Math.exp(-alpha); + final double c = (1 - a) * (1 - a) * (1 - a) / (2 * a * (1 + a)); + final double a1 = 2 * a, a2 = -a * a; + final double gain = 1 - a1 - a2; + + // Causal part: the response to n a^n for n >= 1. + double xPrev = x[0]; + double y1 = a * x[0] / gain, y2 = y1; + for (int i = 0; i < len; i++) { + final double y = a * xPrev + a1 * y1 + a2 * y2; + causal[i] = y; + y2 = y1; + y1 = y; + xPrev = x[i]; + } + // Anti-causal part: the mirrored response. + double xNext = x[len - 1]; + y1 = a * x[len - 1] / gain; + y2 = y1; + for (int i = len - 1; i >= 0; i--) { + final double y = a * xNext + a1 * y1 + a2 * y2; + y2 = y1; + y1 = y; + xNext = x[i]; + x[i] = c * (y - causal[i]); + } + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/filter/diffusion/CoherenceEnhancingDiffusion.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/filter/diffusion/CoherenceEnhancingDiffusion.java new file mode 100644 index 000000000..299ea06f8 --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/filter/diffusion/CoherenceEnhancingDiffusion.java @@ -0,0 +1,253 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.filter.diffusion; + +import java.util.Arrays; + +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.img.array.ArrayImgs; +import net.imglib2.type.numeric.RealType; +import net.imglib2.type.numeric.real.DoubleType; +import net.imglib2.util.Intervals; +import net.imglib2.view.Views; + +import org.scijava.function.Computers; +import org.scijava.function.Functions; +import org.scijava.ops.image.filter.FlatImage; +import org.scijava.ops.image.filter.tensor.TensorUtils; +import org.scijava.ops.spi.Nullable; +import org.scijava.ops.spi.OpDependency; + +/** + * Coherence-enhancing diffusion (Weickert 1999) in n dimensions. + *

+ * This nonlinear anisotropic diffusion smooths along the local orientation of + * flow-like structures (fibers, fingerprints, wood grain) and hardly across + * it, closing interrupted lines. Each iteration performs one explicit step of + * {@code du/dt = div(D ∇u)}, where the diffusion tensor {@code D} is derived + * from the structure tensor {@code J} of the current image (see + * {@code filter.structureTensor}), computed with gradient scale {@code sigma} + * and integration scale {@code rho}. {@code D} has the same eigenvectors as + * {@code J}; with {@code μ1 >= ... >= μn} the eigenvalues of {@code J}, the + * diffusivity along the eigenvector of {@code μn} (the direction of least + * intensity variation, i.e. along the structure) is + *

+ * + *
+ * λn = α + (1 - α) · exp(-C / (μ1 - μn)²)   if μ1 > μn, and α otherwise,
+ * 
+ *

+ * and the diffusivity along all other eigenvectors is the small constant + * {@code α}, which keeps the process well-posed. With {@code α = 1}, the + * diffusion is isotropic and linear. The coherence threshold {@code C} has + * units of intensity to the fourth power, so it depends on the intensity + * range of the image: structures whose coherence {@code (μ1 - μn)²} is much + * larger than {@code C} are diffused along their orientation with diffusivity + * close to 1. In 3D, this smooths along line-like structures, the extension + * of Weickert (1999) for fibers. + *

+ *

+ * Derivatives in {@code div(D ∇u)} use the standard explicit discretization + * (Weickert 1998, section 3.4.2): differences at half-pixel positions for the + * diagonal entries of {@code D} and central differences of central + * differences for the mixed entries, with the border pixels repeated outside + * the image. The scheme is not guaranteed to satisfy a maximum principle, but + * it is stable for time steps up to about {@code 1 / (2n)}, and larger time + * steps are rejected. Scales are in pixels, with no spatial calibration. The + * computation is single-threaded on double-precision flat arrays holding the + * n(n+1)/2 tensor components of every pixel, so the image is limited to + * 231 / (n(n+1)/2) pixels. + *

+ *

+ * Implemented from the papers. The related tensor-driven diffusion of + * Tschumperlé and Deriche (Fiji's "Anisotropic Diffusion 2D") is not + * implemented. + *

+ *

+ * Results are clamped to the range of the output type, so an integer output + * saturates rather than wrapping around. + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @param input pixel type + * @param output pixel type + * @see J. Weickert, + * "Coherence-enhancing diffusion filtering", Int. J. Comput. Vis. + * 31(2/3):111-127, 1999 + * @see J. + * Weickert, "Anisotropic Diffusion in Image Processing", Teubner, + * Stuttgart, 1998 + * @implNote op names='filter.coherenceEnhancingDiffusion' + */ +public class CoherenceEnhancingDiffusion, V extends RealType> + implements + Computers.Arity7, Double, Double, Integer, Double, Double, Double, RandomAccessibleInterval> +{ + + @OpDependency(name = "filter.structureTensor") + private Functions.Arity3, Double, Double, RandomAccessibleInterval> structureTensorOp; + + /** + * Applies coherence-enhancing diffusion. + * + * @param input the image to filter + * @param sigma the Gaussian scale for computing the gradient, in pixels + * (the noise scale, e.g. 0.5 to 1) + * @param rho the Gaussian integration scale of the structure tensor, in + * pixels (the size of the structures to be enhanced, e.g. 4) + * @param iterations the number of iterations (default: 20) + * @param dt the time step, at most 1 / (2n) for an n-dimensional image + * (default: 1 / (4n)) + * @param alpha the diffusivity across structures, in (0, 1] (default: + * 0.001) + * @param c the coherence threshold C, in units of intensity to the fourth + * power (default: 1, suited to 8-bit intensities) + * @param output the filtered image + */ + @Override + public void compute(final RandomAccessibleInterval input, + final Double sigma, final Double rho, @Nullable final Integer iterations, + @Nullable final Double dt, @Nullable final Double alpha, + @Nullable final Double c, final RandomAccessibleInterval output) + { + if (!Intervals.equalDimensions(input, output)) { + throw new IllegalArgumentException( + "Input and output must have the same dimensions"); + } + final var n = input.numDimensions(); + final int iters = iterations == null ? 20 : iterations; + final double step = dt == null ? 1.0 / (4 * n) : dt; + if (!(step > 0) || step > 1.0 / (2 * n)) { + throw new IllegalArgumentException("Time step must be in (0, 1/" + 2 * + n + "] for stability: " + step); + } + final double a = alpha == null ? 0.001 : alpha; + if (!(a > 0 && a <= 1)) { + throw new IllegalArgumentException("Alpha must be in (0, 1]: " + a); + } + final double cc = c == null ? 1 : c; + if (!(cc > 0)) { + throw new IllegalArgumentException("C must be positive: " + cc); + } + final var m = n * (n + 1) / 2; + final var count = FlatImage.size(input, m); + final var dims = input.dimensionsAsLongArray(); + final var strides = FlatImage.strides(dims); + // Index of the tensor component (r, s) in upper-triangular order. + final var comp = new int[n][n]; + for (int r = 0, k = 0; r < n; r++) + for (var s = r; s < n; s++, k++) + comp[r][s] = comp[s][r] = k; + + var u = FlatImage.read(input); + var next = new double[count]; + + final var diffusion = new double[m * count]; + final var mat = new double[n][n]; + final var vecs = new double[n][n]; + final var vals = new double[n]; + final var pos = new int[n]; + for (var it = 0; it < iters; it++) { + // Compute the diffusion tensor from the structure tensor. + final var tensor = structureTensorOp.apply(ArrayImgs.doubles(u, dims), + sigma, rho); + var i = 0; + for (final var t : Views.flatIterable(tensor)) + diffusion[i++] = t.get(); + for (var p = 0; p < count; p++) { + for (var r = 0; r < n; r++) + for (var s = 0; s < n; s++) + mat[r][s] = diffusion[comp[r][s] * count + p]; + TensorUtils.symmetricEigen(mat, vals, vecs); + var max = 0; + var min = 0; + for (var k = 1; k < n; k++) { + if (vals[k] > vals[max]) max = k; + if (vals[k] < vals[min]) min = k; + } + final var coherence = vals[max] - vals[min]; + final var lambda = coherence > 0 ? a + (1 - a) * Math.exp(-cc / + (coherence * coherence)) : a; + // Note: D = α·I + (λn - α)·vn·vnᵀ, as all other diffusivities are α. + for (var r = 0; r < n; r++) { + for (var s = r; s < n; s++) { + diffusion[comp[r][s] * count + p] = (r == s ? a : 0) + (lambda - + a) * vecs[r][min] * vecs[s][min]; + } + } + } + + // Take one explicit step of du/dt = div(D grad u). + Arrays.fill(pos, 0); + for (var p = 0; p < count; p++) { + var change = 0.0; + for (var r = 0; r < n; r++) { + final var up = offset(pos, dims, strides, r, 1); + final var down = offset(pos, dims, strides, r, -1); + final var kr = comp[r][r] * count + p; + change += 0.5 * (diffusion[kr] + diffusion[kr + up]) * (u[p + up] - + u[p]); + change -= 0.5 * (diffusion[kr] + diffusion[kr + down]) * (u[p] - u[p + + down]); + for (var s = 0; s < n; s++) { + if (s == r) continue; + final var k = comp[r][s] * count + p; + final var upS = offset(pos, dims, strides, s, 1); + final var downS = offset(pos, dims, strides, s, -1); + final var fluxUp = diffusion[k + up] * (u[p + up + upS] - u[p + up + + downS]); + final var fluxDown = diffusion[k + down] * (u[p + down + upS] - + u[p + down + downS]); + change += 0.25 * (fluxUp - fluxDown); + } + } + next[p] = u[p] + step * change; + FlatImage.increment(pos, dims); + } + final var tmp = u; + u = next; + next = tmp; + } + + FlatImage.write(u, output); + } + + /** + * Gets the flat index offset to the neighbor in direction {@code ±e_d}, + * repeating the border pixel outside the image. + */ + private static int offset(final int[] pos, final long[] dims, + final int[] strides, final int d, final int dir) + { + final var q = pos[d] + dir; + return q < 0 || q >= dims[d] ? 0 : dir * strides[d]; + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/filter/diffusion/PeronaMalikDiffusion.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/filter/diffusion/PeronaMalikDiffusion.java new file mode 100644 index 000000000..9660dff7c --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/filter/diffusion/PeronaMalikDiffusion.java @@ -0,0 +1,167 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.filter.diffusion; + +import java.util.Arrays; +import java.util.Locale; + +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.type.numeric.RealType; +import net.imglib2.util.Intervals; + +import org.scijava.function.Computers; +import org.scijava.ops.image.filter.FlatImage; +import org.scijava.ops.spi.Nullable; + +/** + * Perona-Malik anisotropic diffusion in n dimensions. + *

+ * Each iteration performs one explicit step of the nonlinear diffusion + * equation {@code dI/dt = div(g(|grad I|) grad I)} on the 2n nearest + * neighbours of each pixel: + *

+ * + *
+ * I(x) += dt * sum over neighbours y of g(|I(y) - I(x)|) * (I(y) - I(x))
+ * 
+ *

+ * The conductance {@code g} is close to 1 for differences much smaller than + * {@code kappa}, so noise is smoothed, and close to 0 for larger differences, + * so edges are preserved. The two conductance functions of Perona and Malik + * are available: {@code "exponential"}, {@code g(s) = exp(-(s/kappa)^2)}, + * which favours high-contrast edges, and {@code "quadratic"}, + * {@code g(s) = 1 / (1 + (s/kappa)^2)}, which favours wide regions over + * smaller ones. There is no flux across the image border, so the total + * intensity is preserved. The scheme is stable for {@code dt <= 1 / (2n)}, + * and larger time steps are rejected. The computation is single-threaded on + * double-precision flat arrays, so the image is limited to 231 + * pixels. + *

+ *

+ * Implemented from the paper. Note: Fiji's "Anisotropic Diffusion 2D" plugin + * implements a different algorithm (Tschumperlé-Deriche tensor-driven + * diffusion), hence the more specific name {@code filter.peronaMalik}. + *

+ *

+ * Results are clamped to the range of the output type, so an integer output + * saturates rather than wrapping around. + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @param input pixel type + * @param output pixel type + * @see P. Perona and J. Malik, + * "Scale-space and edge detection using anisotropic diffusion", IEEE + * Trans. Pattern Anal. Mach. Intell. 12(7):629-639, 1990 + * @implNote op names='filter.peronaMalik' + */ +public class PeronaMalikDiffusion, V extends RealType> + implements + Computers.Arity5, Double, Integer, Double, String, RandomAccessibleInterval> +{ + + /** + * Applies Perona-Malik diffusion. + * + * @param input the image to filter + * @param kappa the gradient threshold, in units of intensity: differences + * well above kappa are treated as edges + * @param iterations the number of iterations (default: 10) + * @param dt the time step, at most 1 / (2n) for an n-dimensional image + * (default: 1 / (4n)) + * @param conductance the conductance function, "exponential" or "quadratic" + * (default: "exponential") + * @param output the filtered image + */ + @Override + public void compute(final RandomAccessibleInterval input, + final Double kappa, @Nullable final Integer iterations, + @Nullable final Double dt, @Nullable final String conductance, + final RandomAccessibleInterval output) + { + if (!Intervals.equalDimensions(input, output)) { + throw new IllegalArgumentException( + "Input and output must have the same dimensions"); + } + if (!(kappa > 0)) { + throw new IllegalArgumentException("Kappa must be positive: " + kappa); + } + final var n = input.numDimensions(); + final int iters = iterations == null ? 10 : iterations; + final double step = dt == null ? 1.0 / (4 * n) : dt; + if (!(step > 0) || step > 1.0 / (2 * n)) { + throw new IllegalArgumentException("Time step must be in (0, 1/" + 2 * + n + "] for stability: " + step); + } + final boolean exponential; + final var g = conductance == null ? "exponential" : conductance + .toLowerCase(Locale.ROOT); + if (g.equals("exponential")) exponential = true; + else if (g.equals("quadratic")) exponential = false; + else throw new IllegalArgumentException( + "Conductance must be exponential or quadratic: " + conductance); + + var current = FlatImage.read(input); + final var count = current.length; + var next = new double[count]; + final var dims = input.dimensionsAsLongArray(); + final var strides = FlatImage.strides(dims); + + final var invKappaSq = 1 / (kappa * kappa); + final var pos = new long[n]; + for (var it = 0; it < iters; it++) { + System.arraycopy(current, 0, next, 0, count); + Arrays.fill(pos, 0); + for (var i = 0; i < count; i++) { + final var v = current[i]; + for (var d = 0; d < n; d++) { + // Note: Each neighbour pair is visited once, from its lower pixel, + // and the flux is applied to both pixels. + if (pos[d] < dims[d] - 1) { + final var j = i + strides[d]; + final var diff = current[j] - v; + final var s = diff * diff * invKappaSq; + final var flux = step * diff * (exponential ? Math.exp(-s) : 1 / + (1 + s)); + next[i] += flux; + next[j] -= flux; + } + } + FlatImage.increment(pos, dims); + } + final var tmp = current; + current = next; + next = tmp; + } + + FlatImage.write(current, output); + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/filter/flatfield/FlatFieldCorrect.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/filter/flatfield/FlatFieldCorrect.java new file mode 100644 index 000000000..126481bef --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/filter/flatfield/FlatFieldCorrect.java @@ -0,0 +1,177 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.filter.flatfield; + +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.loops.LoopBuilder; +import net.imglib2.type.numeric.RealType; +import net.imglib2.util.Intervals; +import net.imglib2.view.Views; + +import org.scijava.function.Computers; +import org.scijava.ops.spi.Nullable; + +/** + * Flat-field correction of an image using a reference flat-field image and an + * optional dark-field image: + * + *
+ * out = (in - dark) / (flat - dark) × mean(flat - dark)
+ * 
+ *

+ * Without a dark-field image, this reduces to + * {@code out = in / flat × mean(flat)}. Pixels where + * {@code flat - dark == 0} are set to {@code in - dark}. The input, flat and + * dark images must have the same dimensions; they may have any number of + * dimensions. The computation is done in double precision, and results are + * clamped to the range of the output type (so with an integer output, + * {@code in < dark} gives 0 and overly bright pixels saturate). + *

+ *

+ * Based on the "Flat-field correction" macro of the BioVoxxel Toolbox by Jan + * Brocher / BioVoxxel (BSD 3-Clause license, Copyright (C) Jan Brocher / + * BioVoxxel). The optional dark-field subtraction is an addition to the + * original, which only divides by the flat-field. The RGB (HSB brightness) + * branch of the original is not ported; apply this op to a brightness channel + * instead. + *

+ *

+ * The original BioVoxxel Toolbox license notice follows. + *

+ * + *
+ * Copyright (c) 2015, BioVoxxel / Jan Brocher
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ *
+ * * Redistributions of source code must retain the above copyright notice, this
+ *   list of conditions and the following disclaimer.
+ *
+ * * 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.
+ *
+ * * Neither the name of BioVoxxel nor the names of its
+ *   contributors may be used to endorse or promote products derived from
+ *   this software without specific prior written permission.
+ *
+ * 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 HOLDER 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.
+ * 
+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @author Jan Brocher (BioVoxxel) + * @param input pixel type + * @param flat-field pixel type + * @param dark-field pixel type + * @param output pixel type + * @see BioVoxxel + * Toolbox + * @implNote op names='filter.flatFieldCorrect' + */ +public class FlatFieldCorrect, U extends RealType, D extends RealType, V extends RealType> + implements + Computers.Arity3, RandomAccessibleInterval, RandomAccessibleInterval, RandomAccessibleInterval> +{ + + /** + * Applies flat-field correction. + * + * @param input the image to correct + * @param flat the flat-field image, i.e. an image of a uniform specimen + * acquired under the same illumination + * @param dark the dark-field image, i.e. an image acquired without + * illumination (default: no dark-field subtraction) + * @param output the corrected image + */ + @Override + public void compute(final RandomAccessibleInterval input, + final RandomAccessibleInterval flat, + @Nullable final RandomAccessibleInterval dark, + final RandomAccessibleInterval output) + { + if (!Intervals.equalDimensions(input, flat)) { + throw new IllegalArgumentException( + "Input and flat-field images must have the same dimensions"); + } + if (dark != null && !Intervals.equalDimensions(input, dark)) { + throw new IllegalArgumentException( + "Input and dark-field images must have the same dimensions"); + } + + // Compute mean(flat - dark). + double sum = 0; + long count = 0; + final var flatCursor = Views.flatIterable(flat).cursor(); + final var darkCursor = dark == null ? null : Views.flatIterable(dark) + .cursor(); + while (flatCursor.hasNext()) { + sum += flatCursor.next().getRealDouble() - (darkCursor == null ? 0 + : darkCursor.next().getRealDouble()); + count++; + } + final var mean = sum / count; + + if (dark == null) { + LoopBuilder.setImages(input, flat, output).forEachPixel((i, f, o) -> o + .setReal(clamp(o, correct(i.getRealDouble(), f.getRealDouble(), + mean)))); + } + else { + LoopBuilder.setImages(input, flat, dark, output).forEachPixel((i, f, d, + o) -> { + final var dv = d.getRealDouble(); + o.setReal(clamp(o, correct(i.getRealDouble() - dv, f.getRealDouble() - + dv, mean))); + }); + } + } + + private static double clamp(final RealType o, final double v) { + return Math.max(o.getMinValue(), Math.min(o.getMaxValue(), v)); + } + + private static double correct(final double signal, final double flat, + final double mean) + { + return flat == 0 ? signal : signal / flat * mean; + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/filter/flatfield/PseudoFlatField.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/filter/flatfield/PseudoFlatField.java new file mode 100644 index 000000000..53808102f --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/filter/flatfield/PseudoFlatField.java @@ -0,0 +1,201 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.filter.flatfield; + +import java.util.Arrays; +import java.util.function.BiFunction; + +import net.imglib2.Dimensions; +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.loops.LoopBuilder; +import net.imglib2.outofbounds.OutOfBoundsBorderFactory; +import net.imglib2.outofbounds.OutOfBoundsFactory; +import net.imglib2.type.numeric.RealType; +import net.imglib2.type.numeric.real.FloatType; +import net.imglib2.view.Views; + +import org.scijava.function.Computers; +import org.scijava.ops.spi.OpDependency; + +/** + * Pseudo flat-field correction: corrects uneven illumination using a heavily + * blurred copy of the image itself as the flat-field estimate. + *

+ * The background {@code G} is estimated by Gaussian-blurring the input with + * the given sigma(s) (out-of-bounds values are taken from the image border), + * and each pixel is then scaled by the ratio of the mean background intensity + * to the local background intensity: + *

+ * + *
+ * out = in × mean(G) / G
+ * 
+ *

+ * Pixels where {@code G == 0} are copied unchanged. Results are clamped to + * the range of the output type, so an integer output saturates rather than + * wrapping around; use a floating-point output to keep the full range. The + * sigma should be + * considerably larger than the largest foreground object, so that the blurred + * image contains only the illumination profile. + *

+ *

+ * Translated from the {@code Pseudo_flat_field_correction} plugin of the + * BioVoxxel Toolbox by Jan Brocher / BioVoxxel (BSD 3-Clause license, + * Copyright (C) Jan Brocher / BioVoxxel). Unlike the original, which blurs + * single 2D planes and uses integer arithmetic for 8- and 16-bit images, this + * op blurs in n dimensions and computes in floating point. The RGB (HSB + * brightness) branch of the original is not ported; apply this op to a + * brightness channel instead. + *

+ *

+ * The original BioVoxxel Toolbox license notice follows. + *

+ * + *
+ * Copyright (c) 2015, BioVoxxel / Jan Brocher
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ *
+ * * Redistributions of source code must retain the above copyright notice, this
+ *   list of conditions and the following disclaimer.
+ *
+ * * 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.
+ *
+ * * Neither the name of BioVoxxel nor the names of its
+ *   contributors may be used to endorse or promote products derived from
+ *   this software without specific prior written permission.
+ *
+ * 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 HOLDER 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.
+ * 
+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @author Jan Brocher (BioVoxxel) + * @param input pixel type + * @param output pixel type + * @see BioVoxxel + * Toolbox + * @implNote op names='filter.pseudoFlatField' + */ +public class PseudoFlatField, V extends RealType> + implements + Computers.Arity2, double[], RandomAccessibleInterval> +{ + + @OpDependency(name = "create.img") + private BiFunction> createOp; + + @OpDependency(name = "filter.gauss") + private Computers.Arity3, double[], OutOfBoundsFactory>, RandomAccessibleInterval> gaussOp; + + /** + * Applies pseudo flat-field correction. + * + * @param input the image to correct + * @param sigmas the standard deviations (one per dimension, in pixels) of + * the Gaussian used to estimate the background + * @param output the corrected image + */ + @Override + public void compute(final RandomAccessibleInterval input, + final double[] sigmas, final RandomAccessibleInterval output) + { + if (sigmas.length != input.numDimensions()) { + throw new IllegalArgumentException("Expected " + input.numDimensions() + + " sigmas but got " + sigmas.length); + } + final var background = createOp.apply(input, new FloatType()); + gaussOp.compute(input, sigmas, new OutOfBoundsBorderFactory<>(), + background); + + double sum = 0; + long count = 0; + for (final var g : Views.flatIterable(background)) { + sum += g.getRealDouble(); + count++; + } + final var meanBackground = sum / count; + + LoopBuilder.setImages(input, background, output).forEachPixel((i, g, + o) -> { + final var gv = g.getRealDouble(); + final var v = gv == 0 ? i.getRealDouble() : i.getRealDouble() * + meanBackground / gv; + o.setReal(Math.max(o.getMinValue(), Math.min(o.getMaxValue(), v))); + }); + } +} + +/** + * Convenience version of {@code filter.pseudoFlatField} using the same sigma + * in every dimension. + * + * @param input pixel type + * @param output pixel type + * @implNote op names='filter.pseudoFlatField' + */ +class PseudoFlatFieldSingleSigma, V extends RealType> + implements + Computers.Arity2, Double, RandomAccessibleInterval> +{ + + @OpDependency(name = "filter.pseudoFlatField") + private Computers.Arity2, double[], RandomAccessibleInterval> pseudoFlatFieldOp; + + /** + * Applies pseudo flat-field correction. + * + * @param input the image to correct + * @param sigma the standard deviation (in pixels, in every dimension) of the + * Gaussian used to estimate the background + * @param output the corrected image + */ + @Override + public void compute(final RandomAccessibleInterval input, + final Double sigma, final RandomAccessibleInterval output) + { + final var sigmas = new double[input.numDimensions()]; + Arrays.fill(sigmas, sigma); + pseudoFlatFieldOp.compute(input, sigmas, output); + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/filter/helmholtz/HelmholtzAnalysis.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/filter/helmholtz/HelmholtzAnalysis.java new file mode 100644 index 000000000..2863a2cc7 --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/filter/helmholtz/HelmholtzAnalysis.java @@ -0,0 +1,391 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.filter.helmholtz; + +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.img.array.ArrayImg; +import net.imglib2.img.array.ArrayImgs; +import net.imglib2.img.basictypeaccess.array.FloatArray; +import net.imglib2.type.numeric.RealType; +import net.imglib2.type.numeric.real.FloatType; +import net.imglib2.view.Views; + +import org.scijava.function.Computers; +import org.scijava.ops.spi.Nullable; +import org.scijava.ops.spi.OpDependency; + +/** + * Helmholtz analysis: estimation of the local wavelength and orientation of + * quasi-periodic patterns (fringes, striations, e.g. of muscle fibers) in 2D + * images, using the Helmholtz equation {@code ∇²I + k²I = 0}, which holds for + * a locally harmonic intensity {@code I} with wave number {@code k}. + *

+ * The image is first band-pass filtered to the range of wavelengths of + * interest, so that it oscillates around zero. Then, at each pixel, the local + * wave number is {@code k = sqrt(-∇²I / I)} and the wavelength is + * {@code λ = 2π / k}; pixels where {@code ∇²I / I >= 0} or where {@code λ} is + * outside {@code [lambdaMin, lambdaMax]} are invalid (NaN). The orientation + * is that of the intensity gradient, computed with Sobel kernels. The + * Laplacian is the discrete 5-point Laplacian, which overestimates the + * wavelength slightly for short wavelengths (by about 2% at 8 pixels and 5% at + * 6 pixels). + *

+ *

+ * Optionally, the raw estimates are smoothed with a validated mean over a disk + * of the given radius: the mean of the valid values within the disk, which is + * only accepted (otherwise NaN) if at least 2/3 of the disk pixels are valid + * and the standard deviation of the valid values is at most {@code lambdaMin} + * (for the wavelength) or 15 degrees (for the orientation, whose mean is + * weighted by the gradient magnitude). + *

+ *

+ * Translated from the Helmholtz_Analysis plugin for Fiji (Helmholtz_Analysis, formerly + * named Muscle Tone) by Bob Dougherty, OptiNav, Inc., used under the BSD + * 3-Clause license. Differences from the plugin: the plugin band-pass filters + * with ImageJ's FFT "Bandpass Filter" (with intensity autoscaling and + * saturation) after a Gaussian blur; this op uses the equivalent Gaussian + * filters in the spatial domain (the FFT filter's Gaussian transfer functions + * for the structure sizes {@code d} correspond to spatial Gaussians with + * {@code sigma = d * sqrt(2) / π}), so values differ slightly. + *

+ *

+ * The original Helmholtz_Analysis license notice follows. + *

+ * + *
+ * Copyright (c) 2005, OptiNav, Inc.
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions
+ * are met:
+ *
+ *   Redistributions of source code must retain the above copyright
+ * notice, this list of conditions and the following disclaimer.
+ *   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.
+ *   Neither the name of OptiNav, Inc. nor the names of its contributors
+ * may be used to endorse or promote products derived from this software
+ * without specific prior written permission.
+ *
+ * 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 OWNER 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.
+ * 
+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @author Bob Dougherty + */ +public final class HelmholtzAnalysis { + + private static final double PHI_TOLERANCE = 15; + + private HelmholtzAnalysis() { + // Prevent instantiation of static utility class. + } + + /** + * Estimates the local wavelength of quasi-periodic patterns in a 2D image, + * using the Helmholtz equation. + * + * @param the input type + * @param gaussOp the Gaussian filter op + * @param input the 2D input image + * @param lambdaMin the minimum wavelength of interest, in pixels + * @param lambdaMax the maximum wavelength of interest, in pixels + * @param averagingRadius the radius, in pixels, of the disk over which the + * estimates are averaged; defaults to 0 (no averaging) + * @return the local wavelength, in pixels, or NaN where it is invalid + * @implNote op names='filter.helmholtzWavelength' + */ + public static > RandomAccessibleInterval + wavelength( // + @OpDependency( + name = "filter.gauss") Computers.Arity2, Double, RandomAccessibleInterval> gaussOp, // + final RandomAccessibleInterval input, // + final Double lambdaMin, // + final Double lambdaMax, // + @Nullable Integer averagingRadius // + ) { + final var result = analyze(gaussOp, input, lambdaMin, lambdaMax); + final var r = averagingRadius == null ? 0 : averagingRadius; + if (r <= 0) return wrap(result.lambda, result.w, result.h); + final var av = new float[result.lambda.length]; + average(result.w, result.h, r, lambdaMin, result.lambda, null, av); + return wrap(av, result.w, result.h); + } + + /** + * Estimates the local orientation of quasi-periodic patterns in a 2D image, + * using the Helmholtz equation. The orientation is that of the fringes, i.e. + * of the lines of locally constant intensity, perpendicular to the + * intensity gradient: an angle in degrees within {@code [0, 180)}, measured + * from the x axis towards the y axis (clockwise on screen, since y points + * down). For example, stripes that run along the y axis have orientation + * 90. Averaging uses the linear mean of the angles, as the plugin does, so + * averaged orientations near 0 or 180 degrees are typically rejected. This + * convention differs from {@code filter.orientation} (radians in + * {@code [-pi/2, pi/2]}, counterclockwise with y up), so convert before + * passing the result to {@code filter.kuwaharaLinear}. + * + * @param the input type + * @param gaussOp the Gaussian filter op + * @param input the 2D input image + * @param lambdaMin the minimum wavelength of interest, in pixels + * @param lambdaMax the maximum wavelength of interest, in pixels + * @param averagingRadius the radius, in pixels, of the disk over which the + * estimates are averaged; defaults to 0 (no averaging) + * @return the local orientation, in degrees, or NaN where the wavelength + * is invalid + * @implNote op names='filter.helmholtzOrientation' + */ + public static > RandomAccessibleInterval + orientation( // + @OpDependency( + name = "filter.gauss") Computers.Arity2, Double, RandomAccessibleInterval> gaussOp, // + final RandomAccessibleInterval input, // + final Double lambdaMin, // + final Double lambdaMax, // + @Nullable Integer averagingRadius // + ) { + final var result = analyze(gaussOp, input, lambdaMin, lambdaMax); + final var r = averagingRadius == null ? 0 : averagingRadius; + if (r <= 0) return wrap(result.phi, result.w, result.h); + final var av = new float[result.phi.length]; + average(result.w, result.h, r, PHI_TOLERANCE, result.phi, result.weight, + av); + return wrap(av, result.w, result.h); + } + + // -- Helper methods -- + + private static final class Result { + + private int w, h; + private float[] lambda, phi, weight; + } + + private static > Result analyze( + final Computers.Arity2, Double, RandomAccessibleInterval> gaussOp, + final RandomAccessibleInterval input, final double lambdaMin, + final double lambdaMax) + { + if (input.numDimensions() != 2) { + throw new IllegalArgumentException("Only 2D images are supported"); + } + if (!(lambdaMin > 0 && lambdaMax > lambdaMin)) { + throw new IllegalArgumentException( + "Need 0 < lambdaMin < lambdaMax"); + } + if (input.size() > Integer.MAX_VALUE) { + throw new IllegalArgumentException("Image is too large"); + } + final var w = (int) input.dimension(0); + final var h = (int) input.dimension(1); + + // Band-pass filter. The plugin blurs with a Gaussian of sigma + // lambdaMin / 5 (ImageJ "radius" lambdaMin / 2), applies the FFT band-pass + // filter, then subtracts a Gaussian blur of sigma lambdaMax / 2.5. + final var f = ArrayImgs.floats(w, h); + final var ic = Views.flatIterable(input).cursor(); + for (final var t : f) + t.setReal(ic.next().getRealDouble()); + final var fftScale = Math.sqrt(2) / Math.PI; + final var sigmaPre = lambdaMin / 5; + final var sigmaSmall = fftScale * lambdaMin; + final var sigmaLarge = fftScale * lambdaMax; + final var sigmaTrend = lambdaMax / 2.5; + final var low = gauss(gaussOp, f, Math.sqrt(sigmaPre * sigmaPre + + sigmaSmall * sigmaSmall)); + final var bandPassed = subtract(low, gauss(gaussOp, low, sigmaLarge)); + final var filtered = subtract(bandPassed, gauss(gaussOp, bandPassed, + sigmaTrend)); + final var data = filtered.update(null).getCurrentStorageArray(); + + final var gradX = convolve3x3(data, w, h, new int[] { -1, 0, 1, -2, 0, 2, + -1, 0, 1 }); + final var gradY = convolve3x3(data, w, h, new int[] { 1, 2, 1, 0, 0, 0, -1, + -2, -1 }); + final var laplace = convolve3x3(data, w, h, new int[] { 0, 1, 0, 1, -4, 1, + 0, 1, 0 }); + + final var result = new Result(); + result.w = w; + result.h = h; + result.lambda = new float[w * h]; + result.phi = new float[w * h]; + result.weight = new float[w * h]; + for (var i = 0; i < data.length; i++) { + final double d = data[i]; + final var k2 = d == 0 ? 0 : laplace[i] / d; + result.lambda[i] = Float.NaN; + result.phi[i] = Float.NaN; + if (k2 >= 0) continue; + final var wavelength = (float) (2 * Math.PI / Math.sqrt(-k2)); + if (wavelength < lambdaMin || wavelength > lambdaMax) continue; + result.lambda[i] = wavelength; + final double gx = gradX[i], gy = gradY[i]; + // Note: atan2 returns ±π for gx = ±0 and gy < 0, which is the same + // orientation as 0; the angle is reduced to [0, 180) in float precision. + var phiDeg = (float) Math.toDegrees(Math.atan2(gx, gy)); + if (phiDeg < 0) phiDeg += 180; + if (phiDeg >= 180) phiDeg -= 180; + result.phi[i] = phiDeg; + result.weight[i] = (float) Math.sqrt(gx * gx + gy * gy); + } + return result; + } + + private static ArrayImg gauss( + final Computers.Arity2, Double, RandomAccessibleInterval> gaussOp, + final ArrayImg in, final double sigma) + { + final var out = ArrayImgs.floats(in.dimensionsAsLongArray()); + gaussOp.compute(in, sigma, out); + return out; + } + + private static ArrayImg subtract( + final ArrayImg a, + final ArrayImg b) + { + final var out = ArrayImgs.floats(a.dimensionsAsLongArray()); + final var pa = a.update(null).getCurrentStorageArray(); + final var pb = b.update(null).getCurrentStorageArray(); + final var po = out.update(null).getCurrentStorageArray(); + for (var i = 0; i < po.length; i++) + po[i] = pa[i] - pb[i]; + return out; + } + + /** + * Correlates with a 3x3 kernel (row-major, first row at {@code y - 1}), + * replicating the edge pixels, as ImageJ's {@code convolve3x3} does. + */ + private static float[] convolve3x3(final float[] p, final int w, + final int h, final int[] kernel) + { + final var out = new float[w * h]; + for (var y = 0; y < h; y++) { + for (var x = 0; x < w; x++) { + var sum = 0.0; + var k = 0; + for (var dy = -1; dy <= 1; dy++) { + final var yy = Math.min(h - 1, Math.max(0, y + dy)); + for (var dx = -1; dx <= 1; dx++) { + final var xx = Math.min(w - 1, Math.max(0, x + dx)); + sum += kernel[k++] * p[xx + yy * w]; + } + } + out[x + y * w] = (float) sum; + } + } + return out; + } + + /** + * Validated mean over a disk of radius {@code rAv}: the (optionally + * weighted) mean of the valid values within the disk, or NaN if fewer than + * 2/3 of the disk pixels inside the image are valid or if the standard + * deviation of the valid values exceeds {@code sigmaMax}. With weights, + * values with zero weight are invalid. + */ + private static void average(final int w, final int h, final int rAv, + final double sigmaMax, final float[] data, final float[] weight, + final float[] av) + { + final var varMax = sigmaMax * sigmaMax; + final var maskWidth = 2 * rAv + 1; + final var mask = new boolean[maskWidth * maskWidth]; + var m = 0; + for (var jm = -rAv; jm <= rAv; jm++) { + for (var im = -rAv; im <= rAv; im++) { + mask[m++] = Math.sqrt(im * im + jm * jm) <= rAv; + } + } + final var x = new float[maskWidth * maskWidth]; + final var wt = new float[maskWidth * maskWidth]; + for (var jCent = 0; jCent < h; jCent++) { + for (var iCent = 0; iCent < w; iCent++) { + var nRoi = 0; + var nValid = 0; + m = 0; + for (var jm = -rAv; jm <= rAv; jm++) { + for (var im = -rAv; im <= rAv; im++) { + if (!mask[m++]) continue; + final var j = jCent + jm; + final var i = iCent + im; + if (j < 0 || j >= h || i < 0 || i >= w) continue; + final var ind = i + j * w; + nRoi++; + final var d = data[ind]; + if (Float.isNaN(d)) continue; + if (weight != null && !(weight[ind] > 0)) continue; + x[nValid] = d; + wt[nValid++] = weight == null ? 1 : weight[ind]; + } + } + final var ind = iCent + jCent * w; + av[ind] = Float.NaN; + // Note: integer division, as in the original plugin. + if (nValid == 0 || nValid < 2 * nRoi / 3) continue; + var sum = 0.0; + var wSum = 0.0; + for (var s = 0; s < nValid; s++) { + sum += wt[s] * x[s]; + wSum += wt[s]; + } + final var mu = sum / wSum; + var sumSq = 0.0; + for (var s = 0; s < nValid; s++) { + sumSq += wt[s] * (x[s] - mu) * (x[s] - mu); + } + if (sumSq / wSum <= varMax) av[ind] = (float) mu; + } + } + } + + private static RandomAccessibleInterval wrap(final float[] data, + final int w, final int h) + { + return ArrayImgs.floats(data, w, h); + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/filter/kalman/DefaultKalmanFilter.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/filter/kalman/DefaultKalmanFilter.java new file mode 100644 index 000000000..148d1c522 --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/filter/kalman/DefaultKalmanFilter.java @@ -0,0 +1,141 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.filter.kalman; + +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.img.array.ArrayImgs; +import net.imglib2.loops.LoopBuilder; +import net.imglib2.type.numeric.RealType; +import net.imglib2.util.Intervals; +import net.imglib2.view.Views; + +import org.scijava.function.Computers; +import org.scijava.ops.spi.Nullable; + +/** + * Kalman stack filter: recursive per-pixel denoising of a time-lapse (or z) + * series along one dimension. + *

+ * This op follows the algorithm documented for the Kalman stack filter for + * ImageJ by C. P. Mauer (imagej.net/ij/plugins/kalman). + * Each pixel is modelled as a constant state observed through noisy + * measurements {@code z_t} (the successive frames) with noise variance + * {@code R}. The estimate is seeded with the first frame, {@code x_0 = z_0}, + * and the error variance with the noise variance, {@code P_0 = R}. For each + * later frame, the previous estimate {@code x_(t-1)} is the prediction, and + *

+ *
+ * K_t = P_(t-1) / (P_(t-1) + R)
+ * x_t = g x_(t-1) + (1 - g) z_t + K_t (z_t - x_(t-1))
+ * P_t = (1 - K_t) P_(t-1)
+ * 
+ *

+ * where {@code g} is the filter gain. The output frame {@code t} is the + * estimate {@code x_t}. Since {@code P_0 = R}, the Kalman gain is + * {@code K_t = 1 / (t + 1)} whatever the value of {@code R}, so the noise + * variance does not affect the result and is not a parameter of this op. + * With {@code g = 1}, the estimate is the running mean of all + * frames so far. For smaller {@code g}, the Kalman term vanishes over time and + * the estimate converges to an exponential moving average with weight + * {@code 1 - g} on the newest frame. Each update moves the estimate by the + * fraction {@code 1 - g + K_t} of the residual {@code z_t - x_(t-1)}; this + * fraction exceeds 1, so that the estimate overshoots, if {@code g < K_t}. + * Results are clamped to the range of the output type. + *

+ *

+ * Implemented from the documented algorithm, whose code was not consulted; + * see also R. E. Kalman, "A new approach to linear filtering and prediction + * problems", J. Basic Eng. 82(1):35-45, 1960; and G. Welch and G. Bishop, "An + * introduction to the Kalman filter", UNC-Chapel Hill TR 95-041. + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @param input pixel type + * @param output pixel type + * @implNote op names='filter.kalman' + */ +public class DefaultKalmanFilter, V extends RealType> + implements + Computers.Arity3, Integer, Double, RandomAccessibleInterval> +{ + + /** + * Applies the Kalman filter along one dimension. + * + * @param input the image series + * @param dim the dimension along which to filter, e.g. time or z (default: + * the last dimension) + * @param gain the filter gain g within [0, 1]: the weight of the prediction + * against the newest frame (default: 0.8) + * @param output the filtered series, with the same dimensions as the input + */ + @Override + public void compute(final RandomAccessibleInterval input, + @Nullable final Integer dim, @Nullable final Double gain, + final RandomAccessibleInterval output) + { + final int d = dim == null ? input.numDimensions() - 1 : dim; + final double g = gain == null ? 0.8 : gain; + if (d < 0 || d >= input.numDimensions()) { + throw new IllegalArgumentException("No such dimension: " + d); + } + if (!(g >= 0 && g <= 1)) { + throw new IllegalArgumentException("Gain must be within [0, 1]: " + g); + } + if (!Intervals.equalDimensions(input, output)) { + throw new IllegalArgumentException( + "Input and output must have the same dimensions"); + } + + final var first = Views.hyperSlice(input, d, input.min(d)); + final var estimate = ArrayImgs.doubles(first.dimensionsAsLongArray()); + final var frames = input.dimension(d); + for (long t = 0; t < frames; t++) { + final var z = Views.hyperSlice(input, d, input.min(d) + t); + final var out = Views.hyperSlice(output, d, output.min(d) + t); + final double a; + if (t == 0) { + a = 1; + } + else { + // x_t = g x + (1 - g) z + K (z - x) = x + (1 - g + K) (z - x) + a = 1 - g + 1.0 / (t + 1); + } + LoopBuilder.setImages(z, estimate, out).multiThreaded().forEachPixel(( + zi, x, o) -> { + final var v = x.get() + a * (zi.getRealDouble() - x.get()); + x.set(v); + o.setReal(Math.max(o.getMinValue(), Math.min(o.getMaxValue(), v))); + }); + } + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/filter/kuwahara/DefaultKuwahara.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/filter/kuwahara/DefaultKuwahara.java new file mode 100644 index 000000000..3f7a03774 --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/filter/kuwahara/DefaultKuwahara.java @@ -0,0 +1,198 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.filter.kuwahara; + +import java.util.Arrays; + +import net.imglib2.FinalInterval; +import net.imglib2.RandomAccess; +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.img.array.ArrayImgs; +import net.imglib2.outofbounds.OutOfBoundsFactory; +import net.imglib2.outofbounds.OutOfBoundsMirrorFactory; +import net.imglib2.type.numeric.RealType; +import net.imglib2.type.numeric.real.DoubleType; +import net.imglib2.util.Intervals; +import net.imglib2.view.Views; + +import org.scijava.function.Computers; +import org.scijava.ops.spi.Nullable; +import org.scijava.ops.spi.OpDependency; + +/** + * Kuwahara filter: edge-preserving smoothing in n dimensions. + *

+ * The {@code size}n window around each pixel is split into the + * 2n overlapping sub-windows (quadrants in 2D, octants in 3D) of + * side {@code (size + 1) / 2} that have the pixel as a corner. The output is + * the mean of the sub-window with the lowest variance (the first one, in case + * of ties). Window means and variances are computed in constant time from + * integral images of the values and of their squares. + *

+ *

+ * Implemented from the description by M. Kuwahara, K. Hachimura, S. Eiho and + * M. Kinoshita, "Processing of RI-angiocardiographic images", in K. Preston and + * M. Onoe (eds.), Digital Processing of Biomedical Images, Plenum Press, pp. + * 187-202, 1976. For 2D 8-bit images, the result agrees with the Fiji + * Kuwahara_Filter plugin up to rounding, away from the image border. + *

+ *

+ * Results are clamped to the range of the output type, so an integer output + * saturates rather than wrapping around. + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @param input pixel type + * @param output pixel type + * @implNote op names='filter.kuwahara' + */ +public class DefaultKuwahara, V extends RealType> + implements + Computers.Arity3, Integer, OutOfBoundsFactory>, RandomAccessibleInterval> +{ + + @OpDependency(name = "image.integral") + private Computers.Arity1, RandomAccessibleInterval> integralOp; + + @OpDependency(name = "image.squareIntegral") + private Computers.Arity1, RandomAccessibleInterval> squareIntegralOp; + + /** + * Applies the Kuwahara filter. + * + * @param input the image to filter + * @param size the (odd) side length of the full window, in pixels (default: + * 5) + * @param outOfBoundsFactory the strategy for values beyond the image border + * (default: mirror, without repeating the border pixel) + * @param output the filtered image + */ + @Override + public void compute(final RandomAccessibleInterval input, + @Nullable final Integer size, + @Nullable OutOfBoundsFactory> outOfBoundsFactory, + final RandomAccessibleInterval output) + { + final int windowSize = size == null ? 5 : size; + if (windowSize < 3 || windowSize % 2 == 0) { + throw new IllegalArgumentException( + "Window size must be odd and at least 3: " + windowSize); + } + if (!Intervals.equalDimensions(input, output)) { + throw new IllegalArgumentException( + "Input and output must have the same dimensions"); + } + if (outOfBoundsFactory == null) outOfBoundsFactory = + new OutOfBoundsMirrorFactory<>(OutOfBoundsMirrorFactory.Boundary.SINGLE); + + final var n = input.numDimensions(); + final var r = (windowSize - 1) / 2; + + // The padded input, with zero min. + final var padded = Views.zeroMin(Views.interval(Views.extend(input, + outOfBoundsFactory), Intervals.expand(input, r))); + + // Integral images with a leading row of zeros along each dimension, so + // that sums[i] holds the sum of padded[0..i-1]. + final var dims = padded.dimensionsAsLongArray(); + final var integralDims = new long[n]; + for (var d = 0; d < n; d++) + integralDims[d] = dims[d] + 1; + final var sums = ArrayImgs.doubles(integralDims); + final var squares = ArrayImgs.doubles(integralDims); + final var inner = new FinalInterval(ones(n), dims); + integralOp.compute(padded, Views.zeroMin(Views.interval(sums, inner))); + squareIntegralOp.compute(padded, Views.zeroMin(Views.interval(squares, + inner))); + + final var sumAccess = sums.randomAccess(); + final var sqAccess = squares.randomAccess(); + final var subwindows = 1 << n; + final var count = Math.pow(r + 1, n); + final var lo = new long[n]; + final var hi = new long[n]; + final var outMin = output.minAsLongArray(); + final var cursor = Views.flatIterable(output).localizingCursor(); + while (cursor.hasNext()) { + cursor.fwd(); + var bestVar = Double.POSITIVE_INFINITY; + var bestMean = 0.0; + for (var w = 0; w < subwindows; w++) { + for (var d = 0; d < n; d++) { + // Position in padded coordinates is (pos - min + r). + final var c = cursor.getLongPosition(d) - outMin[d] + r; + lo[d] = (w >> d & 1) == 0 ? c - r : c; + hi[d] = lo[d] + r + 1; + } + final var mean = boxSum(sumAccess, lo, hi) / count; + final var variance = boxSum(sqAccess, lo, hi) / count - mean * mean; + if (variance < bestVar) { + bestVar = variance; + bestMean = mean; + } + } + final var o = cursor.get(); + o.setReal(Math.max(o.getMinValue(), Math.min(o.getMaxValue(), + bestMean))); + } + } + + /** + * Sums the values in the half-open box {@code [lo, hi)} using an integral + * image with a leading zero row, by inclusion-exclusion over its corners. + */ + private static double boxSum(final RandomAccess integral, + final long[] lo, final long[] hi) + { + final var n = lo.length; + var sum = 0.0; + for (var corner = 0; corner < 1 << n; corner++) { + var sign = 1; + for (var d = 0; d < n; d++) { + if ((corner >> d & 1) == 0) { + integral.setPosition(hi[d], d); + } + else { + integral.setPosition(lo[d], d); + sign = -sign; + } + } + sum += sign * integral.get().get(); + } + return sum; + } + + private static long[] ones(final int n) { + final var a = new long[n]; + Arrays.fill(a, 1); + return a; + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/filter/kuwahara/DefaultLinearKuwahara.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/filter/kuwahara/DefaultLinearKuwahara.java new file mode 100644 index 000000000..b660b7384 --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/filter/kuwahara/DefaultLinearKuwahara.java @@ -0,0 +1,110 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.filter.kuwahara; + +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.outofbounds.OutOfBoundsFactory; +import net.imglib2.type.numeric.RealType; +import net.imglib2.type.numeric.real.DoubleType; + +import org.scijava.function.Computers; +import org.scijava.ops.spi.Nullable; + +/** + * Linear Kuwahara filter: edge- and line-preserving smoothing with line + * segments searched over evenly spaced orientations. + *

+ * For each pixel, every line segment of {@code lineLength} pixels that + * contains the pixel, at each of the orientations {@code kπ/nAngles}, is a + * candidate; the output is the mean of the candidate with the lowest + * criterion (by default, the lowest variance). Thin oriented structures such + * as fibres and membranes are smoothed along their length without being + * blurred across. Lines lie in the plane of dimensions 0 and 1; further + * dimensions are filtered plane by plane. See {@link LinearKuwahara} for + * details, and the {@code filter.kuwaharaLinear} variant taking an orientation + * image (e.g. from {@code filter.orientation}) for the orientation-adaptive + * filter. + *

+ *

+ * Implemented from P. Bakker, L. J. van Vliet and P. W. Verbeek, "Edge + * preserving orientation adaptive filtering", Proc. IEEE CVPR 1999, pp. + * 535-540. + *

+ *

+ * Results are clamped to the range of the output type, so an integer output + * saturates rather than wrapping around. + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @param input pixel type + * @param output pixel type + * @implNote op names='filter.kuwaharaLinear' + */ +public class DefaultLinearKuwahara, V extends RealType> + implements + Computers.Arity5, Integer, Integer, String, OutOfBoundsFactory>, RandomAccessibleInterval> +{ + + /** + * Applies the linear Kuwahara filter. + * + * @param input the image to filter (at least 2D) + * @param nAngles the number of evenly spaced line orientations (default: 30) + * @param lineLength the (odd) length of the line segments, in pixels + * (default: 11) + * @param criterion the criterion minimized by the chosen segment: variance, + * varianceOverMean (for Poisson noise) or varianceOverMeanSquared + * (for multiplicative noise) (default: variance) + * @param outOfBoundsFactory the strategy for values beyond the image border + * (default: mirror, without repeating the border pixel) + * @param output the filtered image + */ + @Override + public void compute(final RandomAccessibleInterval input, + @Nullable final Integer nAngles, @Nullable final Integer lineLength, + @Nullable final String criterion, + @Nullable final OutOfBoundsFactory> outOfBoundsFactory, + final RandomAccessibleInterval output) + { + final int n = nAngles == null ? 30 : nAngles; + if (n < 1) { + throw new IllegalArgumentException("Number of angles must be positive: " + + n); + } + final var angles = new double[n]; + for (var k = 0; k < n; k++) + angles[k] = Math.PI * k / n; + LinearKuwahara. filter(input, angles, null, + lineLength == null ? 11 + : lineLength, LinearKuwahara.Criterion.byName(criterion), + outOfBoundsFactory, output); + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/filter/kuwahara/LinearKuwahara.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/filter/kuwahara/LinearKuwahara.java new file mode 100644 index 000000000..c1bdad1c1 --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/filter/kuwahara/LinearKuwahara.java @@ -0,0 +1,252 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.filter.kuwahara; + +import java.util.Locale; + +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.RealRandomAccess; +import net.imglib2.interpolation.randomaccess.NLinearInterpolatorFactory; +import net.imglib2.outofbounds.OutOfBoundsFactory; +import net.imglib2.outofbounds.OutOfBoundsMirrorFactory; +import net.imglib2.type.numeric.RealType; +import net.imglib2.util.Intervals; +import net.imglib2.view.Views; + +/** + * The linear Kuwahara filter, shared by the {@code filter.kuwaharaLinear} ops. + *

+ * The sub-windows of the classic Kuwahara filter are replaced by straight line + * segments of {@code lineLength} pixels in the plane of dimensions 0 and 1. For + * each pixel and each candidate orientation, the segments of that orientation + * which contain the pixel are those whose centres lie on the line through the + * pixel, at most {@code (lineLength - 1) / 2} pixels away. The output is the + * mean of the candidate segment that minimizes the selection criterion. As in + * a digital (Bresenham) line, consecutive samples of a segment are one pixel + * apart along its major axis, and values between pixels along the minor axis + * are linearly interpolated; offsets along a line are measured in the same + * steps. + *

+ *

+ * Implemented from the description of generalized, orientation-adaptive + * Kuwahara filtering by P. Bakker, L. J. van Vliet and P. W. Verbeek, "Edge + * preserving orientation adaptive filtering", Proc. IEEE CVPR 1999, pp. + * 535-540. + *

+ *

+ * Results are clamped to the range of the output type, so an integer output + * saturates rather than wrapping around. + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +public final class LinearKuwahara { + + private LinearKuwahara() { + // NB: Prevent instantiation of utility class. + } + + /** Criteria for choosing among candidate line segments. */ + public enum Criterion { + + /** The variance of the segment. */ + VARIANCE("variance"), + + /** + * The variance divided by the mean, suited to Poisson (shot) noise in + * non-negative images. + */ + VARIANCE_OVER_MEAN("varianceovermean", "poisson"), + + /** + * The variance divided by the squared mean (the squared coefficient of + * variation), suited to multiplicative noise in non-negative images. + */ + VARIANCE_OVER_MEAN_SQUARED("varianceovermeansquared", "cv", + "coefficientofvariation"); + + /** Smallest denominator, to avoid dividing by zero. */ + private static final double TINY = 1e-12; + + private final String[] names; + + Criterion(final String... names) { + this.names = names; + } + + /** + * Looks up a criterion by name, ignoring case, spaces, dashes and + * underscores. + * + * @param name {@code variance}, {@code varianceOverMean} (or + * {@code poisson}) or {@code varianceOverMeanSquared} (or + * {@code cv}); null for {@code variance} + * @return the criterion + */ + public static Criterion byName(final String name) { + if (name == null) return VARIANCE; + final var key = name.toLowerCase(Locale.ROOT).replaceAll("[\\s_\\-]", + ""); + for (final var c : values()) { + for (final var n : c.names) { + if (n.equals(key)) return c; + } + } + throw new IllegalArgumentException("Unknown criterion: " + name); + } + + double evaluate(final double mean, final double variance) { + switch (this) { + case VARIANCE: + return variance; + case VARIANCE_OVER_MEAN: + return variance / Math.max(mean, TINY); + default: + return variance / Math.max(mean * mean, TINY); + } + } + } + + /** + * Applies the linear Kuwahara filter. + * + * @param input the image to filter (at least 2D) + * @param angles the candidate line orientations shared by all pixels, in + * radians counterclockwise from the x axis as displayed (y up); or + * null to use {@code orientation} + * @param orientation the line orientation of each pixel, in the same + * convention, with the same dimensions as the input (used only if + * {@code angles} is null) + * @param lineLength the (odd) length of the line segments, in pixels + * @param criterion the selection criterion + * @param outOfBoundsFactory the strategy for values beyond the image border + * (null for mirroring) + * @param output the filtered image + */ + static , R extends RealType, V extends RealType> + void filter(final RandomAccessibleInterval input, final double[] angles, + final RandomAccessibleInterval orientation, final int lineLength, + final Criterion criterion, + OutOfBoundsFactory> outOfBoundsFactory, + final RandomAccessibleInterval output) + { + if (input.numDimensions() < 2) { + throw new IllegalArgumentException("Input must be at least 2D"); + } + if (lineLength < 3 || lineLength % 2 == 0) { + throw new IllegalArgumentException( + "Line length must be odd and at least 3: " + lineLength); + } + if (!Intervals.equalDimensions(input, output)) { + throw new IllegalArgumentException( + "Input and output must have the same dimensions"); + } + if (angles == null && !Intervals.equalDimensions(input, orientation)) { + throw new IllegalArgumentException( + "Input and orientation must have the same dimensions"); + } + if (outOfBoundsFactory == null) outOfBoundsFactory = + new OutOfBoundsMirrorFactory<>(OutOfBoundsMirrorFactory.Boundary.SINGLE); + + final var n = input.numDimensions(); + final var r = (lineLength - 1) / 2; + // Samples at offsets -2r..2r along the line cover every segment of + // length 2r + 1 that contains the pixel. + final var nSamples = 4 * r + 1; + final var samples = new double[nSamples]; + final RealRandomAccess interp = Views.interpolate(Views.extend(input, + outOfBoundsFactory), new NLinearInterpolatorFactory()) + .realRandomAccess(); + + final var inMin = input.minAsLongArray(); + final var outMin = output.minAsLongArray(); + final var pos = new double[n]; + final var cursor = Views.flatIterable(output).localizingCursor(); + final var orientationCursor = angles == null ? Views.flatIterable( + orientation).cursor() : null; + final var theta = angles == null ? new double[1] : angles; + while (cursor.hasNext()) { + cursor.fwd(); + if (orientationCursor != null) { + theta[0] = orientationCursor.next().getRealDouble(); + } + var bestCrit = Double.POSITIVE_INFINITY; + var bestMean = 0.0; + for (final var angle : theta) { + // Note: Image y increases downwards, so it is opposite to the + // displayed y axis of the angle convention. + final var cos = Math.cos(angle); + final var sin = Math.sin(angle); + // Note: Unit steps along the major axis, as in a digital line, keep + // diagonal samples on pixel centres. + final var major = Math.max(Math.abs(cos), Math.abs(sin)); + final var ux = cos / major; + final var uy = -sin / major; + for (var d = 0; d < n; d++) + pos[d] = cursor.getLongPosition(d) - outMin[d] + inMin[d]; + final var x0 = pos[0]; + final var y0 = pos[1]; + for (var i = 0; i < nSamples; i++) { + final var t = i - 2 * r; + pos[0] = x0 + t * ux; + pos[1] = y0 + t * uy; + interp.setPosition(pos); + samples[i] = interp.get().getRealDouble(); + } + // Sliding sums over the windows [s, s + 2r]. + var sum = 0.0; + var sumSq = 0.0; + for (var i = 0; i < lineLength; i++) { + sum += samples[i]; + sumSq += samples[i] * samples[i]; + } + for (var s = 0; s <= 2 * r; s++) { + if (s > 0) { + final var out = samples[s - 1]; + final var in = samples[s + 2 * r]; + sum += in - out; + sumSq += in * in - out * out; + } + final var mean = sum / lineLength; + final var variance = Math.max(0, sumSq / lineLength - mean * mean); + final var crit = criterion.evaluate(mean, variance); + if (crit < bestCrit) { + bestCrit = crit; + bestMean = mean; + } + } + } + final var o = cursor.get(); + o.setReal(Math.max(o.getMinValue(), Math.min(o.getMaxValue(), + bestMean))); + } + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/filter/kuwahara/OrientedLinearKuwahara.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/filter/kuwahara/OrientedLinearKuwahara.java new file mode 100644 index 000000000..78c8a2c8d --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/filter/kuwahara/OrientedLinearKuwahara.java @@ -0,0 +1,102 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.filter.kuwahara; + +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.outofbounds.OutOfBoundsFactory; +import net.imglib2.type.numeric.RealType; + +import org.scijava.function.Computers; +import org.scijava.ops.spi.Nullable; + +/** + * Orientation-adaptive linear Kuwahara filter: the line segments at each + * pixel follow a given local orientation, such as the output of + * {@code filter.orientation} (the dominant orientation from the structure + * tensor). + *

+ * For each pixel, the candidates are the line segments of {@code lineLength} + * pixels along the local orientation that contain the pixel; the output is + * the mean of the candidate with the lowest criterion. This is the + * orientation-adaptive filter of Bakker, van Vliet and Verbeek: it smooths + * along oriented structures, and the Kuwahara-style selection of the shift + * keeps edges sharp where the line ends. See {@link LinearKuwahara} and + * {@link DefaultLinearKuwahara}. + *

+ *

+ * Implemented from P. Bakker, L. J. van Vliet and P. W. Verbeek, "Edge + * preserving orientation adaptive filtering", Proc. IEEE CVPR 1999, pp. + * 535-540. + *

+ *

+ * Results are clamped to the range of the output type, so an integer output + * saturates rather than wrapping around. + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @param input pixel type + * @param orientation pixel type + * @param output pixel type + * @implNote op names='filter.kuwaharaLinear' + */ +public class OrientedLinearKuwahara, R extends RealType, V extends RealType> + implements + Computers.Arity5, RandomAccessibleInterval, Integer, String, OutOfBoundsFactory>, RandomAccessibleInterval> +{ + + /** + * Applies the orientation-adaptive linear Kuwahara filter. + * + * @param input the image to filter (at least 2D) + * @param orientation the line orientation at each pixel, in radians + * counterclockwise from the x axis as displayed (y up), as + * computed by filter.orientation; same dimensions as the input + * @param lineLength the (odd) length of the line segments, in pixels + * (default: 11) + * @param criterion the criterion minimized by the chosen segment: variance, + * varianceOverMean (for Poisson noise) or varianceOverMeanSquared + * (for multiplicative noise) (default: variance) + * @param outOfBoundsFactory the strategy for values beyond the image border + * (default: mirror, without repeating the border pixel) + * @param output the filtered image + */ + @Override + public void compute(final RandomAccessibleInterval input, + final RandomAccessibleInterval orientation, + @Nullable final Integer lineLength, @Nullable final String criterion, + @Nullable final OutOfBoundsFactory> outOfBoundsFactory, + final RandomAccessibleInterval output) + { + LinearKuwahara.filter(input, null, orientation, lineLength == null ? 11 + : lineLength, LinearKuwahara.Criterion.byName(criterion), + outOfBoundsFactory, output); + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/filter/localCoefficientOfVariation/DefaultLocalCoefficientOfVariation.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/filter/localCoefficientOfVariation/DefaultLocalCoefficientOfVariation.java new file mode 100644 index 000000000..b799382e5 --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/filter/localCoefficientOfVariation/DefaultLocalCoefficientOfVariation.java @@ -0,0 +1,190 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.filter.localCoefficientOfVariation; + +import java.util.function.BiFunction; + +import net.imglib2.Dimensions; +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.converter.Converter; +import net.imglib2.converter.Converters; +import net.imglib2.loops.LoopBuilder; +import net.imglib2.type.numeric.RealType; +import net.imglib2.type.numeric.real.DoubleType; +import net.imglib2.type.numeric.real.FloatType; +import net.imglib2.util.Intervals; +import net.imglib2.view.Views; + +import org.scijava.function.Computers; +import org.scijava.ops.spi.OpDependency; + +/** + * Local contrast filter of PHANTAST: the local coefficient of variation of an + * image, i.e. the Gaussian-weighted local standard deviation divided by the + * Gaussian-weighted local mean, + * {@code C = sqrt(G * I² - (G * I)²) / (G * I)}, where {@code G} is a + * Gaussian kernel with standard deviation {@code sigma}. Pixels whose local + * mean is not positive are set to 0. The local moments are computed in double + * precision, relative to the global mean, so that the result is accurate for + * inputs with a large intensity offset, such as 16-bit images. + *

+ * In phase contrast microscopy, cells appear as textured regions with high + * local contrast against a smooth background; thresholding this image is the + * core of the PHANTAST segmentation (see {@code segment.phantast}). + *

+ *

+ * Note: this is unrelated to {@code threshold.localContrast}, which is a local + * thresholding method that classifies each pixel by whether it is closer to + * the minimum or the maximum of its neighborhood. This op is a continuous, + * scale-invariant measure of local intensity variability that works on images + * of any dimensionality. + *

+ *

+ * Translated from {@code PHANTAST_.getLocalContrastImage} of the PHANTAST + * plugin for Fiji (PHANTAST-FIJI), Copyright (c) + * 2013-2014, Nicolas Jaccard, Department of Biochemical Engineering and CoMPLEX, + * University College London, used under the BSD 3-Clause license. Reference: + * N. Jaccard, L. D. Griffin, A. Keser, R. J. Macown, A. Super, F. S. Veraitch + * and N. Szita, "Automated method for the rapid and precise estimation of + * adherent cell culture characteristics from phase contrast microscopy + * images", Biotechnology and Bioengineering 111(3):504-517, 2014. + *

+ *

+ * The original PHANTAST license notice follows. + *

+ * + *
+ * Copyright (c) 2013, Nicolas Jaccard
+ * Department of Biochemical Engineering, UCL
+ * Centre for Mathematics and Physics in the Life Sciences and Experimental
+ * Biology, UCL
+ * The British Heart Foundation
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are
+ * met:
+ *
+ *   * Redistributions of source code must retain the above copyright
+ *     notice, this list of conditions and the following disclaimer.
+ *   * 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
+ *   * Neither the names of the University College London or British Heart
+ *     Foundation nor the names of its contributors may be used to endorse
+ *     or promote products derived from this software without specific
+ *     prior written permission.
+ *
+ * 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 OWNER 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.
+ * 
+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @author Nicolas Jaccard + * @param the input type + * @implNote op names='filter.localCoefficientOfVariation' + */ +public class DefaultLocalCoefficientOfVariation> implements + BiFunction, Double, RandomAccessibleInterval> +{ + + @OpDependency(name = "filter.gauss") + private Computers.Arity2, Double, RandomAccessibleInterval> gaussOp; + + @OpDependency(name = "create.img") + private BiFunction> createDoubleOp; + + @OpDependency(name = "create.img") + private BiFunction> createOp; + + /** + * Computes the local contrast image. + * + * @param input the input image + * @param sigma the standard deviation, in pixels, of the Gaussian window + * over which the local mean and standard deviation are computed + * (PHANTAST default: 1.2) + * @return the local contrast (coefficient of variation) image + */ + @Override + public RandomAccessibleInterval apply( + final RandomAccessibleInterval input, final Double sigma) + { + if (sigma == null || sigma <= 0) { + throw new IllegalArgumentException("sigma must be positive"); + } + final var zeroMin = Views.zeroMin(input); + + // Note: The variance E[I²] - E[I]² cancels catastrophically for inputs + // with a large offset, such as 16-bit data, so the intensities are + // shifted by their global mean and blurred in double precision. + var sum = 0.0; + for (final var t : Views.iterable(zeroMin)) + sum += t.getRealDouble(); + final var offset = sum / Intervals.numElements(zeroMin); + final var shifted = Converters.convert(zeroMin, + (Converter) (in, out) -> out.set(in.getRealDouble() - + offset), new DoubleType()); + final var squared = Converters.convert(zeroMin, + (Converter) (in, out) -> { + final var v = in.getRealDouble() - offset; + out.set(v * v); + }, new DoubleType()); + + final var mean = createDoubleOp.apply(zeroMin, new DoubleType()); + gaussOp.compute(shifted, sigma, mean); + final var meanSquare = createDoubleOp.apply(zeroMin, new DoubleType()); + gaussOp.compute(squared, sigma, meanSquare); + + final var output = createOp.apply(zeroMin, new FloatType()); + LoopBuilder.setImages(mean, meanSquare, output).forEachPixel((m, m2, + out) -> { + final var mu = m.get() + offset; + if (mu > 0) { + final var variance = Math.max(0, m2.get() - m.get() * m.get()); + out.setReal(Math.sqrt(variance) / mu); + } + else out.setZero(); + }); + return output; + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/filter/phaseCorrelation/DefaultPhaseCorrelate.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/filter/phaseCorrelation/DefaultPhaseCorrelate.java new file mode 100644 index 000000000..0076c684d --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/filter/phaseCorrelation/DefaultPhaseCorrelate.java @@ -0,0 +1,437 @@ +/*- + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.filter.phaseCorrelation; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.PriorityQueue; +import java.util.function.BiFunction; + +import net.imglib2.Dimensions; +import net.imglib2.FinalDimensions; +import net.imglib2.FinalInterval; +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.algorithm.fft2.FFTMethods; +import net.imglib2.algorithm.neighborhood.RectangleShape; +import net.imglib2.img.Img; +import net.imglib2.loops.LoopBuilder; +import net.imglib2.type.numeric.RealType; +import net.imglib2.type.numeric.complex.ComplexFloatType; +import net.imglib2.type.numeric.real.DoubleType; +import net.imglib2.type.numeric.real.FloatType; +import net.imglib2.view.Views; + +import org.scijava.function.Computers; +import org.scijava.function.Functions; +import org.scijava.ops.spi.Nullable; +import org.scijava.ops.spi.OpDependency; + +/** + * Estimates the translation between two n-dimensional images by phase + * correlation (Kuglin & Hines (1975), "The phase correlation image + * alignment method", Proc. IEEE Conf. on Cybernetics and Society, 163-165), + * with the candidate verification of Preibisch, Saalfeld & Tomancak + * (2009), "Globally optimal stitching of tiled 3D microscopic image + * acquisitions", Bioinformatics 25(11):1463-1465. + *

+ * Both images are mean-subtracted and zero-padded to a common size {@code N} + * suitable for the FFT. The inverse Fourier transform of their normalized + * cross-power spectrum, the phase correlation matrix, has a peak at the shift + * between the images. Each frequency of the cross-power spectrum is divided + * by its magnitude plus {@value #REGULARIZATION} times the largest magnitude, + * so that frequencies without signal (as in smooth or noisy images) do not + * get the same weight as the others. Because the phase correlation matrix is + * periodic, a peak at {@code p} means a shift of either {@code p_d} or + * {@code p_d - N_d} in each dimension {@code d}. Every shift that each of the + * {@code numPeaks} highest local maxima of the matrix could mean is verified + * by the Pearson correlation of the images on their overlap, and the shift + * with the highest correlation wins. + *

+ *

+ * Since the peak of the phase correlation matrix can be off by a pixel or + * two, the two candidate shifts with the highest + * correlation are first moved to the nearest integer shift + * (within a few pixels) at which the correlation is locally maximal. With + * subpixel localization, the winning shift is then refined in each dimension + * by fitting a parabola to the correlation at that shift and its two + * neighbors in that dimension. Fitting the cross-correlation rather than the + * phase correlation matrix is much more robust to noise, since the + * normalization of the cross-power spectrum amplifies the noise of the + * frequencies where the images have little signal. + *

+ *

+ * Implemented from the papers. Unlike the Fiji stitching plugins, no window + * function is applied before the FFT (a cosine taper degraded the estimates + * in testing), the cross-power spectrum is regularized as described above + * rather than purely normalized, and subpixel shifts come from the Pearson + * correlation instead of a quadratic fit of the phase correlation matrix. + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @param type of the first image + * @param type of the second image + * @implNote op names='filter.phaseCorrelate' + */ +public class DefaultPhaseCorrelate, U extends RealType> + implements + Functions.Arity5, RandomAccessibleInterval, Integer, Boolean, Long, PhaseCorrelationResult> +{ + + /** + * The fraction of the largest magnitude of the cross-power spectrum which + * is added to the magnitude of each frequency before normalizing it. + */ + public static final double REGULARIZATION = 0.01; + + /** The number of verified candidate shifts refined by hill climbing. */ + private static final int CLIMB_CANDIDATES = 2; + + @OpDependency(name = "stats.mean") + private Computers.Arity1, DoubleType> mean1Op; + + @OpDependency(name = "stats.mean") + private Computers.Arity1, DoubleType> mean2Op; + + @OpDependency(name = "create.img") + private BiFunction> createImg; + + @OpDependency(name = "filter.createFFTOutput") + private Functions.Arity3> createFFTOutput; + + @OpDependency(name = "filter.fft") + private Computers.Arity1, RandomAccessibleInterval> fftOp; + + @OpDependency(name = "filter.ifft") + private Computers.Arity1, RandomAccessibleInterval> ifftOp; + + @OpDependency(name = "coloc.pearsons") + private BiFunction, Iterable, Double> pearsonsOp; + + /** + * Estimates the translation between two images. + * + * @param image1 the first (reference) image + * @param image2 the second image, of the same dimensionality but possibly a + * different size; both images are taken to start at the origin + * @param numPeaks the number of phase correlation peaks to verify (default + * 5) + * @param subpixel whether to localize the shift to subpixel accuracy + * (default true) + * @param minOverlap the minimum number of overlapping pixels for a shift to + * be considered (default: 5 percent of the pixels of the smaller + * image, and at least 2) + * @return the shift {@code s} such that {@code image2(x) = image1(x - s)}, + * with the Pearson correlation on the overlap + */ + @Override + public PhaseCorrelationResult apply(final RandomAccessibleInterval image1, + final RandomAccessibleInterval image2, @Nullable Integer numPeaks, + @Nullable Boolean subpixel, @Nullable Long minOverlap) + { + final var n = image1.numDimensions(); + if (image2.numDimensions() != n) throw new IllegalArgumentException( + "The images must have the same number of dimensions."); + if (numPeaks == null) numPeaks = 5; + if (numPeaks < 1) throw new IllegalArgumentException( + "At least one peak must be verified."); + if (subpixel == null) subpixel = true; + if (minOverlap == null) { + final var smaller = Math.min(image1.size(), image2.size()); + minOverlap = Math.max(2, (long) Math.ceil(0.05 * smaller)); + } + + final var zero1 = Views.zeroMin(image1); + final var zero2 = Views.zeroMin(image2); + final var dims1 = zero1.dimensionsAsLongArray(); + final var dims2 = zero2.dimensionsAsLongArray(); + final var maxDims = new long[n]; + for (var d = 0; d < n; d++) + maxDims[d] = Math.max(dims1[d], dims2[d]); + final var padded = new long[n]; + FFTMethods.dimensionsRealToComplexFast(new FinalDimensions(maxDims), + padded, new long[n]); + final var paddedDims = new FinalDimensions(padded); + + // Compute the phase correlation matrix. + final var fft1 = transform(zero1, mean(mean1Op, zero1), paddedDims); + final var fft2 = transform(zero2, mean(mean2Op, zero2), paddedDims); + LoopBuilder.setImages(fft1, fft2).forEachPixel((a, b) -> { + // b * conj(a) + final var re = b.getRealDouble() * a.getRealDouble() + b + .getImaginaryDouble() * a.getImaginaryDouble(); + final var im = b.getImaginaryDouble() * a.getRealDouble() - b + .getRealDouble() * a.getImaginaryDouble(); + b.setComplexNumber(re, im); + }); + var maxMagnitude = 0.0; + for (final var c : Views.iterable(fft2)) + maxMagnitude = Math.max(maxMagnitude, c.getPowerDouble()); + final var epsilon = REGULARIZATION * maxMagnitude; + for (final var c : Views.iterable(fft2)) { + final var magnitude = c.getPowerDouble() + epsilon; + if (magnitude > 0) c.mul(1 / magnitude); + } + final var pcm = createImg.apply(paddedDims, new FloatType()); + ifftOp.compute(fft2, pcm); + + // Find the highest local maxima, with periodic boundaries. + final var neighborhoods = new RectangleShape(1, true) + .neighborhoodsRandomAccessible(Views.extendPeriodic(pcm)).randomAccess(); + final var heap = new PriorityQueue((a, b) -> Double.compare(a.value, + b.value)); + final var cursor = pcm.localizingCursor(); + while (cursor.hasNext()) { + final var v = cursor.next().get(); + if (heap.size() == numPeaks && v <= heap.peek().value) continue; + neighborhoods.setPosition(cursor); + var isMax = true; + for (final var neighbor : neighborhoods.get()) { + if (neighbor.get() > v) { + isMax = false; + break; + } + } + if (!isMax) continue; + heap.add(new Peak(cursor.positionAsLongArray(), v)); + if (heap.size() > numPeaks) heap.poll(); + } + final List peaks = new ArrayList<>(heap); + + // Verify each possible shift of each peak by cross-correlation. + final var verifier = new Verifier(zero1, zero2, minOverlap); + final var candidates = new ArrayList(); + for (final var peak : peaks) { + for (var variant = 0; variant < 1 << n; variant++) { + final var shift = new long[n]; + for (var d = 0; d < n; d++) { + shift[d] = (variant >> d & 1) == 0 ? peak.position[d] : // + peak.position[d] - padded[d]; + } + final var r = verifier.correlation(shift); + if (!Double.isNaN(r)) candidates.add(new Candidate(shift, r, + peak.value)); + } + } + + // Note: Hill climbing costs up to 1 + MAX_STEPS * 2n correlations per + // candidate, so only the best few verified candidates are refined. + candidates.sort((a, b) -> Double.compare(b.r, a.r)); + var bestR = Double.NaN; + var bestPeak = Double.NaN; + long[] best = null; + for (final var c : candidates.subList(0, Math.min(CLIMB_CANDIDATES, + candidates.size()))) + { + final var r = verifier.climb(c.shift); + if (best == null || r > bestR) { + bestR = r; + bestPeak = c.peak; + best = c.shift; + } + } + if (best == null) { + return new PhaseCorrelationResult(new double[n], Double.NaN, Double.NaN, + 0); + } + + final var result = new double[n]; + if (subpixel) { + // Fit a parabola in each dimension. + for (var d = 0; d < n; d++) { + best[d]--; + final var below = verifier.correlation(best); + best[d] += 2; + final var above = verifier.correlation(best); + best[d]--; + final var curvature = below + above - 2 * bestR; + if (curvature < 0) { + final var offset = (below - above) / (2 * curvature); + if (Math.abs(offset) <= 0.5) result[d] = offset; + } + } + } + for (var d = 0; d < n; d++) + result[d] += best[d]; + return new PhaseCorrelationResult(result, bestR, bestPeak, verifier + .overlap(best)); + } + + private > double mean( + final Computers.Arity1, DoubleType> meanOp, + final RandomAccessibleInterval image) + { + final var mean = new DoubleType(); + meanOp.compute(Views.iterable(image), mean); + return mean.get(); + } + + /** + * Copies the mean-subtracted image into the origin of a zero image of the + * padded size, and transforms it. + */ + private > RandomAccessibleInterval + transform(final RandomAccessibleInterval image, final double mean, + final Dimensions paddedDims) + { + final var padded = createImg.apply(paddedDims, new FloatType()); + LoopBuilder.setImages(image, Views.interval(padded, image)).forEachPixel(( + in, out) -> out.setReal(in.getRealDouble() - mean)); + final var fft = createFFTOutput.apply(paddedDims, new ComplexFloatType(), + true); + fftOp.compute(padded, fft); + return fft; + } + + private static class Candidate { + + private final long[] shift; + private final double r; + private final double peak; + + private Candidate(final long[] shift, final double r, final double peak) { + this.shift = shift; + this.r = r; + this.peak = peak; + } + } + + private static class Peak { + + private final long[] position; + private final double value; + + private Peak(final long[] position, final double value) { + this.position = position; + this.value = value; + } + } + + /** Computes the Pearson correlation of the images on their overlap. */ + private class Verifier { + + private static final int MAX_STEPS = 3; + + private final RandomAccessibleInterval image1; + private final RandomAccessibleInterval image2; + private final long minOverlap; + private final Map, Double> cache = new HashMap<>(); + + private Verifier(final RandomAccessibleInterval image1, + final RandomAccessibleInterval image2, final long minOverlap) + { + this.image1 = image1; + this.image2 = image2; + this.minOverlap = minOverlap; + } + + /** Gets the overlap of image2 and image1 shifted by the given vector. */ + private FinalInterval interval(final long[] shift) { + final var n = shift.length; + final var min = new long[n]; + final var max = new long[n]; + for (var d = 0; d < n; d++) { + min[d] = Math.max(0, shift[d]); + max[d] = Math.min(image2.dimension(d), image1.dimension(d) + + shift[d]) - 1; + } + return new FinalInterval(min, max); + } + + private long overlap(final long[] shift) { + final var interval = interval(shift); + long size = 1; + for (var d = 0; d < shift.length; d++) + size *= Math.max(0, interval.dimension(d)); + return size; + } + + /** + * Moves the shift, one pixel at a time and at most {@value #MAX_STEPS} + * times, to the neighbor with the highest correlation, until it is a + * local maximum of the correlation. + * + * @return the correlation at the final shift + */ + private double climb(final long[] shift) { + var r = correlation(shift); + if (Double.isNaN(r)) return r; + for (var step = 0; step < MAX_STEPS; step++) { + var bestD = -1; + var bestDelta = 0; + for (var d = 0; d < shift.length; d++) { + for (var delta = -1; delta <= 1; delta += 2) { + shift[d] += delta; + final var neighbor = correlation(shift); + shift[d] -= delta; + if (neighbor > r) { + r = neighbor; + bestD = d; + bestDelta = delta; + } + } + } + if (bestD < 0) break; + shift[bestD] += bestDelta; + } + return r; + } + + /** + * Gets the correlation under the given shift, or {@code NaN} if the + * overlap is too small or constant. + */ + private double correlation(final long[] shift) { + final var key = new ArrayList(shift.length); + for (final var v : shift) + key.add(v); + return cache.computeIfAbsent(key, k -> computeCorrelation(shift)); + } + + private double computeCorrelation(final long[] shift) { + for (var d = 0; d < shift.length; d++) + if (shift[d] >= image2.dimension(d) || // + shift[d] <= -image1.dimension(d)) return Double.NaN; + if (overlap(shift) < minOverlap) return Double.NaN; + final var interval = interval(shift); + try { + return pearsonsOp.apply( // + Views.flatIterable(Views.interval(Views.translate(image1, shift), + interval)), // + Views.flatIterable(Views.interval(image2, interval))); + } + catch (final IllegalArgumentException e) { + return Double.NaN; + } + } + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/filter/phaseCorrelation/PhaseCorrelationResult.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/filter/phaseCorrelation/PhaseCorrelationResult.java new file mode 100644 index 000000000..8b010abc8 --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/filter/phaseCorrelation/PhaseCorrelationResult.java @@ -0,0 +1,93 @@ +/*- + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.filter.phaseCorrelation; + +import java.util.Arrays; + +/** + * The result of {@code filter.phaseCorrelate}: the estimated translation + * between two images and how well the images agree under it. + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +public class PhaseCorrelationResult { + + private final double[] shift; + private final double correlation; + private final double phaseCorrelation; + private final long overlap; + + public PhaseCorrelationResult(final double[] shift, final double correlation, + final double phaseCorrelation, final long overlap) + { + this.shift = shift; + this.correlation = correlation; + this.phaseCorrelation = phaseCorrelation; + this.overlap = overlap; + } + + /** + * Gets the translation {@code s} of the second image relative to the first, + * such that {@code image2(x) = image1(x - s)} where both are defined. + */ + public double[] getShift() { + return shift.clone(); + } + + /** + * Gets the Pearson correlation coefficient of the two images on their + * overlap under the integer shift nearest to the subpixel one, or + * {@code NaN} if no candidate shift had enough nonconstant overlap. + */ + public double getCorrelation() { + return correlation; + } + + /** Gets the height of the chosen peak of the phase correlation matrix. */ + public double getPhaseCorrelation() { + return phaseCorrelation; + } + + /** + * Gets the number of pixels in the overlap of the two images under the + * integer shift. + */ + public long getOverlap() { + return overlap; + } + + @Override + public String toString() { + return "shift = " + Arrays.toString(shift) + ", correlation = " + + correlation + ", phaseCorrelation = " + phaseCorrelation + + ", overlap = " + overlap; + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/filter/retinex/ColorRetinex.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/filter/retinex/ColorRetinex.java new file mode 100644 index 000000000..208c0364f --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/filter/retinex/ColorRetinex.java @@ -0,0 +1,93 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.filter.retinex; + +import java.util.function.BiFunction; + +import net.imglib2.Dimensions; +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.type.numeric.RealType; +import net.imglib2.type.numeric.real.DoubleType; +import net.imglib2.type.numeric.real.FloatType; + +import org.scijava.function.Computers; +import org.scijava.function.Functions; +import org.scijava.ops.spi.Nullable; +import org.scijava.ops.spi.OpDependency; + +/** + * Multi-scale retinex with colour restoration (MSRCR) of a multichannel + * image. Each channel is smoothed along all dimensions except the channel + * dimension. See {@link Retinex} for details. + *

+ * Implemented from D. J. Jobson, Z. Rahman and G. A. Woodell, "A multiscale + * retinex for bridging the gap between color images and the human observation + * of scenes", IEEE Trans. Image Process. 6(7):965-976, 1997. Each scale is + * applied to the original image. + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @param input pixel type + * @implNote op names='filter.retinex' + */ +public class ColorRetinex> implements + Functions.Arity4, Integer, double[], Double, RandomAccessibleInterval> +{ + + @OpDependency(name = "filter.gauss") + private Computers.Arity2, Double, RandomAccessibleInterval> gaussOp; + + @OpDependency(name = "create.img") + private BiFunction> createOp; + + /** + * Computes the multi-scale retinex with colour restoration. + * + * @param input the image, with its colour channels along one dimension + * @param channelDim the dimension holding the colour channels + * @param sigmas the Gaussian surround scales, in pixels, weighted equally + * (default: 15, 80 and 250) + * @param dynamic the half-width, in standard deviations around the mean, of + * the range mapped to [0, 1]; 0 returns the raw retinex values + * (default: 1.2) + * @return the retinex image, with the same dimensions as the input + */ + @Override + public RandomAccessibleInterval apply( + final RandomAccessibleInterval input, final Integer channelDim, + @Nullable final double[] sigmas, @Nullable final Double dynamic) + { + final var output = createOp.apply(input, new FloatType()); + Retinex.compute(Retinex.channels(input, channelDim), sigmas, true, + dynamic, gaussOp, Retinex.channels(output, channelDim)); + return output; + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/filter/retinex/ColorRetinexARGB.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/filter/retinex/ColorRetinexARGB.java new file mode 100644 index 000000000..7183db6e6 --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/filter/retinex/ColorRetinexARGB.java @@ -0,0 +1,94 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.filter.retinex; + +import java.util.function.BiFunction; + +import net.imglib2.Dimensions; +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.converter.Converters; +import net.imglib2.type.numeric.ARGBType; +import net.imglib2.type.numeric.real.DoubleType; +import net.imglib2.type.numeric.real.FloatType; + +import org.scijava.function.Computers; +import org.scijava.function.Functions; +import org.scijava.ops.spi.Nullable; +import org.scijava.ops.spi.OpDependency; + +/** + * Multi-scale retinex with colour restoration (MSRCR) of a packed + * {@link ARGBType} image. The alpha channel is ignored. See {@link Retinex} + * for details. + *

+ * Implemented from D. J. Jobson, Z. Rahman and G. A. Woodell, "A multiscale + * retinex for bridging the gap between color images and the human observation + * of scenes", IEEE Trans. Image Process. 6(7):965-976, 1997. + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @implNote op names='filter.retinex' + */ +public class ColorRetinexARGB implements + Functions.Arity3, double[], Double, RandomAccessibleInterval> +{ + + @OpDependency(name = "filter.gauss") + private Computers.Arity2, Double, RandomAccessibleInterval> gaussOp; + + @OpDependency(name = "create.img") + private BiFunction> createOp; + + /** + * Computes the multi-scale retinex with colour restoration. + * + * @param input the ARGB image + * @param sigmas the Gaussian surround scales, in pixels, weighted equally + * (default: 15, 80 and 250) + * @param dynamic the half-width, in standard deviations around the mean, of + * the range mapped to [0, 1]; 0 returns the raw retinex values + * (default: 1.2) + * @return the retinex image, with one more dimension than the input; the + * last dimension holds the red, green and blue channels + */ + @Override + public RandomAccessibleInterval apply( + final RandomAccessibleInterval input, + @Nullable final double[] sigmas, @Nullable final Double dynamic) + { + final var rgb = Converters.argbChannels(input, 1, 2, 3); + final var output = createOp.apply(rgb, new FloatType()); + final var last = rgb.numDimensions() - 1; + Retinex.compute(Retinex.channels(rgb, last), sigmas, true, dynamic, + gaussOp, Retinex.channels(output, last)); + return output; + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/filter/retinex/DefaultRetinex.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/filter/retinex/DefaultRetinex.java new file mode 100644 index 000000000..b717a7e71 --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/filter/retinex/DefaultRetinex.java @@ -0,0 +1,96 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.filter.retinex; + +import java.util.List; +import java.util.function.BiFunction; + +import net.imglib2.Dimensions; +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.type.numeric.RealType; +import net.imglib2.type.numeric.real.DoubleType; +import net.imglib2.type.numeric.real.FloatType; + +import org.scijava.function.Computers; +import org.scijava.function.Functions; +import org.scijava.ops.spi.Nullable; +import org.scijava.ops.spi.OpDependency; + +/** + * Multi-scale retinex (MSR) of a single-channel image, for dynamic range + * compression and removal of smooth illumination variations. All dimensions + * are smoothed, so a multichannel image passed without a channel dimension is + * treated as a single channel, with the surrounds mixing the channels; use the + * {@code filter.retinex} variant with {@code channelDim} for colour images. + * See {@link Retinex} for details. + *

+ * Implemented from D. J. Jobson, Z. Rahman and G. A. Woodell, "A multiscale + * retinex for bridging the gap between color images and the human observation + * of scenes", IEEE Trans. Image Process. 6(7):965-976, 1997. Each scale is + * applied to the original image. + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @param input pixel type + * @implNote op names='filter.retinex' + */ +public class DefaultRetinex> implements + Functions.Arity3, double[], Double, RandomAccessibleInterval> +{ + + @OpDependency(name = "filter.gauss") + private Computers.Arity2, Double, RandomAccessibleInterval> gaussOp; + + @OpDependency(name = "create.img") + private BiFunction> createOp; + + /** + * Computes the multi-scale retinex. + * + * @param input the image + * @param sigmas the Gaussian surround scales, in pixels, weighted equally + * (default: 15, 80 and 250) + * @param dynamic the half-width, in standard deviations around the mean, of + * the range mapped to [0, 1]; 0 returns the raw retinex values + * (default: 1.2) + * @return the retinex image + */ + @Override + public RandomAccessibleInterval apply( + final RandomAccessibleInterval input, @Nullable final double[] sigmas, + @Nullable final Double dynamic) + { + final var output = createOp.apply(input, new FloatType()); + Retinex.compute(List.of(input), sigmas, false, dynamic, gaussOp, List.of( + output)); + return output; + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/filter/retinex/Retinex.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/filter/retinex/Retinex.java new file mode 100644 index 000000000..acbad9880 --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/filter/retinex/Retinex.java @@ -0,0 +1,229 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.filter.retinex; + +import java.util.ArrayList; +import java.util.List; + +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.img.Img; +import net.imglib2.img.array.ArrayImgs; +import net.imglib2.loops.LoopBuilder; +import net.imglib2.type.numeric.IntegerType; +import net.imglib2.type.numeric.RealType; +import net.imglib2.type.numeric.real.DoubleType; +import net.imglib2.type.numeric.real.FloatType; +import net.imglib2.util.Util; +import net.imglib2.view.Views; + +import org.scijava.function.Computers; + +/** + * Multi-scale retinex (MSR) with optional colour restoration (MSRCR), shared + * by the {@code filter.retinex} ops. + *

+ * For each channel {@code I}, the multi-scale retinex is + * {@code R = Σ_n w_n (log I - log(G_σn * I))}, with equal weights + * {@code w_n = 1/N} and Gaussian surrounds {@code G_σn} applied to the + * original channel at each scale. With colour restoration, each channel is + * then multiplied by {@code C_i = β (log(α I_i) - log Σ_j I_j)}, with + * {@code α = 125} and {@code β = 46}. Finally, the result is mapped linearly + * from {@code [μ - d·s, μ + d·s]} to {@code [0, 1]} and clipped, where + * {@code μ} and {@code s} are the mean and standard deviation of {@code R} + * over the whole image (all channels) and {@code d} is the dynamic parameter; + * with {@code d = 0} the raw retinex values are returned instead. + *

+ *

+ * Intensities are clamped below at 1 for integer types, and at + * 10-6 times the image maximum otherwise, so that their logarithm + * is finite. + *

+ *

+ * Implemented from D. J. Jobson, Z. Rahman and G. A. Woodell, "A multiscale + * retinex for bridging the gap between color images and the human observation + * of scenes", IEEE Trans. Image Process. 6(7):965-976, 1997. + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +public final class Retinex { + + /** Default surround scales (Jobson et al. 1997). */ + public static final double[] DEFAULT_SIGMAS = { 15, 80, 250 }; + + /** Default dynamic of the final contrast stretch. */ + public static final double DEFAULT_DYNAMIC = 1.2; + + private static final double ALPHA = 125; + private static final double BETA = 46; + + private Retinex() { + // NB: Prevent instantiation of utility class. + } + + /** + * Computes the (multi-scale) retinex of one or more channels. + * + * @param channels the channels of the image, each with the same dimensions + * @param sigmas the Gaussian surround scales, in pixels (null for the + * defaults) + * @param colorRestoration whether to apply colour restoration across the + * channels + * @param dynamic the dynamic of the final contrast stretch (null for the + * default; 0 to return raw values) + * @param gaussOp the Gaussian filter + * @param outputs the output channels + */ + static > void compute( + final List> channels, double[] sigmas, + final boolean colorRestoration, Double dynamic, + final Computers.Arity2, Double, RandomAccessibleInterval> gaussOp, + final List> outputs) + { + if (sigmas == null) sigmas = DEFAULT_SIGMAS; + if (sigmas.length == 0) { + throw new IllegalArgumentException("At least one scale is required"); + } + for (final var s : sigmas) { + if (!(s > 0)) { + throw new IllegalArgumentException("Scales must be positive: " + s); + } + } + final double d = dynamic == null ? DEFAULT_DYNAMIC : dynamic; + if (!(d >= 0)) { + throw new IllegalArgumentException("Dynamic must be non-negative: " + + d); + } + + final var floor = floor(channels); + final var dims = channels.get(0).dimensionsAsLongArray(); + final var weight = 1.0 / sigmas.length; + + // Floored intensities of each channel. + final List> intensities = new ArrayList<>(); + for (final var c : channels) { + final var img = ArrayImgs.doubles(dims); + LoopBuilder.setImages(c, img).forEachPixel((i, o) -> o.set(Math.max(i + .getRealDouble(), floor))); + intensities.add(img); + } + + final List> retinex = new ArrayList<>(); + final var blurred = ArrayImgs.doubles(dims); + for (final var img : intensities) { + final var r = ArrayImgs.doubles(dims); + for (final var sigma : sigmas) { + gaussOp.compute(img, sigma, blurred); + LoopBuilder.setImages(img, blurred, r).forEachPixel((i, b, o) -> o + .set(o.get() + weight * (Math.log(i.get()) - Math.log(Math.max(b + .get(), floor))))); + } + retinex.add(r); + } + + if (colorRestoration) { + final var sum = ArrayImgs.doubles(dims); + for (final var img : intensities) + LoopBuilder.setImages(img, sum).forEachPixel((i, s) -> s.set(s.get() + + i.get())); + for (var c = 0; c < intensities.size(); c++) { + LoopBuilder.setImages(intensities.get(c), sum, retinex.get(c)) + .forEachPixel((i, s, r) -> r.set(r.get() * BETA * (Math.log(ALPHA * + i.get()) - Math.log(s.get())))); + } + } + + var lo = 0.0; + var scale = 1.0; + if (d > 0) { + double sum = 0, sumSq = 0; + long n = 0; + for (final var r : retinex) { + for (final var t : r) { + final var v = t.get(); + sum += v; + sumSq += v * v; + n++; + } + } + final var mean = sum / n; + final var std = Math.sqrt(Math.max(0, sumSq / n - mean * mean)); + lo = mean - d * std; + scale = std > 0 ? 1 / (2 * d * std) : 0; + } + final var stretch = d > 0; + final var min = lo; + final var factor = scale; + for (var c = 0; c < retinex.size(); c++) { + LoopBuilder.setImages(retinex.get(c), outputs.get(c)).forEachPixel((r, + o) -> { + if (!stretch) { + o.setReal(r.get()); + return; + } + final var v = factor == 0 ? 0.5 : (r.get() - min) * factor; + o.setReal(Math.min(1, Math.max(0, v))); + }); + } + } + + private static > double floor( + final List> channels) + { + final var type = Util.getTypeFromInterval(channels.get(0)); + if (type instanceof IntegerType) return 1; + var max = 0.0; + for (final var c : channels) + for (final var t : Views.iterable(c)) + max = Math.max(max, t.getRealDouble()); + return max > 0 ? 1e-6 * max : 1e-6; + } + + /** + * Splits an image into its channels along a dimension. + * + * @param img the image + * @param channelDim the channel dimension + * @return one hyperslice per channel + */ + static List> channels( + final RandomAccessibleInterval img, final int channelDim) + { + if (channelDim < 0 || channelDim >= img.numDimensions()) { + throw new IllegalArgumentException("No such channel dimension: " + + channelDim); + } + final List> list = new ArrayList<>(); + for (var c = img.min(channelDim); c <= img.max(channelDim); c++) + list.add(Views.hyperSlice(img, channelDim, c)); + return list; + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/filter/tensor/TensorFilters.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/filter/tensor/TensorFilters.java new file mode 100644 index 000000000..8e545b2b8 --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/filter/tensor/TensorFilters.java @@ -0,0 +1,495 @@ +/*- + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.filter.tensor; + +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.loops.LoopBuilder; +import net.imglib2.type.numeric.RealType; +import net.imglib2.type.numeric.real.DoubleType; +import net.imglib2.view.Views; + +import org.scijava.ops.spi.Nullable; + +/** + * Filters derived from second-order tensors: the Hessian matrix and the + * structure tensor. + *

+ * All scales ({@code sigma}, {@code sigmaGradient}, {@code sigmaIntegration}) + * are Gaussian standard deviations in physical units, i.e. they are divided by + * the optional per-dimension {@code calibration} (pixel spacing, default 1) to + * obtain pixel sigmas, and derivatives are likewise expressed in physical + * units. Unlike most ops, a calibration with fewer values than dimensions is + * accepted, and the missing values default to 1. Derivatives are central finite differences of the Gaussian-smoothed + * image ({@code [-1, 0, 1] / 2} for gradients, {@code [1, -2, 1]} and the + * 3×3 cross stencil for second derivatives); a scale of 0 disables + * smoothing. Eigenvalues are computed with {@code TensorEigenValues} of + * imglib2-algorithm. The image is mirrored + * at its borders for smoothing, and border pixels are repeated for the finite + * differences. All outputs are {@link DoubleType} images with zero-based + * coordinates. + *

+ *

+ * Multi-component outputs ({@code filter.hessianEigenvalues}, + * {@code filter.structureTensor}, {@code filter.structureTensorEigenvalues}) + * have one extra, last dimension holding the components. Symmetric tensors + * are stored as their upper triangle in row-major order, + * {@code [t11, t12, ..., t1n, t22, t23, ..., tnn]} (n(n+1)/2 components), as + * in imglib2-algorithm's {@code HessianMatrix} and {@code TensorEigenValues}. + *

+ *

+ * Note: unlike {@code filter.hessian}, which applies a Sobel operator twice + * without explicit smoothing and returns the full n×n matrix, the Hessian + * here is computed at a chosen Gaussian scale, which is what scale-space + * measures like eigenvalues, shape index and sheetness require, similar to + * {@code filter.tubeness}. However, {@code filter.tubeness} uses the + * {@code HessianMatrix} of imglib2-algorithm, whose diagonal stencil has a + * step of 2 pixels, so its eigenvalues differ slightly from those of + * {@code filter.hessianEigenvalues} at the same scale. Eigenvalue orders also + * differ: {@code filter.hessianEigenvalues} sorts by ascending absolute value + * (as in Frangi et al.), {@code filter.structureTensorEigenvalues} by + * descending value, and {@code filter.tubeness} keeps the order of + * {@code TensorEigenValues}. + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +public final class TensorFilters { + + private TensorFilters() { + // Prevent instantiation of static utility class. + } + + /** + * Computes the eigenvalues of the Hessian matrix at each pixel of an n-D + * image, at the given Gaussian scale. + *

+ * The eigenvalues are sorted in ascending order of absolute value, i.e. + * {@code |λ1| <= |λ2| <= ... <= |λn|}, the convention of Frangi et al. + * (1998) and Sato et al. (1998). Bright structures on a dark background + * have negative eigenvalues across them; for example, at the center of a + * bright 3D line, {@code λ1 ≈ 0} and {@code λ2 ≈ λ3 << 0}. + *

+ *

+ * With {@code normalize} enabled, the Hessian is multiplied by + * {@code sigma²} (γ = 1 scale normalization; Lindeberg 1998), which makes + * responses comparable across scales. + *

+ * + * @param the input type + * @param input the n-dimensional input image + * @param sigma the Gaussian scale in physical units (0 for no smoothing) + * @param normalize whether to scale-normalize the Hessian by + * {@code sigma²}; defaults to {@code false} + * @param calibration the pixel spacing per dimension; defaults to 1 in every + * dimension + * @return an (n+1)-dimensional image whose last dimension holds the n + * sorted eigenvalues + * @implNote op names='filter.hessianEigenvalues' + */ + public static > RandomAccessibleInterval + hessianEigenvalues( // + final RandomAccessibleInterval input, // + final Double sigma, // + @Nullable Boolean normalize, // + @Nullable final double[] calibration // + ) { + if (normalize == null) normalize = false; + final var hessian = TensorUtils.hessian(input, sigma, calibration, + normalize); + return TensorUtils.eigenvalues(hessian, true); + } + + /** + * Computes the structure tensor (also called the second-moment matrix) of + * an n-D image: the outer product of the image gradient with itself, + * averaged with a Gaussian window, + * {@code J = G(sigmaIntegration) * (∇f ∇fᵀ)}, where the gradient + * {@code ∇f} is computed at scale {@code sigmaGradient}. + *

+ * References: Förstner & Gülch (1987), "A fast operator for detection + * and precise location of distinct points, corners and centres of circular + * features"; Bigün, Granlund & Wiklund (1991), "Multidimensional + * orientation estimation with applications to texture analysis and optical + * flow", IEEE TPAMI 13(8). + *

+ * + * @param the input type + * @param input the n-dimensional input image + * @param sigmaGradient the Gaussian scale for computing the gradient, in + * physical units (0 for no pre-smoothing) + * @param sigmaIntegration the Gaussian scale of the averaging window, in + * physical units + * @param calibration the pixel spacing per dimension; defaults to 1 in every + * dimension + * @return an (n+1)-dimensional image whose last dimension holds the n(n+1)/2 + * components {@code [J11, J12, ..., J1n, J22, ..., Jnn]} + * @implNote op names='filter.structureTensor' + */ + public static > RandomAccessibleInterval + structureTensor( // + final RandomAccessibleInterval input, // + final Double sigmaGradient, // + final Double sigmaIntegration, // + @Nullable final double[] calibration // + ) { + return TensorUtils.structureTensor(input, sigmaGradient, sigmaIntegration, + calibration); + } + + /** + * Computes the eigenvalues of the structure tensor (see + * {@code filter.structureTensor}) at each pixel of an n-D image. + *

+ * The structure tensor is positive semi-definite, so all eigenvalues are + * non-negative; they are sorted in descending order, + * {@code λ1 >= λ2 >= ... >= λn >= 0}. In 2D, {@code λ1 ≈ λ2 ≈ 0} indicates + * a flat region, {@code λ1 >> λ2 ≈ 0} an edge or oriented texture, and + * {@code λ1 ≈ λ2 >> 0} a corner or isotropic texture. + *

+ *

+ * This corresponds to the "structure tensor eigenvalues" feature of + * labkit-pixel-classification (Matthias Arzt, BSD-2-Clause) and the + * "Structure" feature of Trainable Weka Segmentation (via imagescience). + *

+ * + * @param the input type + * @param input the n-dimensional input image + * @param sigmaGradient the Gaussian scale for computing the gradient, in + * physical units (0 for no pre-smoothing) + * @param sigmaIntegration the Gaussian scale of the averaging window, in + * physical units + * @param calibration the pixel spacing per dimension; defaults to 1 in every + * dimension + * @return an (n+1)-dimensional image whose last dimension holds the n + * eigenvalues in descending order + * @implNote op names='filter.structureTensorEigenvalues' + */ + public static > RandomAccessibleInterval + structureTensorEigenvalues( // + final RandomAccessibleInterval input, // + final Double sigmaGradient, // + final Double sigmaIntegration, // + @Nullable final double[] calibration // + ) { + final var tensor = TensorUtils.structureTensor(input, sigmaGradient, + sigmaIntegration, calibration); + return TensorUtils.eigenvalues(tensor, false); + } + + /** + * Computes the shape index of a 2D image, treating intensity as a height + * map (Koenderink & van Doorn 1992, "Surface shape and curvature + * scales", Image and Vision Computing 10(8)). + *

+ * With {@code κ1 >= κ2} the eigenvalues of the Hessian at scale + * {@code sigma}, the shape index is + * {@code S = -(2/π) · atan((κ1 + κ2) / (κ1 - κ2))}, which lies in + * {@code [-1, 1]}: +1 for a bright spherical cap (local maximum), +0.5 for + * a bright ridge, 0 for a saddle, -0.5 for a dark valley and -1 for a dark + * spherical cup (local minimum). The shape index is undefined where the + * image is locally planar ({@code κ1 = κ2 = 0}); there the output is 0. + *

+ *

+ * This matches the sign convention of Fiji's {@code Shape_Index_Map} + * (Johannes Schindelin), but uses Gaussian-smoothed central differences + * rather than one-sided differences. + *

+ * + * @param the input type + * @param input the 2-dimensional input image + * @param sigma the Gaussian scale in physical units (0 for no smoothing) + * @param calibration the pixel spacing per dimension; defaults to 1 in every + * dimension + * @return the shape index image + * @implNote op names='filter.shapeIndex' + */ + public static > RandomAccessibleInterval + shapeIndex( // + final RandomAccessibleInterval input, // + final Double sigma, // + @Nullable final double[] calibration // + ) { + TensorUtils.checkDimensions(input, 2, "filter.shapeIndex"); + final var hessian = TensorUtils.hessian(input, sigma, calibration, false); + final var output = TensorUtils.createImg(input.dimensionsAsLongArray()); + final var factor = -2 / Math.PI; + LoopBuilder.setImages(Views.collapse(hessian), output).multiThreaded() + .forEachPixel((h, out) -> { + final var hxx = h.get(0).getRealDouble(); + final var hxy = h.get(1).getRealDouble(); + final var hyy = h.get(2).getRealDouble(); + final var diff = hxx - hyy; + // Note: κ1 + κ2 is the trace, and κ1 - κ2 is sqrt(diff² + 4 hxy²). + final var s = factor * Math.atan((hxx + hyy) / Math.sqrt(diff * diff + + 4 * hxy * hxy)); + out.set(Double.isNaN(s) ? 0 : s); + }); + return output; + } + + /** + * Computes a sheetness (bright sheet / plate) measure of a 3D image from + * the eigenvalues of its Hessian matrix, following the sheet filter of + * Sato et al. (2000), "Tissue classification based on 3D local intensity + * structures for volume rendering", IEEE TVCG 6(2). + *

+ * With {@code λ1 >= λ2 >= λ3} the eigenvalues of the scale-normalized + * Hessian ({@code sigma²·H}), the response is 0 unless {@code λ3 < 0}, and + * otherwise {@code |λ3| · ω(λ2, λ3) · ω(λ1, λ3)}, where + *

+ *
    + *
  • {@code ω(λs, λt) = 1 + λs/|λt|} if {@code λt <= λs <= 0},
  • + *
  • {@code ω(λs, λt) = 1 - α·λs/|λt|} if {@code 0 < λs < |λt|/α}, + * and
  • + *
  • {@code ω(λs, λt) = 0} otherwise.
  • + *
+ *

+ * A bright plate thus gives {@code |λ3|}, while lines ({@code λ2 ≈ λ3}) and + * blobs ({@code λ1 ≈ λ2 ≈ λ3}) are suppressed to 0. This is the sheet + * counterpart of {@code filter.tubeness}. + *

+ *

+ * Note: Fiji's VIB "Surfaceness" plugin (Mark Longair) uses + * {@code sqrt(|λ3/λ2|) - 1} when both {@code λ2, λ3 < 0} (sorted by + * magnitude). That ratio is unbounded and vanishes at the center of an + * ideal plate (where {@code λ2 = 0}), so this op uses Sato's measure + * instead, under a different name. + *

+ * + * @param the input type + * @param input the 3-dimensional input image + * @param sigma the Gaussian scale in physical units (0 for no smoothing) + * @param alpha the asymmetry parameter α of the weight function, penalizing + * positive (dark-side) curvature; defaults to 0.25 + * @param calibration the pixel spacing per dimension; defaults to 1 in every + * dimension + * @return the sheetness image + * @implNote op names='filter.sheetness' + */ + public static > RandomAccessibleInterval + sheetness( // + final RandomAccessibleInterval input, // + final Double sigma, // + @Nullable Double alpha, // + @Nullable final double[] calibration // + ) { + TensorUtils.checkDimensions(input, 3, "filter.sheetness"); + if (alpha == null) alpha = 0.25; + final double a = alpha; + final var hessian = TensorUtils.hessian(input, sigma, calibration, true); + final var evs = TensorUtils.eigenvalues(hessian, false); + final var output = TensorUtils.createImg(input.dimensionsAsLongArray()); + LoopBuilder.setImages(Views.collapse(evs), output).multiThreaded() + .forEachPixel((ev, out) -> { + final var l1 = ev.get(0).getRealDouble(); + final var l2 = ev.get(1).getRealDouble(); + final var l3 = ev.get(2).getRealDouble(); + if (l3 >= 0) out.setZero(); + else out.set(-l3 * satoWeight(l2, l3, a) * satoWeight(l1, l3, a)); + }); + return output; + } + + /** + * Computes the local orientation of a 2D image from its structure tensor, + * following the conventions of OrientationJ (Rezakhaniha et al. 2012, + * "Experimental investigation of collagen waviness and orientation in the + * arterial adventitia using confocal laser scanning microscopy", Biomech. + * Model. Mechanobiol. 11; Püspöki et al. 2016, "Transforms and operators + * for directional bioimage analysis: a survey", Adv. Anat. Embryol. Cell + * Biol. 219). + *

+ * The result is the orientation of the dominant oriented structure (i.e. + * perpendicular to the dominant gradient direction), in radians within + * {@code [-π/2, π/2]}, measured counterclockwise from the x axis as the + * image is displayed (y axis pointing up on screen, i.e. opposite to + * increasing row index): {@code θ = ½·atan2(2·Jxy, Jyy - Jxx)}, where + * {@code J} is the structure tensor in image coordinates. Horizontal + * structures yield 0 and vertical structures ±π/2. + *

+ *

+ * Unlike OrientationJ, which offers cubic-spline and finite-difference + * gradients, the gradient is computed by central differences of the image + * smoothed at {@code sigmaGradient}. + *

+ * + * @param the input type + * @param input the 2-dimensional input image + * @param sigmaGradient the Gaussian scale for computing the gradient, in + * physical units (0 for no pre-smoothing) + * @param sigmaIntegration the Gaussian scale of the averaging window + * (the local window of OrientationJ), in physical units + * @param calibration the pixel spacing per dimension; defaults to 1 in every + * dimension + * @return the orientation image, in radians + * @implNote op names='filter.orientation' + */ + public static > RandomAccessibleInterval + orientation( // + final RandomAccessibleInterval input, // + final Double sigmaGradient, // + final Double sigmaIntegration, // + @Nullable final double[] calibration // + ) { + TensorUtils.checkDimensions(input, 2, "filter.orientation"); + final var tensor = TensorUtils.structureTensor(input, sigmaGradient, + sigmaIntegration, calibration); + final var output = TensorUtils.createImg(input.dimensionsAsLongArray()); + LoopBuilder.setImages(Views.collapse(tensor), output).multiThreaded() + .forEachPixel((j, out) -> { + final var jxx = j.get(0).getRealDouble(); + final var jxy = j.get(1).getRealDouble(); + final var jyy = j.get(2).getRealDouble(); + out.set(0.5 * Math.atan2(2 * jxy, jyy - jxx)); + }); + return output; + } + + /** + * Computes the local coherency of a 2D image from its structure tensor, + * following the conventions of OrientationJ (see + * {@code filter.orientation}). + *

+ * With {@code λ1 >= λ2} the eigenvalues of the structure tensor, the + * coherency is {@code (λ1 - λ2) / (λ1 + λ2)} + * {@code = sqrt((Jyy - Jxx)² + 4·Jxy²) / (Jxx + Jyy)}, which lies in + * {@code [0, 1]}: 1 for a perfectly oriented pattern, 0 for an isotropic + * one. Where the tensor vanishes (a constant region), the output is 0. + *

+ * + * @param the input type + * @param input the 2-dimensional input image + * @param sigmaGradient the Gaussian scale for computing the gradient, in + * physical units (0 for no pre-smoothing) + * @param sigmaIntegration the Gaussian scale of the averaging window + * (the local window of OrientationJ), in physical units + * @param calibration the pixel spacing per dimension; defaults to 1 in every + * dimension + * @return the coherency image + * @implNote op names='filter.coherency' + */ + public static > RandomAccessibleInterval + coherency( // + final RandomAccessibleInterval input, // + final Double sigmaGradient, // + final Double sigmaIntegration, // + @Nullable final double[] calibration // + ) { + TensorUtils.checkDimensions(input, 2, "filter.coherency"); + final var tensor = TensorUtils.structureTensor(input, sigmaGradient, + sigmaIntegration, calibration); + final var output = TensorUtils.createImg(input.dimensionsAsLongArray()); + LoopBuilder.setImages(Views.collapse(tensor), output).multiThreaded() + .forEachPixel((j, out) -> { + final var jxx = j.get(0).getRealDouble(); + final var jxy = j.get(1).getRealDouble(); + final var jyy = j.get(2).getRealDouble(); + final var trace = jxx + jyy; + final var diff = jyy - jxx; + out.set(trace > 0 ? Math.sqrt(diff * diff + 4 * jxy * jxy) / trace + : 0); + }); + return output; + } + + /** + * Computes the Harris corner response of an n-D image (Harris & + * Stephens 1988, "A combined corner and edge detector", Proc. 4th Alvey + * Vision Conference). + *

+ * With {@code J} the structure tensor (see {@code filter.structureTensor}), + * the response is {@code R = det(J) - k·trace(J)ⁿ}, where n is the number + * of dimensions. In 2D this is the classic {@code det - k·trace²}; the + * exponent n keeps both terms of the same degree in higher dimensions (as + * in the 3D extension of Laptev 2005, "On space-time interest points", + * IJCV 64). Corners give large positive responses, edges negative ones and + * flat regions values near 0. Corner points can be extracted as local + * maxima of the response. + *

+ *

+ * Note: Fiji's VIB {@code Harris_Corner_Detector} (3D) computes the + * determinant with {@code gyy·gyy} where {@code gyy·gzz} is intended, and + * uses {@code trace²}; neither is replicated here. + *

+ * + * @param the input type + * @param input the n-dimensional input image + * @param sigmaGradient the Gaussian scale for computing the gradient, in + * physical units (0 for no pre-smoothing) + * @param sigmaIntegration the Gaussian scale of the averaging window, in + * physical units + * @param k the sensitivity parameter; defaults to 0.04 (typical values in + * 2D are 0.04 to 0.06; smaller values suit higher dimensions, e.g. + * 0.005 in 3D) + * @param calibration the pixel spacing per dimension; defaults to 1 in every + * dimension + * @return the Harris corner response image + * @implNote op names='filter.harris' + */ + public static > RandomAccessibleInterval + harris( // + final RandomAccessibleInterval input, // + final Double sigmaGradient, // + final Double sigmaIntegration, // + @Nullable Double k, // + @Nullable final double[] calibration // + ) { + if (k == null) k = 0.04; + final double kk = k; + final var n = input.numDimensions(); + final var tensor = TensorUtils.structureTensor(input, sigmaGradient, + sigmaIntegration, calibration); + final var evs = TensorUtils.eigenvalues(tensor, false); + final var output = TensorUtils.createImg(input.dimensionsAsLongArray()); + LoopBuilder.setImages(Views.collapse(evs), output).multiThreaded() + .forEachPixel((ev, out) -> { + var det = 1.0; + var trace = 0.0; + for (var i = 0; i < n; i++) { + final var v = ev.get(i).getRealDouble(); + det *= v; + trace += v; + } + out.set(det - kk * Math.pow(trace, n)); + }); + return output; + } + + /** Sato's weight function {@code ω(λs, λt)} with γ = 1. */ + private static double satoWeight(final double ls, final double lt, + final double alpha) + { + final var abs = Math.abs(lt); + if (lt <= ls && ls <= 0) return 1 + ls / abs; + if (ls > 0 && alpha * ls < abs) return 1 - alpha * ls / abs; + return 0; + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/filter/tensor/TensorUtils.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/filter/tensor/TensorUtils.java new file mode 100644 index 000000000..fc2e18194 --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/filter/tensor/TensorUtils.java @@ -0,0 +1,423 @@ +/*- + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.filter.tensor; + +import java.util.Arrays; + +import net.imglib2.FinalDimensions; +import net.imglib2.Interval; +import net.imglib2.RandomAccessible; +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.algorithm.gauss3.Gauss3; +import net.imglib2.algorithm.gauss3.SeparableSymmetricConvolution; +import net.imglib2.algorithm.gradient.PartialDerivative; +import net.imglib2.algorithm.linalg.eigen.TensorEigenValues; +import net.imglib2.exception.IncompatibleTypeException; +import net.imglib2.img.Img; +import net.imglib2.loops.LoopBuilder; +import net.imglib2.type.numeric.RealType; +import net.imglib2.type.numeric.real.DoubleType; +import net.imglib2.util.Intervals; +import net.imglib2.util.Util; +import net.imglib2.view.Views; + +import org.scijava.concurrent.Parallelization; + +/** + * Shared machinery for the ops in this package: Gaussian smoothing, Hessian + * matrix, structure tensor and sorted tensor eigenvalues, all computed in + * double precision using imglib2-algorithm. + *

+ * Symmetric tensors are stored as images with one extra (last) dimension of + * size {@code n(n+1)/2}, holding the upper triangle in row-major order + * {@code [t11, t12, ..., t1n, t22, t23, ..., tnn]}. This is the layout used by + * {@code HessianMatrix} of imglib2-algorithm and expected by {@link TensorEigenValues}. + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +public final class TensorUtils { + + private TensorUtils() { + // Prevent instantiation of static utility class. + } + + /** Number of independent entries of a symmetric {@code n x n} tensor. */ + static int tensorSize(final int n) { + return n * (n + 1) / 2; + } + + /** + * Converts a physical-unit scale into per-dimension pixel-unit sigmas. + * + * @param n number of dimensions + * @param sigma scale in physical units (must be non-negative) + * @param calibration pixel spacing per dimension, or null for unit spacing + * @return {@code sigma / calibration[d]} for each dimension {@code d} + */ + static double[] pixelSigmas(final int n, final double sigma, + final double[] calibration) + { + if (sigma < 0 || Double.isNaN(sigma)) { + throw new IllegalArgumentException("Sigma must be non-negative: " + + sigma); + } + final var spacing = spacing(n, calibration); + final var sigmas = new double[n]; + for (var d = 0; d < n; d++) + sigmas[d] = sigma / spacing[d]; + return sigmas; + } + + /** + * Gets the pixel spacing per dimension, defaulting to 1 where the given + * calibration is missing. + */ + static double[] spacing(final int n, final double[] calibration) { + final var spacing = new double[n]; + for (var d = 0; d < n; d++) { + spacing[d] = calibration != null && d < calibration.length + ? calibration[d] : 1; + if (!(spacing[d] > 0)) { + throw new IllegalArgumentException( + "Calibration must be positive: " + Arrays.toString(calibration)); + } + } + return spacing; + } + + /** Ensures the input has the expected dimensionality. */ + static void checkDimensions(final RandomAccessibleInterval input, + final int n, final String opName) + { + if (input.numDimensions() != n) { + throw new IllegalArgumentException(opName + " requires a " + n + + "D image, but the input is " + input.numDimensions() + "D"); + } + } + + /** Creates a double-precision image with the given dimensions. */ + static Img createImg(final long... dims) { + return Util.getSuitableImgFactory(new FinalDimensions(dims), + new DoubleType()).create(dims); + } + + /** Creates an n+1 dimensional image with {@code c} channels appended. */ + static Img createImg(final RandomAccessibleInterval input, + final long c) + { + final var n = input.numDimensions(); + final var dims = Arrays.copyOf(input.dimensionsAsLongArray(), n + 1); + dims[n] = c; + return createImg(dims); + } + + /** + * Smooths the source with a separable Gaussian. A sigma of zero along a + * dimension means no smoothing along that dimension. + * + * @param source extended source + * @param sigmas per-dimension sigmas in pixel units + * @param target the output interval + */ + static > void gauss(final RandomAccessible source, + final double[] sigmas, final RandomAccessibleInterval target) + { + final var halfkernels = new double[sigmas.length][]; + for (var d = 0; d < sigmas.length; d++) { + halfkernels[d] = sigmas[d] > 0 // + ? Gauss3.halfkernels(new double[] { sigmas[d] })[0] // + : new double[] { 1 }; + } + try { + SeparableSymmetricConvolution.convolve(halfkernels, source, target, + Parallelization.getExecutorService()); + } + catch (final IncompatibleTypeException exc) { + throw new IllegalStateException(exc); + } + } + + /** + * Smooths the input with a Gaussian of the given per-dimension pixel + * sigmas, mirroring at the image border. + */ + static > Img smooth( + final RandomAccessibleInterval input, final double[] sigmas) + { + final var zeroMin = Views.zeroMin(input); + final var smoothed = createImg(zeroMin.dimensionsAsLongArray()); + gauss(Views.extendMirrorSingle(zeroMin), sigmas, smoothed); + return smoothed; + } + + /** + * Computes the Hessian matrix at scale {@code sigma}: second-order finite + * differences of the Gaussian-smoothed input, expressed in physical units. + * + * @param input n-dimensional input + * @param sigma Gaussian scale in physical units (0 for no smoothing) + * @param calibration pixel spacing per dimension, or null + * @param normalize whether to scale-normalize the result by + * {@code sigma^2} + * @return n+1 dimensional tensor image + */ + static > Img hessian( + final RandomAccessibleInterval input, final double sigma, + final double[] calibration, final boolean normalize) + { + final var n = input.numDimensions(); + final var spacing = spacing(n, calibration); + final var smoothed = smooth(input, pixelSigmas(n, sigma, calibration)); + final var hessian = createImg(smoothed, tensorSize(n)); + // Note: HessianMatrix of imglib2-algorithm differentiates the central + // difference gradient again, i.e. it uses a stencil of step 2 for the + // diagonal entries. The compact [1, -2, 1] stencil used here is about + // four times more accurate for smooth structures at small scales. + final var extended = Views.extendBorder(smoothed); + for (int i = 0, k = 0; i < n; i++) { + for (var j = i; j < n; j++, k++) { + final var out = Views.hyperSlice(hessian, n, k); + if (i == j) { + LoopBuilder.setImages(shifted(extended, smoothed, i, -1, i, 0), + shifted(extended, smoothed, i, 0, i, 0), shifted(extended, smoothed, + i, 1, i, 0), out).multiThreaded().forEachPixel((b, c, f, h) -> h + .setReal(f.get() - 2 * c.get() + b.get())); + } + else { + LoopBuilder.setImages(shifted(extended, smoothed, i, 1, j, 1), + shifted(extended, smoothed, i, 1, j, -1), shifted(extended, + smoothed, i, -1, j, 1), shifted(extended, smoothed, i, -1, j, -1), + out).multiThreaded().forEachPixel((pp, pm, mp, mm, h) -> h.setReal( + 0.25 * (pp.get() - pm.get() - mp.get() + mm.get()))); + } + } + } + // Convert from pixel units into physical units, and scale-normalize. + final var s2 = normalize ? sigma * sigma : 1; + for (int i = 0, k = 0; i < n; i++) { + for (var j = i; j < n; j++, k++) { + final var factor = s2 / (spacing[i] * spacing[j]); + if (factor != 1) scale(Views.hyperSlice(hessian, n, k), factor); + } + } + return hessian; + } + + /** + * Computes the structure tensor: the Gaussian-weighted (integration scale) + * average of the outer product of the gradient, where the gradient is + * computed by central differences of the input smoothed at the gradient + * scale. + * + * @param input n-dimensional input + * @param sigmaGradient Gaussian scale for gradient computation in physical + * units (0 for no pre-smoothing) + * @param sigmaIntegration Gaussian scale for the averaging of the gradient + * products, in physical units + * @param calibration pixel spacing per dimension, or null + * @return n+1 dimensional tensor image + */ + static > Img structureTensor( + final RandomAccessibleInterval input, final double sigmaGradient, + final double sigmaIntegration, final double[] calibration) + { + final var n = input.numDimensions(); + final var spacing = spacing(n, calibration); + final var integrationSigmas = pixelSigmas(n, sigmaIntegration, + calibration); + final var smoothed = smooth(input, pixelSigmas(n, sigmaGradient, + calibration)); + + // Compute the gradient, in physical units. + final var gradient = createImg(smoothed, n); + final var extended = Views.extendBorder(smoothed); + for (var d = 0; d < n; d++) { + final var slice = Views.hyperSlice(gradient, n, d); + PartialDerivative.gradientCentralDifference(extended, slice, d); + if (spacing[d] != 1) scale(slice, 1 / spacing[d]); + } + + // Compute the outer products of the gradient. + final var products = createImg(smoothed, tensorSize(n)); + LoopBuilder.setImages(Views.collapse(gradient), Views.collapse(products)) + .multiThreaded().forEachPixel((g, p) -> { + for (int i = 0, k = 0; i < n; i++) { + final var gi = g.get(i).getRealDouble(); + for (var j = i; j < n; j++, k++) + p.get(k).setReal(gi * g.get(j).getRealDouble()); + } + }); + + // Integrate by smoothing each product image. + final var tensor = createImg(smoothed, tensorSize(n)); + for (var k = 0; k < tensorSize(n); k++) { + gauss(Views.extendMirrorSingle(Views.hyperSlice(products, n, k)), + integrationSigmas, Views.hyperSlice(tensor, n, k)); + } + return tensor; + } + + /** + * Computes the eigenvalues of each symmetric tensor and sorts them. + * + * @param tensor n+1 dimensional tensor image + * @param byMagnitude if true, sort ascending by absolute value + * ({@code |λ1| <= |λ2| <= ...}); otherwise, sort descending by + * value ({@code λ1 >= λ2 >= ...}) + * @return n+1 dimensional image with n eigenvalues along the last + * dimension + */ + static Img eigenvalues(final RandomAccessibleInterval tensor, + final boolean byMagnitude) + { + final var n = tensor.numDimensions() - 1; + final var evs = createImg(Views.hyperSlice(tensor, n, 0), n); + TensorEigenValues.calculateEigenValuesSymmetric(tensor, evs, nTasks(), + Parallelization.getExecutorService()); + LoopBuilder.setImages(Views.collapse(evs)).multiThreaded().forEachChunk( + chunk -> { + final var values = new double[n]; + chunk.forEachPixel(c -> { + for (var i = 0; i < n; i++) + values[i] = c.get(i).getRealDouble(); + sort(values, byMagnitude); + for (var i = 0; i < n; i++) + c.get(i).setReal(values[i]); + }); + return null; + }); + return evs; + } + + /** + * Sorts the values in place: ascending by absolute value if + * {@code byMagnitude}, otherwise descending by value. + */ + static void sort(final double[] values, final boolean byMagnitude) { + // Use insertion sort, since the arrays are tiny (one entry per dimension). + for (var i = 1; i < values.length; i++) { + final var v = values[i]; + var j = i - 1; + while (j >= 0 && (byMagnitude ? Math.abs(values[j]) > Math.abs(v) + : values[j] < v)) + { + values[j + 1] = values[j]; + j--; + } + values[j + 1] = v; + } + } + + private static void scale(final RandomAccessibleInterval image, + final double factor) + { + LoopBuilder.setImages(image).multiThreaded().forEachPixel(t -> t.mul( + factor)); + } + + /** + * Gets the values of {@code source} over {@code interval}, shifted by + * {@code o1} along dimension {@code d1} and by {@code o2} along {@code d2}. + */ + private static RandomAccessibleInterval shifted( + final RandomAccessible source, final Interval interval, + final int d1, final long o1, final int d2, final long o2) + { + final var offset = new long[interval.numDimensions()]; + offset[d1] += o1; + offset[d2] += o2; + return Views.interval(source, Intervals.translate(interval, offset)); + } + + private static int nTasks() { + return Runtime.getRuntime().availableProcessors(); + } + + /** + * Computes the eigenvalues and eigenvectors (as columns of {@code vecs}) of + * a symmetric matrix with the cyclic Jacobi method. The matrix is + * overwritten. Unlike {@link TensorEigenValues}, this also yields the + * eigenvectors; the eigenvalues are not sorted. + * + * @param a the symmetric matrix, overwritten in the process + * @param vals the array to fill with the eigenvalues + * @param vecs the matrix to fill with the eigenvectors, one per column + */ + public static void symmetricEigen(final double[][] a, final double[] vals, + final double[][] vecs) + { + final var n = a.length; + for (var r = 0; r < n; r++) + for (var s = 0; s < n; s++) + vecs[r][s] = r == s ? 1 : 0; + for (var sweep = 0; sweep < 50; sweep++) { + var off = 0.0; + var diag = 0.0; + for (var r = 0; r < n; r++) { + diag += a[r][r] * a[r][r]; + for (var s = r + 1; s < n; s++) + off += a[r][s] * a[r][s]; + } + if (off == 0 || off <= 1e-30 * diag) break; + for (var p = 0; p < n; p++) { + for (var q = p + 1; q < n; q++) { + if (a[p][q] == 0) continue; + final var theta = (a[q][q] - a[p][p]) / (2 * a[p][q]); + final var t = (theta >= 0 ? 1 : -1) / (Math.abs(theta) + Math.sqrt( + theta * theta + 1)); + final var cos = 1 / Math.sqrt(t * t + 1); + final var sin = t * cos; + for (var k = 0; k < n; k++) { + final var akp = a[k][p]; + final var akq = a[k][q]; + a[k][p] = cos * akp - sin * akq; + a[k][q] = sin * akp + cos * akq; + } + for (var k = 0; k < n; k++) { + final var apk = a[p][k]; + final var aqk = a[q][k]; + a[p][k] = cos * apk - sin * aqk; + a[q][k] = sin * apk + cos * aqk; + } + for (var k = 0; k < n; k++) { + final var vkp = vecs[k][p]; + final var vkq = vecs[k][q]; + vecs[k][p] = cos * vkp - sin * vkq; + vecs[k][q] = sin * vkp + cos * vkq; + } + } + } + } + for (var r = 0; r < n; r++) + vals[r] = a[r][r]; + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/filter/tubeness/DefaultTubeness.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/filter/tubeness/DefaultTubeness.java index 7dfa03124..864008d4d 100644 --- a/scijava-ops-image/src/main/java/org/scijava/ops/image/filter/tubeness/DefaultTubeness.java +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/filter/tubeness/DefaultTubeness.java @@ -83,6 +83,11 @@ *

* Port of the tubeness filter of the VIB package, with original authors Mark * Longair and Stephan Preibisch, to ImageJ-ops. + *

+ * Unlike VIB, which with calibration enabled smooths with calibrated sigmas + * but takes the second derivatives in pixel units, this op expresses the + * Hessian in calibrated units too, so that the same physical structure yields + * the same tubeness regardless of the (possibly anisotropic) sampling. * * @see Tubeness @@ -98,17 +103,18 @@ public class DefaultTubeness> implements @OpDependency(name = "create.imgFactory") private Function> createFactoryOp; - // TODO: make sure this works @OpDependency(name = "transform.project") private Computers.Arity3, Computers.Arity1, DoubleType>, Integer, IterableInterval> projector; /** - * TODO + * Computes the tubeness of the input image. * - * @param input - * @param sigma - * @param calibration - * @param tubeness + * @param input the 2D or 3D input image + * @param sigma the scale of the Gaussian smoothing, in calibrated units + * @param calibration the physical size of a pixel along each dimension + * (missing entries default to 1); both the Gaussian smoothing and + * the Hessian are computed in these calibrated units + * @param tubeness the output tubeness image */ @Override public void compute(final RandomAccessibleInterval input, @@ -117,11 +123,16 @@ public void compute(final RandomAccessibleInterval input, { final var numDimensions = input.numDimensions(); + if (numDimensions != 2 && numDimensions != 3) { + throw new IllegalArgumentException("Cannot compute tubeness for " + + numDimensions + "D images"); + } // Sigmas in pixel units. final var sigmas = new double[numDimensions]; + final var cals = new double[numDimensions]; for (var d = 0; d < sigmas.length; d++) { - final var cal = d < calibration.length ? calibration[d] : 1; - sigmas[d] = sigma / cal; + cals[d] = d < calibration.length ? calibration[d] : 1; + sigmas[d] = sigma / cals[d]; } /* @@ -153,10 +164,13 @@ public void compute(final RandomAccessibleInterval input, try { var es = Parallelization.getExecutorService(); - // Hessian calculation. - HessianMatrix.calculateMatrix(Views.extendBorder(input), gaussian, + // Hessian calculation. Note: The created images are zero-min, so the + // input must be too, or the wrong window of the input is filtered. + HessianMatrix.calculateMatrix(Views.extendBorder(Views.zeroMin(input)), + gaussian, gradient, hessian, new OutOfBoundsBorderFactory<>(), nThreads, es, - sigma); + sigmas); + calibrateHessian(hessian, cals); // Hessian eigenvalues. final var evs = TensorEigenValues @@ -164,28 +178,35 @@ public void compute(final RandomAccessibleInterval input, .createAppropriateResultImg(hessian, factory, new DoubleType()), nThreads, es); - final Computers.Arity1, DoubleType> method; - switch (numDimensions) { - case 2: - method = new Tubeness2D(sigma); - break; - case 3: - method = new Tubeness3D(sigma); - break; - default: - System.err.println("Cannot compute tubeness for " + numDimensions + - "D images."); - return; - } + final Computers.Arity1, DoubleType> method = + numDimensions == 2 ? new Tubeness2D(sigma) : new Tubeness3D(sigma); projector.compute(evs, method, numDimensions, tubeness); - - return; } - catch (final IncompatibleTypeException | InterruptedException - | ExecutionException e) - { - e.printStackTrace(); - return; + catch (final IncompatibleTypeException | ExecutionException e) { + throw new IllegalStateException(e); + } + catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(e); + } + } + + /** + * Converts the Hessian from pixel units to calibrated units, dividing the + * component {@code (i, j)} by {@code cal[i] * cal[j]}. + */ + private static void calibrateHessian(final Img hessian, + final double[] cals) + { + final var n = cals.length; + var c = 0; + for (var i = 0; i < n; i++) { + for (var j = i; j < n; j++, c++) { + final var scale = 1 / (cals[i] * cals[j]); + if (scale == 1) continue; + for (final var t : Views.hyperSlice(hessian, n, c)) + t.mul(scale); + } } } @@ -238,6 +259,10 @@ public void compute(final Iterable input, } /** + * The tubeness filter without calibration, i.e. with a pixel size of 1 along + * every dimension. See {@link DefaultTubeness}. + * + * @param the type of the source pixels * @implNote op names='filter.tubeness' */ class DefaultTubenessWithoutCalibration> implements @@ -248,16 +273,16 @@ class DefaultTubenessWithoutCalibration> implements Computers.Arity3, Double, double[], IterableInterval> tubenessOp; /** - * TODO Can this be removed? + * Computes the tubeness of an uncalibrated input image. * - * @param in1 - * @param in3 - * @param out + * @param input the 2D or 3D input image + * @param sigma the scale of the Gaussian smoothing, in pixels + * @param tubeness the output tubeness image */ @Override - public void compute(RandomAccessibleInterval in1, Double in3, - IterableInterval out) + public void compute(final RandomAccessibleInterval input, + final Double sigma, final IterableInterval tubeness) { - tubenessOp.compute(in1, in3, new double[] {}, out); + tubenessOp.compute(input, sigma, new double[] {}, tubeness); } } diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/filter/tv/DefaultTVDenoise.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/filter/tv/DefaultTVDenoise.java new file mode 100644 index 000000000..59792a7fe --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/filter/tv/DefaultTVDenoise.java @@ -0,0 +1,175 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.filter.tv; + +import java.util.Arrays; + +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.type.numeric.RealType; +import net.imglib2.util.Intervals; + +import org.scijava.function.Computers; +import org.scijava.ops.image.filter.FlatImage; +import org.scijava.ops.spi.Nullable; + +/** + * Total variation (Rudin-Osher-Fatemi) denoising in n dimensions, using + * the dual projection algorithm of Chambolle (2004). + *

+ * The output {@code u} approximates the minimizer of + * {@code TV(u) + ||u - f||^2 / (2 lambda)}, where {@code f} is the input and + * {@code TV} is the (isotropic) total variation. Larger values of {@code lambda} + * remove more noise; {@code lambda} is in units of intensity, and a flat region of + * {@code k} pixels next to an edge moves by at most about {@code lambda / k} + * towards its neighbour. Each iteration updates the dual field {@code p} as + *

+ * + *
+ * g = grad(div p - f / lambda)
+ * p = (p + tau g) / (1 + tau |g|)
+ * 
+ *

+ * and the result is {@code u = f - lambda div p}. Gradients are forward + * differences with Neumann boundary conditions, and {@code div} is their + * negative adjoint, so the mean intensity is preserved exactly. Convergence is + * guaranteed for {@code tau <= 1 / (4n)}; larger steps are accepted (unlike + * the time step of {@code filter.peronaMalik}) but may not converge. + *

+ *

+ * The computation is single-threaded on double-precision flat arrays, so the + * image is limited to 231 pixels. + *

+ *

+ * Implemented from the paper. The ROF_Denoise plugin of Fiji (GPL) uses the + * same algorithm with different boundary handling, so results differ near the + * image border and slightly elsewhere. + *

+ *

+ * Results are clamped to the range of the output type, so an integer output + * saturates rather than wrapping around. + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @param input pixel type + * @param output pixel type + * @see
A. + * Chambolle, "An algorithm for total variation minimization and + * applications", J. Math. Imaging Vis. 20:89-97, 2004 + * @see L. I. Rudin, S. + * Osher and E. Fatemi, "Nonlinear total variation based noise removal + * algorithms", Physica D 60:259-268, 1992 + * @implNote op names='filter.tvDenoise' + */ +public class DefaultTVDenoise, V extends RealType> + implements + Computers.Arity4, Double, Integer, Double, RandomAccessibleInterval> +{ + + /** + * Applies total variation denoising. + * + * @param input the image to denoise + * @param lambda the regularization weight, in units of intensity (larger + * values smooth more) + * @param iterations the number of iterations (default: 50) + * @param tau the step size (default: 1 / (4n) for an n-dimensional image, + * the largest step with guaranteed convergence) + * @param output the denoised image + */ + @Override + public void compute(final RandomAccessibleInterval input, + final Double lambda, @Nullable final Integer iterations, + @Nullable final Double tau, final RandomAccessibleInterval output) + { + if (!Intervals.equalDimensions(input, output)) { + throw new IllegalArgumentException( + "Input and output must have the same dimensions"); + } + if (!(lambda > 0)) { + throw new IllegalArgumentException("Lambda must be positive: " + lambda); + } + final var n = input.numDimensions(); + final int iters = iterations == null ? 50 : iterations; + final double step = tau == null ? 1.0 / (4 * n) : tau; + if (!(step > 0)) { + throw new IllegalArgumentException("Tau must be positive: " + step); + } + final var f = FlatImage.read(input); + final var count = f.length; + final var dims = input.dimensionsAsLongArray(); + final var strides = FlatImage.strides(dims); + + final var p = new double[n][count]; + final var divP = new double[count]; + final var w = new double[count]; + final var g = new double[n]; + final var pos = new long[n]; + for (var it = 0; it < iters; it++) { + for (var i = 0; i < count; i++) + w[i] = divP[i] - f[i] / lambda; + Arrays.fill(pos, 0); + for (var i = 0; i < count; i++) { + var norm = 0.0; + for (var d = 0; d < n; d++) { + g[d] = pos[d] < dims[d] - 1 ? w[i + strides[d]] - w[i] : 0; + norm += g[d] * g[d]; + } + final var denom = 1 + step * Math.sqrt(norm); + for (var d = 0; d < n; d++) + p[d][i] = (p[d][i] + step * g[d]) / denom; + FlatImage.increment(pos, dims); + } + divergence(p, dims, strides, pos, divP); + } + + final var u = new double[count]; + for (var i = 0; i < count; i++) + u[i] = f[i] - lambda * divP[i]; + FlatImage.write(u, output); + } + + /** Computes the discrete divergence, the negative adjoint of the gradient. */ + private static void divergence(final double[][] p, final long[] dims, + final int[] strides, final long[] pos, final double[] out) + { + final var n = dims.length; + Arrays.fill(pos, 0); + for (var i = 0; i < out.length; i++) { + var div = 0.0; + for (var d = 0; d < n; d++) { + if (pos[d] < dims[d] - 1) div += p[d][i]; + if (pos[d] > 0) div -= p[d][i - strides[d]]; + } + out[i] = div; + FlatImage.increment(pos, dims); + } + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/filter/wavelet/Starlet.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/filter/wavelet/Starlet.java new file mode 100644 index 000000000..b94b118d8 --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/filter/wavelet/Starlet.java @@ -0,0 +1,165 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.filter.wavelet; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Supplier; + +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.algorithm.convolution.kernel.Kernel1D; +import net.imglib2.algorithm.convolution.kernel.SeparableKernelConvolution; +import net.imglib2.loops.LoopBuilder; +import net.imglib2.type.numeric.RealType; +import net.imglib2.type.numeric.real.FloatType; +import net.imglib2.view.Views; + +/** + * Utility methods for the isotropic undecimated (à trous) wavelet + * transform with the cubic B-spline scaling function, also known as the + * starlet transform. + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @see "J.-L. Starck and F. Murtagh, Astronomical Image and Data Analysis, + * 2nd ed., Springer, 2006" + */ +final class Starlet { + + /** The 1D cubic B-spline filter: (1, 4, 6, 4, 1) / 16. */ + private static final double[] B3 = { 1 / 16.0, 4 / 16.0, 6 / 16.0, + 4 / 16.0, 1 / 16.0 }; + + private Starlet() { + // Prevent instantiation of utility class. + } + + /** + * Decomposes an image into wavelet planes {@code w_1 ... w_J} and the + * residual (smooth) plane {@code c_J}, so that the input equals the sum of + * all returned images. Dimensions of size 1 are not filtered. + * + * @param input the image to decompose + * @param nScales the number of wavelet scales {@code J} + * @param creator creates a new (zero-min) image with the dimensions of the + * input + * @return {@code J + 1} images: the wavelet planes from fine to coarse, + * followed by the residual + */ + static > List> + decompose(final RandomAccessibleInterval input, final int nScales, + final Supplier> creator) + { + if (nScales < 1) { + throw new IllegalArgumentException("Need at least one scale: " + + nScales); + } + final var n = input.numDimensions(); + final var planes = new ArrayList>(); + var previous = creator.get(); + LoopBuilder.setImages(Views.zeroMin(input), previous).forEachPixel((i, + o) -> o.setReal(i.getRealDouble())); + for (var j = 1; j <= nScales; j++) { + final var step = 1 << (j - 1); + final var kernels = new Kernel1D[n]; + for (var d = 0; d < n; d++) { + kernels[d] = input.dimension(d) > 1 ? Kernel1D.symmetric(halfKernel( + step)) : Kernel1D.symmetric(1); + } + final var smooth = creator.get(); + SeparableKernelConvolution.convolve(kernels, Views.extendMirrorSingle( + previous), smooth); + LoopBuilder.setImages(previous, smooth).forEachPixel((w, c) -> w.sub( + c)); + planes.add(previous); + previous = smooth; + } + planes.add(previous); + return planes; + } + + /** + * Computes, for each scale, the standard deviation of the wavelet + * coefficients of unit-variance white Gaussian noise. These are the norms + * of the equivalent filters producing the wavelet planes, which are + * separable differences {@code h_(j-1) x ... x h_(j-1) - h_j x ... x h_j} + * of the cumulative 1D smoothing filters {@code h_j}. + * + * @param nScales the number of wavelet scales + * @param nDims the number of filtered dimensions + * @return the noise standard deviation for scales 1 to {@code nScales} + * (index 0 is scale 1) + */ + static double[] noiseLevels(final int nScales, final int nDims) { + final var result = new double[nScales]; + var h = new double[] { 1 }; + for (var j = 1; j <= nScales; j++) { + final var next = convolve(h, dilate(1 << (j - 1))); + // Note: h is centered in next, which is longer by 2^j on each side. + final var shift = (next.length - h.length) / 2; + double hh = 0, hn = 0, nn = 0; + for (var i = 0; i < h.length; i++) { + hh += h[i] * h[i]; + hn += h[i] * next[i + shift]; + } + for (final var v : next) + nn += v * v; + result[j - 1] = Math.sqrt(Math.pow(hh, nDims) - 2 * Math.pow(hn, + nDims) + Math.pow(nn, nDims)); + h = next; + } + return result; + } + + /** Returns the half kernel of the B3 filter with holes of the given step. */ + private static double[] halfKernel(final int step) { + final var half = new double[2 * step + 1]; + half[0] = B3[2]; + half[step] = B3[3]; + half[2 * step] = B3[4]; + return half; + } + + /** Returns the full B3 filter with holes of the given step. */ + private static double[] dilate(final int step) { + final var full = new double[4 * step + 1]; + for (var i = 0; i < B3.length; i++) + full[i * step] = B3[i]; + return full; + } + + private static double[] convolve(final double[] a, final double[] b) { + final var result = new double[a.length + b.length - 1]; + for (var i = 0; i < a.length; i++) + for (var k = 0; k < b.length; k++) + result[i + k] += a[i] * b[k]; + return result; + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/filter/wavelet/WaveletATrous.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/filter/wavelet/WaveletATrous.java new file mode 100644 index 000000000..fa9cc0a20 --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/filter/wavelet/WaveletATrous.java @@ -0,0 +1,98 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.filter.wavelet; + +import java.util.List; +import java.util.function.BiFunction; + +import net.imglib2.Dimensions; +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.type.numeric.RealType; +import net.imglib2.type.numeric.real.FloatType; + +import org.scijava.ops.spi.OpDependency; + +/** + * Isotropic undecimated wavelet transform (à trous algorithm with the + * cubic B-spline scaling function, also called the starlet transform). + *

+ * Starting from {@code c_0 = input}, each smooth plane {@code c_j} is + * computed by convolving {@code c_(j-1)} along every dimension with the 1D + * filter {@code (1, 4, 6, 4, 1) / 16}, whose taps are spaced + * {@code 2^(j-1)} pixels apart ("with holes"). The wavelet plane of scale + * {@code j} is {@code w_j = c_(j-1) - c_j}. Boundaries are handled by mirror + * reflection (without repeating the border pixel). Dimensions of size 1 are + * left unfiltered. + *

+ *

+ * The result contains the {@code nScales} wavelet planes from fine to coarse, + * followed by the residual {@code c_nScales}; the sum of all of them + * reconstructs the input. Wavelet planes of scale 2 and 3 are often used as + * spot-enhancing filters in single-molecule localization microscopy (Izeddin + * et al. 2012). + *

+ *

+ * Implemented from the description by Starck and Murtagh. + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @param input pixel type + * @see "J.-L. Starck, F. Murtagh and A. Bijaoui, Image Processing and Data + * Analysis: The Multiscale Approach, Cambridge University Press, 1998" + * @see I. Izeddin et al., + * "Wavelet analysis for single molecule localization microscopy", Opt. + * Express 20(3):2081-2095, 2012 + * @implNote op names='filter.waveletATrous' + */ +public class WaveletATrous> implements + BiFunction, Integer, List>> +{ + + @OpDependency(name = "create.img") + private BiFunction> createOp; + + /** + * Decomposes an image into wavelet planes. + * + * @param input the image to decompose + * @param nScales the number of wavelet scales (at least 1) + * @return {@code nScales + 1} images of the dimensions of the input: the + * wavelet planes from fine to coarse, followed by the residual + * smooth plane + */ + @Override + public List> apply( + final RandomAccessibleInterval input, final Integer nScales) + { + return Starlet.decompose(input, nScales, () -> createOp.apply(input, + new FloatType())); + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/filter/wavelet/WaveletDenoise.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/filter/wavelet/WaveletDenoise.java new file mode 100644 index 000000000..b336fb2e7 --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/filter/wavelet/WaveletDenoise.java @@ -0,0 +1,137 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.filter.wavelet; + +import java.util.Arrays; +import java.util.function.BiFunction; + +import net.imglib2.Dimensions; +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.loops.LoopBuilder; +import net.imglib2.type.numeric.RealType; +import net.imglib2.type.numeric.real.FloatType; +import net.imglib2.util.Intervals; +import net.imglib2.view.Views; + +import org.scijava.function.Functions; +import org.scijava.ops.spi.Nullable; +import org.scijava.ops.spi.OpDependency; + +/** + * Wavelet denoising by hard thresholding of the starlet (à trous) + * transform, as described by Starck and Murtagh. + *

+ * The image is decomposed into {@code nScales} wavelet planes and a residual + * (see {@code filter.waveletATrous}). The standard deviation of the + * (assumed white Gaussian) noise is estimated from the finest plane as + * {@code sigma = MAD(w_1) / 0.6745 / e_1}, where {@code MAD} is the median + * absolute deviation from the median and {@code e_j} is the standard + * deviation of the coefficients of plane {@code j} for unit-variance white + * noise (computed exactly from the filter, for the number of non-singleton + * dimensions of the input). Each coefficient of plane {@code j} is kept if + * its magnitude exceeds {@code k * sigma * e_j}, and set to zero otherwise. + * The result is the residual plus all thresholded planes. + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @param input pixel type + * @see "J.-L. Starck and F. Murtagh, Astronomical Image and Data Analysis, + * 2nd ed., Springer, 2006" + * @implNote op names='filter.waveletDenoise' + */ +public class WaveletDenoise> implements + Functions.Arity3, Integer, Double, RandomAccessibleInterval> +{ + + @OpDependency(name = "create.img") + private BiFunction> createOp; + + /** + * Denoises an image. + * + * @param input the image to denoise + * @param nScales the number of wavelet scales (default: 4) + * @param k the threshold, in multiples of the noise standard deviation of + * each scale (default: 3) + * @return the denoised image + */ + @Override + public RandomAccessibleInterval apply( + final RandomAccessibleInterval input, @Nullable final Integer nScales, + @Nullable final Double k) + { + final int scales = nScales == null ? 4 : nScales; + final double kSigma = k == null ? 3 : k; + final var size = Intervals.numElements(input); + if (size > Integer.MAX_VALUE) { + throw new IllegalArgumentException("Image is too large: " + size + + " pixels"); + } + var nDims = 0; + for (var d = 0; d < input.numDimensions(); d++) + if (input.dimension(d) > 1) nDims++; + final var levels = Starlet.noiseLevels(scales, nDims); + final var planes = Starlet.decompose(input, scales, () -> createOp.apply( + input, new FloatType())); + final var sigma = mad(planes.get(0), (int) size) / 0.6745 / levels[0]; + + final var output = planes.get(scales); + for (var j = 0; j < scales; j++) { + final var t = kSigma * sigma * levels[j]; + LoopBuilder.setImages(planes.get(j), output).forEachPixel((w, o) -> { + if (Math.abs(w.getRealDouble()) > t) o.add(w); + }); + } + return output; + } + + /** Computes the median absolute deviation from the median. */ + private static double mad(final RandomAccessibleInterval img, + final int size) + { + final var values = new double[size]; + var i = 0; + for (final var t : Views.flatIterable(img)) + values[i++] = t.getRealDouble(); + final var median = median(values); + for (i = 0; i < size; i++) + values[i] = Math.abs(values[i] - median); + return median(values); + } + + private static double median(final double[] values) { + final var sorted = values.clone(); + Arrays.sort(sorted); + final var m = sorted.length / 2; + return sorted.length % 2 == 1 ? sorted[m] : (sorted[m - 1] + sorted[m]) / + 2; + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/filter/weightedMedian/DefaultWeightedMedian.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/filter/weightedMedian/DefaultWeightedMedian.java new file mode 100644 index 000000000..7e534f72f --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/filter/weightedMedian/DefaultWeightedMedian.java @@ -0,0 +1,202 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.filter.weightedMedian; + +import java.util.ArrayList; + +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.loops.LoopBuilder; +import net.imglib2.outofbounds.OutOfBoundsBorderFactory; +import net.imglib2.outofbounds.OutOfBoundsFactory; +import net.imglib2.type.numeric.RealType; +import net.imglib2.util.Intervals; +import net.imglib2.view.Views; + +import org.scijava.function.Computers; +import org.scijava.ops.spi.Nullable; + +/** + * Weighted median filter. + *

+ * Each output pixel is the weighted median of the input values in a + * neighborhood given by a weight kernel: the smallest value {@code v} such that + * the summed weight of all neighborhood values {@code <= v} is at least half of + * the total weight. With uniform weights over an odd number of pixels, this is + * the ordinary median. The kernel may have any number of dimensions (matching + * the input) and must have an odd size in every dimension; its center pixel is + * aligned with the output pixel. Weights must be non-negative; pixels with + * zero weight are ignored. + *

+ *

+ * The input and output must have the same dimensions, but may have different + * minima: output pixels correspond to input pixels at the same offset from + * the respective minimum. Results are clamped to the range of the output type. + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @param input pixel type + * @param kernel pixel type + * @param output pixel type + * @implNote op names='filter.weightedMedian' + */ +public class DefaultWeightedMedian, K extends RealType, V extends RealType> + implements + Computers.Arity3, RandomAccessibleInterval, OutOfBoundsFactory>, RandomAccessibleInterval> +{ + + /** + * Applies the weighted median filter. + * + * @param input the image to filter + * @param kernel the weights of the neighborhood, centered on each pixel + * @param outOfBounds how to extend the input beyond its bounds (default: + * repeat the border values) + * @param output the filtered image + */ + @Override + public void compute(final RandomAccessibleInterval input, + final RandomAccessibleInterval kernel, + @Nullable OutOfBoundsFactory> outOfBounds, + final RandomAccessibleInterval output) + { + if (!Intervals.equalDimensions(input, output)) { + throw new IllegalArgumentException( + "Input and output must have the same dimensions"); + } + final var n = input.numDimensions(); + if (kernel.numDimensions() != n) { + throw new IllegalArgumentException("Kernel must have " + n + + " dimensions"); + } + for (var d = 0; d < n; d++) { + if (kernel.dimension(d) % 2 == 0) { + throw new IllegalArgumentException( + "Kernel must have an odd size in every dimension"); + } + } + if (outOfBounds == null) outOfBounds = new OutOfBoundsBorderFactory<>(); + + // Collect the offsets and weights of the nonzero kernel entries. + final var offsetList = new ArrayList(); + final var weightList = new ArrayList(); + final var kc = Views.flatIterable(kernel).localizingCursor(); + while (kc.hasNext()) { + final var w = kc.next().getRealDouble(); + if (w < 0) { + throw new IllegalArgumentException("Kernel weights must be >= 0"); + } + if (w == 0) continue; + final var offset = new long[n]; + for (var d = 0; d < n; d++) { + offset[d] = kc.getLongPosition(d) - (kernel.min(d) + kernel.max(d)) / + 2; + } + offsetList.add(offset); + weightList.add(w); + } + final var count = offsetList.size(); + if (count == 0) throw new IllegalArgumentException( + "Kernel has no positive weights"); + final var offsets = offsetList.toArray(new long[0][]); + final var weights = weightList.stream().mapToDouble(Double::doubleValue) + .toArray(); + double totalWeight = 0; + for (final var w : weights) + totalWeight += w; + final var halfWeight = totalWeight / 2; + + // Note: Output pixel p reads the input around p + (inMin - outMin), so + // that the input and output may have different minima. + final var shift = new long[n]; + for (var d = 0; d < n; d++) + shift[d] = input.min(d) - output.min(d); + final var extended = Views.extend(input, outOfBounds); + LoopBuilder.setImages(Intervals.positions(output), output).multiThreaded() + .forEachChunk(chunk -> { + final var ra = extended.randomAccess(); + final var values = new double[count]; + final var w = new double[count]; + final var pos = new long[n]; + chunk.forEachPixel((p, o) -> { + p.localize(pos); + for (var i = 0; i < count; i++) { + for (var d = 0; d < n; d++) { + ra.setPosition(pos[d] + shift[d] + offsets[i][d], d); + } + values[i] = ra.get().getRealDouble(); + w[i] = weights[i]; + } + final var v = weightedMedian(values, w, count, halfWeight); + o.setReal(Math.max(o.getMinValue(), Math.min(o.getMaxValue(), v))); + }); + return null; + }); + } + + /** + * Computes the weighted median of the given values. Both arrays are + * reordered in the process. + */ + static double weightedMedian(final double[] values, final double[] weights, + final int count, final double halfWeight) + { + sortPairs(values, weights, count); + double cumulative = 0; + for (var i = 0; i < count; i++) { + cumulative += weights[i]; + if (cumulative >= halfWeight) return values[i]; + } + return values[count - 1]; + } + + /** Sorts values ascending, permuting weights along (Shell sort). */ + private static void sortPairs(final double[] v, final double[] w, + final int count) + { + var gap = 1; + while (gap < count / 3) + gap = 3 * gap + 1; + for (; gap > 0; gap /= 3) { + for (var i = gap; i < count; i++) { + final var tv = v[i]; + final var tw = w[i]; + var j = i; + while (j >= gap && v[j - gap] > tv) { + v[j] = v[j - gap]; + w[j] = w[j - gap]; + j -= gap; + } + v[j] = tv; + w[j] = tw; + } + } + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/filter/weightedMedian/GaussianWeightedMedian.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/filter/weightedMedian/GaussianWeightedMedian.java new file mode 100644 index 000000000..075481f91 --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/filter/weightedMedian/GaussianWeightedMedian.java @@ -0,0 +1,134 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.filter.weightedMedian; + +import java.util.Arrays; + +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.img.array.ArrayImg; +import net.imglib2.img.array.ArrayImgs; +import net.imglib2.img.basictypeaccess.array.DoubleArray; +import net.imglib2.outofbounds.OutOfBoundsFactory; +import net.imglib2.type.numeric.RealType; +import net.imglib2.type.numeric.real.DoubleType; + +import org.scijava.function.Computers; +import org.scijava.ops.spi.Nullable; +import org.scijava.ops.spi.OpDependency; + +/** + * Gaussian-weighted median filter: a {@code filter.weightedMedian} whose + * weights are {@code exp(-d² / (2σ²))} over a hypersphere of the given radius + * around each pixel, where {@code d} is the distance to the center pixel. + * Compared to a plain median filter, pixels closer to the center have more + * influence, which preserves fine structures and edges better at the same + * radius. + *

+ * Inspired by the {@code Gaussian_Weighted_Median} plugin of the BioVoxxel + * Toolbox by Jan Brocher / BioVoxxel. That plugin carries a GPL-3 license + * header, so this op was implemented independently from the general + * definition of a Gaussian-weighted median (real-valued Gaussian weights with + * a user-defined sigma and radius, in n dimensions), not from its source code, + * so its results need not match those of the plugin. + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @param input pixel type + * @param output pixel type + * @see BioVoxxel + * Toolbox + * @implNote op names='filter.gaussianWeightedMedian' + */ +public class GaussianWeightedMedian, V extends RealType> + implements + Computers.Arity4, Double, Integer, OutOfBoundsFactory>, RandomAccessibleInterval> +{ + + @OpDependency(name = "filter.weightedMedian") + private Computers.Arity3, RandomAccessibleInterval, OutOfBoundsFactory>, RandomAccessibleInterval> weightedMedianOp; + + /** + * Applies the Gaussian-weighted median filter. + * + * @param input the image to filter + * @param sigma the standard deviation of the Gaussian weights, in pixels + * @param radius the radius of the hyperspherical neighborhood, in pixels + * (default: {@code ceil(2σ)}) + * @param outOfBounds how to extend the input beyond its bounds (default: + * repeat the border values) + * @param output the filtered image + */ + @Override + public void compute(final RandomAccessibleInterval input, + final Double sigma, @Nullable Integer radius, + @Nullable final OutOfBoundsFactory> outOfBounds, + final RandomAccessibleInterval output) + { + if (sigma <= 0) { + throw new IllegalArgumentException("Sigma must be positive"); + } + if (radius == null) radius = (int) Math.ceil(2 * sigma); + if (radius < 0) { + throw new IllegalArgumentException("Radius must be non-negative"); + } + final var kernel = kernel(input.numDimensions(), sigma, radius); + weightedMedianOp.compute(input, kernel, outOfBounds, output); + } + + /** + * Creates a hyperspherical kernel of Gaussian weights. + * + * @param numDimensions the dimensionality of the kernel + * @param sigma the standard deviation of the Gaussian + * @param radius the radius of the hypersphere + * @return the kernel, with size {@code 2 * radius + 1} in every dimension + */ + static ArrayImg kernel(final int numDimensions, + final double sigma, final int radius) + { + final var dims = new long[numDimensions]; + Arrays.fill(dims, 2L * radius + 1); + final var kernel = ArrayImgs.doubles(dims); + final var c = kernel.localizingCursor(); + while (c.hasNext()) { + c.fwd(); + double distSq = 0; + for (var d = 0; d < numDimensions; d++) { + final double offset = c.getLongPosition(d) - radius; + distSq += offset * offset; + } + if (distSq <= (double) radius * radius) { + c.get().set(Math.exp(-distSq / (2 * sigma * sigma))); + } + } + return kernel; + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/geom/compactness/DefaultCompactness.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/geom/compactness/DefaultCompactness.java new file mode 100644 index 000000000..a00d91157 --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/geom/compactness/DefaultCompactness.java @@ -0,0 +1,114 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.geom.compactness; + +import java.util.function.Function; + +import net.imglib2.roi.geom.real.Polygon2D; +import net.imglib2.type.numeric.real.DoubleType; + +import org.scijava.function.Computers; +import org.scijava.ops.spi.OpDependency; + +/** + * Computes the compactness of a polygon: the diameter of the circle with the + * same area, divided by the major axis of the fitted ellipse, + * {@code sqrt(4 A / π) / major}. It is 1 for a circle and smaller for + * elongated shapes; it equals the square root of {@code geom.roundness}. + *

+ * Based on the "Compactness" shape descriptor of the + * {@code Extended_Particle_Analyzer} plugin of the BioVoxxel Toolbox by Jan + * Brocher / BioVoxxel (BSD 3-Clause license, Copyright (C) Jan Brocher / + * BioVoxxel). + *

+ *

+ * The original BioVoxxel Toolbox license notice follows. + *

+ * + *
+ * Copyright (c) 2015, BioVoxxel / Jan Brocher
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ *
+ * * Redistributions of source code must retain the above copyright notice, this
+ *   list of conditions and the following disclaimer.
+ *
+ * * 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.
+ *
+ * * Neither the name of BioVoxxel nor the names of its
+ *   contributors may be used to endorse or promote products derived from
+ *   this software without specific prior written permission.
+ *
+ * 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 HOLDER 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.
+ * 
+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @author Jan Brocher (BioVoxxel) + * @see BioVoxxel + * Toolbox + * @implNote op names='geom.compactness', label='Geometric (2D): Compactness' + */ +public class DefaultCompactness implements + Computers.Arity1 +{ + + @OpDependency(name = "geom.size") + private Function areaFunc; + + @OpDependency(name = "geom.majorAxis") + private Function majorAxisFunc; + + /** + * Computes the compactness. + * + * @param input the polygon + * @param compactness the compactness of the polygon + */ + @Override + public void compute(final Polygon2D input, final DoubleType compactness) { + final var area = areaFunc.apply(input).getRealDouble(); + final var major = majorAxisFunc.apply(input).getRealDouble(); + compactness.set(Math.sqrt(4 * area / Math.PI) / major); + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/geom/extent/Extents.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/geom/extent/Extents.java new file mode 100644 index 000000000..84f976400 --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/geom/extent/Extents.java @@ -0,0 +1,137 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.geom.extent; + +import java.util.function.Function; + +import net.imglib2.roi.IterableRegion; +import net.imglib2.roi.geom.real.Polygon2D; +import net.imglib2.type.BooleanType; +import net.imglib2.type.numeric.real.DoubleType; +import net.imglib2.util.Intervals; + +import org.scijava.ops.spi.OpDependency; + +/** + * Ops computing the extent of a shape: the ratio of its size to the size of + * its axis-aligned bounding box. The extent is 1 for an axis-aligned + * rectangle (or box) and decreases for shapes that fill their bounding box + * less completely. Unlike {@code geom.boxivity}, which uses the smallest + * (possibly rotated) enclosing rectangle, the extent depends on the + * orientation of the shape. + *

+ * Based on the "Extent" shape descriptor of the {@code Extended_Particle_Analyzer} + * plugin of the BioVoxxel Toolbox by Jan Brocher / BioVoxxel (BSD 3-Clause + * license, Copyright (C) Jan Brocher / BioVoxxel), generalized to n-D + * regions. + *

+ *

+ * The original BioVoxxel Toolbox license notice follows. + *

+ * + *
+ * Copyright (c) 2015, BioVoxxel / Jan Brocher
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ *
+ * * Redistributions of source code must retain the above copyright notice, this
+ *   list of conditions and the following disclaimer.
+ *
+ * * 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.
+ *
+ * * Neither the name of BioVoxxel nor the names of its
+ *   contributors may be used to endorse or promote products derived from
+ *   this software without specific prior written permission.
+ *
+ * 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 HOLDER 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.
+ * 
+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @author Jan Brocher (BioVoxxel) + * @see BioVoxxel + * Toolbox + */ +public final class Extents { + + private Extents() { + // Prevent instantiation of static utility class. + } + + /** + * Computes the extent of a polygon: its area divided by the area of its + * axis-aligned bounding box. + * + * @param sizeOp the Op computing the area of the polygon + * @param input the polygon + * @param output the extent + * @implNote op names='geom.extent', label='Geometric (2D): Extent', + * type=Computer + */ + public static void polygon( // + @OpDependency(name = "geom.size") Function sizeOp, // + final Polygon2D input, // + final DoubleType output // + ) { + final var boxArea = (input.realMax(0) - input.realMin(0)) * (input + .realMax(1) - input.realMin(1)); + output.set(sizeOp.apply(input).get() / boxArea); + } + + /** + * Computes the extent of an n-dimensional region, such as a + * {@link net.imglib2.roi.labeling.LabelRegion}: its number of elements + * divided by the number of elements of its bounding interval. + * + * @param input the region + * @param output the extent + * @param the boolean type of the region + * @implNote op names='geom.extent', label='Geometric: Extent', + * type=Computer + */ + public static > void region( + final IterableRegion input, final DoubleType output) + { + output.set((double) input.inside().size() / Intervals.numElements(input)); + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/geom/feretAspectRatio/DefaultFeretAspectRatio.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/geom/feretAspectRatio/DefaultFeretAspectRatio.java new file mode 100644 index 000000000..c7890e578 --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/geom/feretAspectRatio/DefaultFeretAspectRatio.java @@ -0,0 +1,114 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.geom.feretAspectRatio; + +import java.util.function.Function; + +import net.imglib2.roi.geom.real.Polygon2D; +import net.imglib2.type.numeric.real.DoubleType; + +import org.scijava.function.Computers; +import org.scijava.ops.spi.OpDependency; + +/** + * Computes the Feret aspect ratio of a polygon: its maximum Feret diameter + * divided by its minimum Feret diameter. It is 1 for a circle and grows for + * elongated shapes. Unlike the ellipse-based aspect ratio, it does not depend + * on the distribution of the area inside the shape. + *

+ * Based on the "Feret aspect ratio" (FAR) shape descriptor of the + * {@code Extended_Particle_Analyzer} plugin of the BioVoxxel Toolbox by Jan + * Brocher / BioVoxxel (BSD 3-Clause license, Copyright (C) Jan Brocher / + * BioVoxxel). + *

+ *

+ * The original BioVoxxel Toolbox license notice follows. + *

+ * + *
+ * Copyright (c) 2015, BioVoxxel / Jan Brocher
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ *
+ * * Redistributions of source code must retain the above copyright notice, this
+ *   list of conditions and the following disclaimer.
+ *
+ * * 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.
+ *
+ * * Neither the name of BioVoxxel nor the names of its
+ *   contributors may be used to endorse or promote products derived from
+ *   this software without specific prior written permission.
+ *
+ * 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 HOLDER 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.
+ * 
+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @author Jan Brocher (BioVoxxel) + * @see BioVoxxel + * Toolbox + * @implNote op names='geom.feretAspectRatio', label='Geometric (2D): Feret + * Aspect Ratio' + */ +public class DefaultFeretAspectRatio implements + Computers.Arity1 +{ + + @OpDependency(name = "geom.maximumFeretsDiameter") + private Function maxFeretFunc; + + @OpDependency(name = "geom.minimumFeretsDiameter") + private Function minFeretFunc; + + /** + * Computes the Feret aspect ratio. + * + * @param input the polygon + * @param ratio the Feret aspect ratio of the polygon + */ + @Override + public void compute(final Polygon2D input, final DoubleType ratio) { + ratio.set(maxFeretFunc.apply(input).getRealDouble() / minFeretFunc.apply( + input).getRealDouble()); + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultMinorMajorAxis.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultMinorMajorAxis.java index 4f06b26ad..99e3c4499 100644 --- a/scijava-ops-image/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultMinorMajorAxis.java +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultMinorMajorAxis.java @@ -53,77 +53,41 @@ public class DefaultMinorMajorAxis implements { /** - * Code taken from ImageJ ({@code EllipseFitter#getEllipseParam()}) and - * adapted to work with a {@link Polygon2D}. + * Computes the axes of the moment-equivalent ellipse, as ImageJ 1.x + * ({@code EllipseFitter}) does: the axis lengths are proportional to the + * square roots of the eigenvalues of the covariance matrix of the polygon, + * scaled so that the ellipse has the same area as the polygon. * - * @param input - * @param points vertices of polygon in counter clockwise order. + * @param input the polygon + * @param points vertices of polygon, with the first vertex repeated last * @return the minor and major axis */ private double[] getMinorMajorAxis(final Polygon2D input, final List points) { - var moments = getMoments(input, points); - - var m00 = moments[0]; - var u20 = moments[1]; - var u11 = moments[2]; - var u02 = moments[3]; - - var m4 = 4.0 * Math.abs(u02 * u20 - u11 * u11); - if (m4 < 0.000001) { - m4 = 0.000001; - } - - var a11 = u02 / m4; - var a12 = u11 / m4; - var a22 = u20 / m4; - - var tmp = a11 - a22; - if (tmp == 0.0) { - tmp = 0.000001; - } - - var ta = 0.5 * Math.atan(2.0 * a12 / tmp); - if (ta < 0.0) { - ta += Math.PI / 2d; + final var moments = getMoments(input, points); + // Note: the area is negative for clockwise polygons. + final var area = Math.abs(moments[0]); + final var u20 = moments[1]; + final var u11 = moments[2]; + final var u02 = moments[3]; + + // Eigenvalues of the covariance matrix [[u20, u11], [u11, u02]]. + final var mean = (u20 + u02) / 2; + final var diff = (u20 - u02) / 2; + final var root = Math.sqrt(diff * diff + u11 * u11); + final var l1 = mean + root; + final var l2 = Math.max(mean - root, 0); + + // For an ellipse with semi-axes a, b: l1 = a^2 / 4 and l2 = b^2 / 4. + var major = 2 * Math.sqrt(l1); + var minor = 2 * Math.sqrt(l2); + if (major * minor > 0) { + final var scale = Math.sqrt(area / (Math.PI * major * minor)); + major *= scale; + minor *= scale; } - if (a12 > 0.0) { - ta += Math.PI / 2d; - } - - else if (a12 == 0.0) { - if (a22 > a11) { - ta = 0.0; - tmp = a22; - a22 = a11; - a11 = tmp; - } - else if (a11 != a22) { - ta = Math.PI / 2d; - } - } - - tmp = Math.sin(ta); - if (tmp == 0.0) tmp = 0.000001; - var z = a12 * Math.cos(ta) / tmp; - var major = Math.sqrt(1.0 / Math.abs(a22 + z)); - var minor = Math.sqrt(1.0 / Math.abs(a11 - z)); - // equalize areas - var scale = Math.sqrt(m00 / (Math.PI * major * minor)); - - major = major * scale * 2.0; - minor = minor * scale * 2.0; - var angle = 180.0 * ta / Math.PI; - - if (angle == 180.0) angle = 0.0; - if (major < minor) { - tmp = major; - major = minor; - minor = tmp; - } - - return new double[] { minor, major }; + return new double[] { 2 * minor, 2 * major }; } /** diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/geom/geom3d/DefaultInertiaTensor3D.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/geom/geom3d/DefaultInertiaTensor3D.java index 79b92b577..6e70aec08 100644 --- a/scijava-ops-image/src/main/java/org/scijava/ops/image/geom/geom3d/DefaultInertiaTensor3D.java +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/geom/geom3d/DefaultInertiaTensor3D.java @@ -80,17 +80,17 @@ public RealMatrix apply(final IterableRegion input) { c.localize(pos); output.setEntry(0, 0, output.getEntry(0, 0) + (pos[0] - mX) * (pos[0] - mX)); - output.setEntry(1, 1, output.getEntry(1, 1) + (pos[1] - mX) * (pos[1] - + output.setEntry(1, 1, output.getEntry(1, 1) + (pos[1] - mY) * (pos[1] - mY)); - output.setEntry(2, 2, output.getEntry(2, 2) + (pos[2] - mX) * (pos[2] - + output.setEntry(2, 2, output.getEntry(2, 2) + (pos[2] - mZ) * (pos[2] - mZ)); - output.setEntry(0, 1, output.getEntry(0, 1) + (pos[0] - mY) * (pos[1] - + output.setEntry(0, 1, output.getEntry(0, 1) + (pos[0] - mX) * (pos[1] - mY)); output.setEntry(1, 0, output.getEntry(0, 1)); - output.setEntry(0, 2, output.getEntry(0, 2) + (pos[0] - mY) * (pos[2] - + output.setEntry(0, 2, output.getEntry(0, 2) + (pos[0] - mX) * (pos[2] - mZ)); output.setEntry(2, 0, output.getEntry(0, 2)); - output.setEntry(1, 2, output.getEntry(1, 2) + (pos[1] - mZ) * (pos[2] - + output.setEntry(1, 2, output.getEntry(1, 2) + (pos[1] - mY) * (pos[2] - mZ)); output.setEntry(2, 1, output.getEntry(1, 2)); } diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/image/bleach/Bleach.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/image/bleach/Bleach.java new file mode 100644 index 000000000..0e11e471a --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/image/bleach/Bleach.java @@ -0,0 +1,218 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.image.bleach; + +import java.util.Arrays; + +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.type.numeric.RealType; +import net.imglib2.util.Intervals; +import net.imglib2.view.Views; + +import org.apache.commons.math3.analysis.ParametricUnivariateFunction; +import org.apache.commons.math3.fitting.SimpleCurveFitter; +import org.apache.commons.math3.fitting.WeightedObservedPoints; + +/** + * Utility methods for photobleaching correction and histogram matching. + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +final class Bleach { + + /** The function {@code a * exp(-b t) + c} with parameters (a, b, c). */ + static final ParametricUnivariateFunction EXP_OFFSET = + new ParametricUnivariateFunction() + { + + @Override + public double value(final double t, final double... p) { + return p[0] * Math.exp(-p[1] * t) + p[2]; + } + + @Override + public double[] gradient(final double t, final double... p) { + final var e = Math.exp(-p[1] * t); + return new double[] { e, -p[0] * t * e, 1 }; + } + }; + + private Bleach() { + // Prevent instantiation of utility class. + } + + /** Checks that a dimension index is valid for the given image. */ + static void checkDim(final RandomAccessibleInterval img, final int d) { + if (d < 0 || d >= img.numDimensions()) { + throw new IllegalArgumentException("Invalid dimension " + d + + " for a " + img.numDimensions() + "-dimensional image"); + } + } + + /** Returns the hyperslice at index {@code t} (relative to the minimum). */ + static RandomAccessibleInterval frame( + final RandomAccessibleInterval img, final int timeDim, final long t) + { + return Views.hyperSlice(img, timeDim, img.min(timeDim) + t); + } + + /** Computes the mean intensity of each frame along the given dimension. */ + static > double[] frameMeans( + final RandomAccessibleInterval input, final int timeDim) + { + checkDim(input, timeDim); + final var means = new double[(int) input.dimension(timeDim)]; + for (var t = 0; t < means.length; t++) { + double sum = 0; + long count = 0; + for (final var v : frame(input, timeDim, t)) { + sum += v.getRealDouble(); + count++; + } + means[t] = sum / count; + } + return means; + } + + /** + * Fits {@code y = a * exp(-b t) + c} to the given values (with + * {@code t = 0, 1, ...}) by Levenberg-Marquardt least squares. + * + * @return the parameters {@code (a, b, c)} + */ + static double[] fitExponential(final double[] y) { + var min = Double.POSITIVE_INFINITY; + var max = Double.NEGATIVE_INFINITY; + for (final var v : y) { + min = Math.min(min, v); + max = Math.max(max, v); + } + final var mean = Arrays.stream(y).average().orElse(0); + if (y.length < 3 || !(max - min > 1e-12 * Math.max(1, Math.abs(max)))) { + return new double[] { 0, 0, mean }; + } + // Initial guess: linear regression of log(y - c0) against t. + final var c0 = min - 0.1 * (max - min); + double st = 0, sz = 0, stt = 0, stz = 0; + for (var t = 0; t < y.length; t++) { + final var z = Math.log(y[t] - c0); + st += t; + sz += z; + stt += t * t; + stz += t * z; + } + final var n = y.length; + final var slope = (n * stz - st * sz) / (n * stt - st * st); + final var intercept = (sz - slope * st) / n; + final var points = new WeightedObservedPoints(); + for (var t = 0; t < n; t++) + points.add(t, y[t]); + return SimpleCurveFitter.create(EXP_OFFSET, new double[] { Math.exp( + intercept), -slope, c0 }).withMaxIterations(10000).fit(points.toList()); + } + + /** + * Writes {@code input * factor} to {@code output}, clamped to the range of + * the output type. + */ + static , V extends RealType> void scale( + final RandomAccessibleInterval input, final double factor, + final RandomAccessibleInterval output) + { + final var c = Views.flatIterable(input).cursor(); + final var o = Views.flatIterable(output).cursor(); + while (c.hasNext()) { + final var out = o.next(); + final var v = c.next().getRealDouble() * factor; + out.setReal(Math.max(out.getMinValue(), Math.min(out.getMaxValue(), v))); + } + } + + /** + * Maps the values of {@code input} so that their cumulative distribution + * matches that of {@code reference}. A value {@code v} of the input, with + * {@code F(v)} the fraction of input values less than or equal to + * {@code v}, maps to the smallest reference value {@code r} such that the + * fraction of reference values less than or equal to {@code r} is at least + * {@code F(v)}. + */ + static , U extends RealType, V extends RealType> + void matchHistogram(final RandomAccessibleInterval input, + final RandomAccessibleInterval reference, + final RandomAccessibleInterval output) + { + if (!Intervals.equalDimensions(input, output)) { + throw new IllegalArgumentException( + "Input and output must have the same dimensions"); + } + final var in = values(input); + final var ref = values(reference); + if (ref.length == 0) { + throw new IllegalArgumentException("Reference image is empty"); + } + final var sortedIn = in.clone(); + Arrays.sort(sortedIn); + Arrays.sort(ref); + var i = 0; + for (final var o : Views.flatIterable(output)) { + final var atMost = upperBound(sortedIn, in[i++]); + final var k = (int) Math.ceil((double) atMost * ref.length / + sortedIn.length) - 1; + o.setReal(ref[Math.max(0, Math.min(ref.length - 1, k))]); + } + } + + private static > double[] values( + final RandomAccessibleInterval img) + { + final var size = Intervals.numElements(img); + if (size > Integer.MAX_VALUE) { + throw new IllegalArgumentException("Image is too large: " + size + + " pixels"); + } + final var values = new double[(int) size]; + var i = 0; + for (final var t : Views.flatIterable(img)) + values[i++] = t.getRealDouble(); + return values; + } + + /** Returns the number of elements of a sorted array that are at most v. */ + private static int upperBound(final double[] sorted, final double v) { + int lo = 0, hi = sorted.length; + while (lo < hi) { + final var mid = (lo + hi) >>> 1; + if (sorted[mid] <= v) lo = mid + 1; + else hi = mid; + } + return lo; + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/image/bleach/BleachCorrect.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/image/bleach/BleachCorrect.java new file mode 100644 index 000000000..c37368aae --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/image/bleach/BleachCorrect.java @@ -0,0 +1,125 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.image.bleach; + +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.type.numeric.RealType; +import net.imglib2.util.Intervals; + +import org.scijava.function.Computers; +import org.scijava.ops.spi.Nullable; + +/** + * Photobleaching correction of a time series. + *

+ * Each frame (hyperslice along {@code timeDim}) is corrected with one of the + * following methods: + *

+ *
    + *
  • {@code "ratio"}: frame {@code t} is multiplied by {@code m_0 / m_t}, + * where {@code m_t} is the mean intensity of frame {@code t}, so that all + * frames have the mean intensity of the first frame.
  • + *
  • {@code "exponential"}: the decay model {@code f(t) = a exp(-b t) + c} + * is fitted to the frame means by Levenberg-Marquardt least squares, and + * frame {@code t} is multiplied by {@code f(0) / f(t)}. This is less + * sensitive than the ratio method to intensity changes that are not caused + * by bleaching. The fitted parameters are available from + * {@code image.bleachFit}.
  • + *
  • {@code "histogram"}: the intensity histogram of each frame is matched + * to that of the first frame (see {@code image.histogramMatch}). This also + * corrects for contrast changes, but alters intensity relationships, so it + * is meant for segmentation rather than quantification.
  • + *
+ *

+ * Values are clamped to the range of the output type. These are the standard + * methods described by Miura (2020); this implementation was written from + * that description. + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @param input pixel type + * @param output pixel type + * @see K. Miura, + * "Bleach correction ImageJ plugin for compensating the photobleaching + * of time-lapse sequences", F1000Research 9:1494, 2020 + * @implNote op names='image.bleachCorrect' + */ +public class BleachCorrect, V extends RealType> + implements + Computers.Arity3, Integer, String, RandomAccessibleInterval> +{ + + /** + * Corrects photobleaching. + * + * @param input the time series to correct + * @param timeDim the index of the time dimension + * @param method the correction method: ratio, exponential or histogram + * (default: ratio) + * @param output the corrected time series + */ + @Override + public void compute(final RandomAccessibleInterval input, + final Integer timeDim, @Nullable final String method, + final RandomAccessibleInterval output) + { + if (!Intervals.equalDimensions(input, output)) { + throw new IllegalArgumentException( + "Input and output must have the same dimensions"); + } + Bleach.checkDim(input, timeDim); + final var m = method == null ? "ratio" : method; + final var frames = input.dimension(timeDim); + if (m.equals("histogram")) { + final var reference = Bleach.frame(input, timeDim, 0); + for (var t = 0; t < frames; t++) { + Bleach.matchHistogram(Bleach.frame(input, timeDim, t), reference, + Bleach.frame(output, timeDim, t)); + } + return; + } + final var means = Bleach.frameMeans(input, timeDim); + final double[] model; + if (m.equals("ratio")) model = means; + else if (m.equals("exponential")) { + final var p = Bleach.fitExponential(means); + model = new double[means.length]; + for (var t = 0; t < model.length; t++) + model[t] = Bleach.EXP_OFFSET.value(t, p); + } + else throw new IllegalArgumentException("Unknown method: " + m); + for (var t = 0; t < frames; t++) { + final var factor = model[t] == 0 ? 1 : model[0] / model[t]; + Bleach.scale(Bleach.frame(input, timeDim, t), factor, Bleach.frame( + output, timeDim, t)); + } + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/image/bleach/BleachFit.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/image/bleach/BleachFit.java new file mode 100644 index 000000000..f8a822987 --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/image/bleach/BleachFit.java @@ -0,0 +1,65 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.image.bleach; + +import java.util.function.BiFunction; + +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.type.numeric.RealType; + +/** + * Fits the photobleaching model {@code f(t) = a exp(-b t) + c} to the mean + * intensity of each frame of a time series, by Levenberg-Marquardt least + * squares, with {@code t} the frame index starting at 0. This is the model + * used by the exponential method of {@code image.bleachCorrect}. + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @param input pixel type + * @implNote op names='image.bleachFit' + */ +public class BleachFit> implements + BiFunction, Integer, double[]> +{ + + /** + * Fits the bleaching model. + * + * @param input the time series + * @param timeDim the index of the time dimension + * @return the fitted parameters {@code a}, {@code b} and {@code c} + */ + @Override + public double[] apply(final RandomAccessibleInterval input, + final Integer timeDim) + { + return Bleach.fitExponential(Bleach.frameMeans(input, timeDim)); + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/image/bleach/HistogramMatch.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/image/bleach/HistogramMatch.java new file mode 100644 index 000000000..9e47cf004 --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/image/bleach/HistogramMatch.java @@ -0,0 +1,127 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.image.bleach; + +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.type.numeric.RealType; +import net.imglib2.util.Intervals; + +import org.scijava.function.Computers; +import org.scijava.ops.spi.Nullable; + +/** + * Histogram matching (histogram specification): maps the intensities of an + * image so that their distribution matches that of a reference image. + *

+ * Let {@code F(v)} be the fraction of input pixels with value at most + * {@code v}, and {@code G(r)} the same for the reference. Each input value + * {@code v} is mapped to the smallest reference value {@code r} with + * {@code G(r) >= F(v)}. Output values are therefore always values occurring + * in the reference; matching an image to itself is the identity. The images + * need not have the same size. Exact values are used, without binning. + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @param input pixel type + * @param reference pixel type + * @param output pixel type + * @see Histogram + * matching + * @implNote op names='image.histogramMatch' + */ +public class HistogramMatch, U extends RealType, V extends RealType> + implements + Computers.Arity2, RandomAccessibleInterval, RandomAccessibleInterval> +{ + + /** + * Matches the histogram of an image to that of a reference. + * + * @param input the image whose intensities are mapped + * @param reference the image with the desired intensity distribution + * @param output the mapped image + */ + @Override + public void compute(final RandomAccessibleInterval input, + final RandomAccessibleInterval reference, + final RandomAccessibleInterval output) + { + Bleach.matchHistogram(input, reference, output); + } +} + +/** + * Matches the histogram of every frame of a time series to that of a + * reference frame (see the two-image version of + * {@code image.histogramMatch}). + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @param input pixel type + * @param output pixel type + * @implNote op names='image.histogramMatch' + */ +class HistogramMatchFrames, V extends RealType> + implements + Computers.Arity3, Integer, Integer, RandomAccessibleInterval> +{ + + /** + * Matches the histogram of each frame to that of a reference frame. + * + * @param input the time series + * @param timeDim the index of the time dimension + * @param referenceFrame the index of the reference frame, relative to the + * start of the time dimension (default: 0) + * @param output the matched time series + */ + @Override + public void compute(final RandomAccessibleInterval input, + final Integer timeDim, @Nullable final Integer referenceFrame, + final RandomAccessibleInterval output) + { + Bleach.checkDim(input, timeDim); + if (!Intervals.equalDimensions(input, output)) { + throw new IllegalArgumentException( + "Input and output must have the same dimensions"); + } + final var frames = input.dimension(timeDim); + final long ref = referenceFrame == null ? 0 : referenceFrame; + if (ref < 0 || ref >= frames) { + throw new IllegalArgumentException("Invalid reference frame: " + ref); + } + final var reference = Bleach.frame(input, timeDim, ref); + for (var t = 0; t < frames; t++) { + Bleach.matchHistogram(Bleach.frame(input, timeDim, t), reference, Bleach + .frame(output, timeDim, t)); + } + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/image/distancetransform/DefaultDistanceTransform.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/image/distancetransform/DefaultDistanceTransform.java index e9b0a1b33..dde65ee15 100644 --- a/scijava-ops-image/src/main/java/org/scijava/ops/image/image/distancetransform/DefaultDistanceTransform.java +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/image/distancetransform/DefaultDistanceTransform.java @@ -39,6 +39,7 @@ import net.imglib2.RandomAccessibleInterval; import net.imglib2.type.BooleanType; import net.imglib2.type.numeric.RealType; +import net.imglib2.view.Views; import net.imglib2.util.IntervalIndexer; /** @@ -58,8 +59,12 @@ private DefaultDistanceTransform() { * Source: http://fab.cba.mit.edu/classes/S62.12/docs/Meijster_distance.pdf */ public static , T extends RealType> void compute( - final RandomAccessibleInterval in, final RandomAccessibleInterval out) + final RandomAccessibleInterval input, final RandomAccessibleInterval output) { + // Note: The scans below index pixels from zero, so both images are + // aligned at the origin. This keeps inputs with a non-zero min working. + final RandomAccessibleInterval in = Views.zeroMin(input); + final RandomAccessibleInterval out = Views.zeroMin(output); // stores the size of each dimension final var dimensSizes = new int[in.numDimensions()]; diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/image/distancetransform/DefaultDistanceTransformCalibration.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/image/distancetransform/DefaultDistanceTransformCalibration.java index d68de4331..4847c3f22 100644 --- a/scijava-ops-image/src/main/java/org/scijava/ops/image/image/distancetransform/DefaultDistanceTransformCalibration.java +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/image/distancetransform/DefaultDistanceTransformCalibration.java @@ -40,6 +40,7 @@ import net.imglib2.RandomAccessibleInterval; import net.imglib2.type.BooleanType; import net.imglib2.type.numeric.RealType; +import net.imglib2.view.Views; import net.imglib2.util.IntervalIndexer; /** @@ -59,9 +60,13 @@ private DefaultDistanceTransformCalibration() { * Source: http://fab.cba.mit.edu/classes/S62.12/docs/Meijster_distance.pdf */ public static , T extends RealType> void compute( - final RandomAccessibleInterval in, final double[] calibration, - final RandomAccessibleInterval out) + final RandomAccessibleInterval input, final double[] calibration, + final RandomAccessibleInterval output) { + // Note: The scans below index pixels from zero, so both images are + // aligned at the origin. This keeps inputs with a non-zero min working. + final RandomAccessibleInterval in = Views.zeroMin(input); + final RandomAccessibleInterval out = Views.zeroMin(output); // stores the size of each dimension final var dimensSizes = new int[in.numDimensions()]; diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/image/distancetransform/DistanceTransform2D.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/image/distancetransform/DistanceTransform2D.java index 156914f95..db25afd85 100644 --- a/scijava-ops-image/src/main/java/org/scijava/ops/image/image/distancetransform/DistanceTransform2D.java +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/image/distancetransform/DistanceTransform2D.java @@ -36,6 +36,7 @@ import net.imglib2.RandomAccessibleInterval; import net.imglib2.type.BooleanType; import net.imglib2.type.numeric.RealType; +import net.imglib2.view.Views; import org.scijava.concurrent.Parallelization; /** @@ -55,8 +56,12 @@ private DistanceTransform2D() { * http://fab.cba.mit.edu/classes/S62.12/docs/Meijster_distance.pdf */ public static , T extends RealType> void compute( - final RandomAccessibleInterval in, final RandomAccessibleInterval out) + final RandomAccessibleInterval input, final RandomAccessibleInterval output) { + // Note: The scans below index pixels from zero, so both images are + // aligned at the origin. This keeps inputs with a non-zero min working. + final RandomAccessibleInterval in = Views.zeroMin(input); + final RandomAccessibleInterval out = Views.zeroMin(output); // tempValues stores the integer values of the first phase, i.e. the // first two scans diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/image/distancetransform/DistanceTransform2DCalibration.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/image/distancetransform/DistanceTransform2DCalibration.java index 90a72075b..65a1ea653 100644 --- a/scijava-ops-image/src/main/java/org/scijava/ops/image/image/distancetransform/DistanceTransform2DCalibration.java +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/image/distancetransform/DistanceTransform2DCalibration.java @@ -38,6 +38,7 @@ import net.imglib2.RandomAccessibleInterval; import net.imglib2.type.BooleanType; import net.imglib2.type.numeric.RealType; +import net.imglib2.view.Views; /** * Computes a distance transform, i.e. for every foreground pixel its distance @@ -56,9 +57,13 @@ private DistanceTransform2DCalibration() { * http://fab.cba.mit.edu/classes/S62.12/docs/Meijster_distance.pdf */ public static , T extends RealType> void compute( - final RandomAccessibleInterval in, final double[] calibration, - final RandomAccessibleInterval out) + final RandomAccessibleInterval input, final double[] calibration, + final RandomAccessibleInterval output) { + // Note: The scans below index pixels from zero, so both images are + // aligned at the origin. This keeps inputs with a non-zero min working. + final RandomAccessibleInterval in = Views.zeroMin(input); + final RandomAccessibleInterval out = Views.zeroMin(output); // tempValues stores the integer values of the first phase, i.e. the // first two scans diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/image/distancetransform/DistanceTransform3D.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/image/distancetransform/DistanceTransform3D.java index ab1fb9805..74e3fa8d1 100644 --- a/scijava-ops-image/src/main/java/org/scijava/ops/image/image/distancetransform/DistanceTransform3D.java +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/image/distancetransform/DistanceTransform3D.java @@ -38,6 +38,7 @@ import net.imglib2.RandomAccessibleInterval; import net.imglib2.type.BooleanType; import net.imglib2.type.numeric.RealType; +import net.imglib2.view.Views; /** * Computes a distance transform, i.e. for every foreground pixel its distance @@ -56,8 +57,12 @@ private DistanceTransform3D() { * http://fab.cba.mit.edu/classes/S62.12/docs/Meijster_distance.pdf */ public static , T extends RealType> void compute( - final RandomAccessibleInterval in, final RandomAccessibleInterval out) + final RandomAccessibleInterval input, final RandomAccessibleInterval output) { + // Note: The scans below index pixels from zero, so both images are + // aligned at the origin. This keeps inputs with a non-zero min working. + final RandomAccessibleInterval in = Views.zeroMin(input); + final RandomAccessibleInterval out = Views.zeroMin(output); // tempValues stores the integer values of the first phase, i.e. the // first two scans diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/image/distancetransform/DistanceTransform3DCalibration.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/image/distancetransform/DistanceTransform3DCalibration.java index 42ca7c193..4a7f0d397 100644 --- a/scijava-ops-image/src/main/java/org/scijava/ops/image/image/distancetransform/DistanceTransform3DCalibration.java +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/image/distancetransform/DistanceTransform3DCalibration.java @@ -36,6 +36,7 @@ import net.imglib2.RandomAccessibleInterval; import net.imglib2.type.BooleanType; import net.imglib2.type.numeric.RealType; +import net.imglib2.view.Views; import org.scijava.concurrent.Parallelization; /** @@ -55,9 +56,13 @@ private DistanceTransform3DCalibration() { * http://fab.cba.mit.edu/classes/S62.12/docs/Meijster_distance.pdf */ public static , T extends RealType> void compute( - final RandomAccessibleInterval in, final double[] calibration, - final RandomAccessibleInterval out) + final RandomAccessibleInterval input, final double[] calibration, + final RandomAccessibleInterval output) { + // Note: The scans below index pixels from zero, so both images are + // aligned at the origin. This keeps inputs with a non-zero min working. + final RandomAccessibleInterval in = Views.zeroMin(input); + final RandomAccessibleInterval out = Views.zeroMin(output); // tempValues stores the integer values of the first phase, i.e. the // first two scans diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/image/extrema/DefaultLocalExtrema.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/image/extrema/DefaultLocalExtrema.java new file mode 100644 index 000000000..bf133d6b5 --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/image/extrema/DefaultLocalExtrema.java @@ -0,0 +1,91 @@ +/*- + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.image.extrema; + +import java.util.List; + +import net.imglib2.Point; +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.algorithm.localextrema.LocalExtrema; +import net.imglib2.algorithm.neighborhood.Shape; +import net.imglib2.type.numeric.RealType; + +import org.scijava.function.Functions; +import org.scijava.ops.spi.Nullable; + +/** + * Finds the local maxima (or minima) of an image, wrapping ImgLib2's + * {@link LocalExtrema}. + *

+ * A pixel is a local maximum if no pixel in its neighborhood is brighter, and + * its value is at least {@code threshold} (for minima: no neighbor is darker, + * and its value is at most {@code threshold}). Extrema are non-strict, so + * every pixel of a flat plateau that is not adjacent to a brighter pixel is + * reported. Pixels on the image border are candidates too: the image is + * extended by border replication. Note that this op finds local + * extrema; for regional extrema (whole plateaus), use a dedicated regional + * extrema op. + *

+ *

+ * The extremum checks are those of ImgLib2's + * {@link LocalExtrema.MaximumCheck} and {@link LocalExtrema.MinimumCheck} + * (BSD-2-Clause, by Tobias Pietzsch). + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @param image type + * @implNote op names='image.localExtrema' + */ +public class DefaultLocalExtrema> implements + Functions.Arity4, Shape, Double, Boolean, List> +{ + + /** + * Finds local extrema. + * + * @param input the image in which to search for extrema + * @param shape the neighborhood of each pixel; defaults to a 3^n hypercube + * (a {@code RectangleShape} of span 1) + * @param threshold minimum value of a maximum (maximum value of a minimum); + * by default, there is no threshold + * @param maxima {@code true} (default) to find maxima, {@code false} to find + * minima + * @return the positions of the local extrema, sorted in flat iteration + * order (first dimension fastest) + */ + @Override + public List apply(final RandomAccessibleInterval input, + @Nullable final Shape shape, @Nullable final Double threshold, + @Nullable final Boolean maxima) + { + return LocalExtremaFinder.find(input, shape, threshold, maxima); + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/image/extrema/LocalExtremaFinder.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/image/extrema/LocalExtremaFinder.java new file mode 100644 index 000000000..9582c1540 --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/image/extrema/LocalExtremaFinder.java @@ -0,0 +1,121 @@ +/*- + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.image.extrema; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.concurrent.ExecutionException; + +import net.imglib2.Point; +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.algorithm.localextrema.LocalExtrema; +import net.imglib2.algorithm.localextrema.LocalExtrema.LocalNeighborhoodCheck; +import net.imglib2.algorithm.neighborhood.RectangleShape; +import net.imglib2.algorithm.neighborhood.Shape; +import net.imglib2.type.numeric.IntegerType; +import net.imglib2.type.numeric.RealType; +import net.imglib2.view.Views; + +import org.scijava.concurrent.Parallelization; + +/** + * Utility methods backing the {@code image.localExtrema} ops. + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +final class LocalExtremaFinder { + + private LocalExtremaFinder() { + // NB: Prevent instantiation of utility class. + } + + /** + * Finds the local extrema of {@code input}, sorted in flat iteration order + * (first dimension varying fastest). + */ + static > List find( + final RandomAccessibleInterval input, Shape shape, + final Double threshold, Boolean maxima) + { + if (shape == null) shape = new RectangleShape(1, true); + if (maxima == null) maxima = true; + + // Build the threshold value, clamped to the range of the pixel type. + final var t = input.getType().createVariable(); + double bound; + if (threshold == null) bound = maxima ? t.getMinValue() : t.getMaxValue(); + else bound = Math.max(t.getMinValue(), Math.min(t.getMaxValue(), + threshold)); + if (t instanceof IntegerType) { + // Note: Rounding inward keeps the comparison exact for integer pixels. + bound = maxima ? Math.ceil(bound) : Math.floor(bound); + } + t.setReal(bound); + + final LocalNeighborhoodCheck check = maxima ? // + new LocalExtrema.MaximumCheck<>(t) : new LocalExtrema.MinimumCheck<>(t); + + // Note: Extending by border replication lets edge pixels be extrema. + // Since the checks are non-strict, replicated values never disqualify a + // candidate unless a pixel within the image would also do so. + final var source = Views.extendBorder(input); + + final var es = Parallelization.getExecutorService(); + final var splitDim = LocalExtrema.getBiggestDimension(input); + final var numTasks = (int) Math.max(1, Math.min(input.dimension( + splitDim), 4L * Runtime.getRuntime().availableProcessors())); + final List extrema; + try { + extrema = new ArrayList<>(LocalExtrema.findLocalExtrema(source, input, + check, shape, es, numTasks, splitDim)); + } + catch (final InterruptedException exc) { + Thread.currentThread().interrupt(); + throw new RuntimeException(exc); + } + catch (final ExecutionException exc) { + throw new RuntimeException(exc); + } + + // Sort for deterministic output regardless of the parallel split. + extrema.sort(FLAT_ORDER); + return extrema; + } + + private static final Comparator FLAT_ORDER = (p1, p2) -> { + for (var d = p1.numDimensions() - 1; d >= 0; d--) { + final var c = Long.compare(p1.getLongPosition(d), p2.getLongPosition(d)); + if (c != 0) return c; + } + return 0; + }; +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/image/extrema/LocalExtremaMask.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/image/extrema/LocalExtremaMask.java new file mode 100644 index 000000000..4bb8a9f59 --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/image/extrema/LocalExtremaMask.java @@ -0,0 +1,92 @@ +/*- + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.image.extrema; + +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.algorithm.neighborhood.Shape; +import net.imglib2.type.BooleanType; +import net.imglib2.type.numeric.RealType; +import net.imglib2.util.Intervals; +import net.imglib2.view.Views; + +import org.scijava.function.Computers; +import org.scijava.ops.spi.Nullable; + +/** + * Marks the local maxima (or minima) of an image in a binary mask. See + * {@link DefaultLocalExtrema} for the definition of a local extremum. + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @param image type + * @param mask type + * @implNote op names='image.localExtrema' + */ +public class LocalExtremaMask, B extends BooleanType> + implements + Computers.Arity4, Shape, Double, Boolean, RandomAccessibleInterval> +{ + + /** + * Finds local extrema and marks them in a mask. + * + * @param input the image in which to search for extrema + * @param shape the neighborhood of each pixel; defaults to a 3^n hypercube + * (a {@code RectangleShape} of span 1) + * @param threshold minimum value of a maximum (maximum value of a minimum); + * by default, there is no threshold + * @param maxima {@code true} (default) to find maxima, {@code false} to find + * minima + * @param mask the output mask, of the same dimensions as {@code input}; set + * to {@code true} at local extrema and {@code false} elsewhere + */ + @Override + public void compute(final RandomAccessibleInterval input, + @Nullable final Shape shape, @Nullable final Double threshold, + @Nullable final Boolean maxima, final RandomAccessibleInterval mask) + { + if (!Intervals.equalDimensions(input, mask)) { + throw new IllegalArgumentException( + "Mask dimensions must match input dimensions"); + } + final var extrema = LocalExtremaFinder.find(input, shape, threshold, + maxima); + for (final var b : Views.flatIterable(mask)) + b.set(false); + final var n = input.numDimensions(); + final var ra = mask.randomAccess(); + for (final var p : extrema) { + for (var d = 0; d < n; d++) { + ra.setPosition(p.getLongPosition(d) - input.min(d) + mask.min(d), d); + } + ra.get().set(true); + } + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/image/histogram2D/DefaultHistogram2D.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/image/histogram2D/DefaultHistogram2D.java new file mode 100644 index 000000000..2fb40a4bd --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/image/histogram2D/DefaultHistogram2D.java @@ -0,0 +1,131 @@ +/*- + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.image.histogram2D; + +import java.util.function.BiFunction; +import java.util.function.Function; + +import net.imglib2.Dimensions; +import net.imglib2.FinalDimensions; +import net.imglib2.img.Img; +import net.imglib2.type.numeric.RealType; +import net.imglib2.type.numeric.integer.LongType; +import net.imglib2.util.IterablePair; +import net.imglib2.util.Pair; + +import org.scijava.function.Functions; +import org.scijava.ops.image.coloc.ColocUtil; +import org.scijava.ops.spi.Nullable; +import org.scijava.ops.spi.OpDependency; + +/** + * Computes the joint (two-dimensional) intensity histogram of two images, also + * known as the 2D scatter plot or cytofluorogram of colocalization analysis. + *

+ * Each channel is binned uniformly between its own minimum and maximum: a + * value {@code v} goes into bin {@code floor((v - min) / (max - min) * bins)}, + * with the maximum going into the last bin. The result is an image of size + * {@code bins1 x bins2} whose value at {@code (i, j)} is the number of sample + * pairs whose first value falls into bin {@code i} of the first channel and + * whose second value falls into bin {@code j} of the second channel. + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @param type of the first image + * @param type of the second image + * @implNote op names='image.histogram2D' + */ +public class DefaultHistogram2D, U extends RealType> + implements + Functions.Arity4, Iterable, Integer, Integer, Img> +{ + + public static final int DEFAULT_NUM_BINS = 256; + + @OpDependency(name = "stats.minMax") + private Function, Pair> minMax1; + + @OpDependency(name = "stats.minMax") + private Function, Pair> minMax2; + + @OpDependency(name = "create.img") + private BiFunction> createImg; + + /** + * Computes the joint histogram of two images. + * + * @param image1 the first image, binned along the first histogram axis + * @param image2 the second image, binned along the second histogram axis; + * it must have the same size and iteration order as the first + * @param bins1 the number of bins of the first channel (default 256) + * @param bins2 the number of bins of the second channel (default: same as + * bins1) + * @return the joint histogram, a {@code bins1 x bins2} image of counts + */ + @Override + public Img apply(final Iterable image1, + final Iterable image2, @Nullable Integer bins1, + @Nullable Integer bins2) + { + if (!ColocUtil.sameIterationOrder(image1, image2)) + throw new IllegalArgumentException( + "Input images must have the same dimensionality and iteration order!"); + if (bins1 == null) bins1 = DEFAULT_NUM_BINS; + if (bins2 == null) bins2 = bins1; + if (bins1 < 1 || bins2 < 1) throw new IllegalArgumentException( + "The number of bins must be positive."); + + final var mm1 = minMax1.apply(image1); + final var mm2 = minMax2.apply(image2); + final var min1 = mm1.getA().getRealDouble(); + final var max1 = mm1.getB().getRealDouble(); + final var min2 = mm2.getA().getRealDouble(); + final var max2 = mm2.getB().getRealDouble(); + + final var histogram = createImg.apply(new FinalDimensions(bins1, bins2), + new LongType()); + final var ra = histogram.randomAccess(); + for (final var pair : new IterablePair<>(image1, image2)) { + ra.setPosition(bin(pair.getA().getRealDouble(), min1, max1, bins1), 0); + ra.setPosition(bin(pair.getB().getRealDouble(), min2, max2, bins2), 1); + ra.get().inc(); + } + return histogram; + } + + private static int bin(final double v, final double min, final double max, + final int bins) + { + if (!(max > min)) return 0; + final var b = (int) Math.floor((v - min) / (max - min) * bins); + return Math.max(0, Math.min(bins - 1, b)); + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/image/integral/AbstractIntegralImg.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/image/integral/AbstractIntegralImg.java index bafe53d0a..8a0bfaba5 100644 --- a/scijava-ops-image/src/main/java/org/scijava/ops/image/image/integral/AbstractIntegralImg.java +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/image/integral/AbstractIntegralImg.java @@ -75,16 +75,39 @@ public void compute(final RandomAccessibleInterval input, Views.iterable(output).firstElement().createVariable()); // Create integral image - for (var i = 0; i < input.numDimensions(); ++i) { - // Slicewise integral addition in one direction + final var n = input.numDimensions(); + for (var i = 0; i < n; ++i) { + // Cumulative addition along each line in dimension i. // TODO can we find a way to parallelize this? - for (var j = 0; j < input.dimension(i); j++) - getComputer(i).compute(Views.hyperSlice(generalizedInput, i, j), Views - .hyperSlice(output, i, j)); + if (n == 1) { + getComputer(i).compute(generalizedInput, output); + } + else { + final var pos = new long[n]; + final var lineStarts = Views.flatIterable(Views.hyperSlice(output, i, + output.min(i))).localizingCursor(); + while (lineStarts.hasNext()) { + lineStarts.fwd(); + for (var d = 0; d < n - 1; d++) + pos[d < i ? d : d + 1] = lineStarts.getLongPosition(d); + getComputer(i).compute(line(generalizedInput, i, pos), line(output, + i, pos)); + } + } generalizedInput = output; } } + /** Gets the 1-D line along dimension {@code dim} through {@code pos}. */ + private static RandomAccessibleInterval line( + RandomAccessibleInterval rai, final int dim, final long[] pos) + { + for (var d = rai.numDimensions() - 1; d >= 0; d--) { + if (d != dim) rai = Views.hyperSlice(rai, d, pos[d]); + } + return rai; + } + /** * Implements the row-wise addition required for computations of integral * images. diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/image/kymograph/DefaultKymograph.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/image/kymograph/DefaultKymograph.java new file mode 100644 index 000000000..e70c17064 --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/image/kymograph/DefaultKymograph.java @@ -0,0 +1,322 @@ +/*- + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.image.kymograph; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.function.BiFunction; + +import net.imglib2.Dimensions; +import net.imglib2.FinalDimensions; +import net.imglib2.RandomAccessible; +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.RealRandomAccess; +import net.imglib2.converter.Converters; +import net.imglib2.interpolation.InterpolatorFactory; +import net.imglib2.interpolation.randomaccess.NLinearInterpolatorFactory; +import net.imglib2.interpolation.randomaccess.NearestNeighborInterpolatorFactory; +import net.imglib2.roi.geom.real.Polyline; +import net.imglib2.type.NativeType; +import net.imglib2.type.numeric.IntegerType; +import net.imglib2.type.numeric.RealType; +import net.imglib2.type.numeric.real.DoubleType; +import net.imglib2.view.Views; + +import org.scijava.function.Functions; +import org.scijava.ops.spi.Nullable; +import org.scijava.ops.spi.OpDependency; + +/** + * Builds a kymograph: samples a time series along a (poly)line at every time + * point, yielding a space-time image. + *

+ * The polyline lives in the plane of dimensions 0 and 1 (x and y) of the + * input. It is sampled at unit arc-length steps from its first vertex, giving + * {@code floor(length) + 1} samples. With a line width {@code w > 1}, + * {@code w} parallel lines are sampled, offset along the normal of each + * segment by {@code i - (w - 1) / 2} pixels for {@code i = 0..w-1}, and + * combined with the chosen reducer: {@code "max"} (default, as in + * KymographBuilder), {@code "mean"}, or {@code "none"} to keep each parallel + * line separately. + *

+ *

+ * The output has dimensions {@code [position, time, (line if reduce is + * "none"), other...]}, where {@code other} are the remaining input dimensions + * (e.g. channel or z) in their original order. Samples outside the image are + * zero. + *

+ *

+ * Adapted from the + * KymographBuilder Fiji + * plugin by Hadrien Mary, MIT License, Copyright (C) 2016 - 2026 Fiji + * developers. Differences from the original: samples are taken at uniform + * arc-length steps along the whole polyline in real coordinates (rather than + * from integer-rounded segment endpoints via {@code ij.gui.Line}); pixels in + * row 0 and column 0 are sampled (the original skipped them due to an + * off-by-one bounds check); interpolation defaults to n-linear (use + * {@code "nearest"} for the original's rounding); and all non-spatial, + * non-time dimensions are kept rather than a single z plane. + *

+ *

+ * The original KymographBuilder license notice follows. + *

+ * + *
+ * The MIT License (MIT)
+ *
+ * Copyright (c) 2016 - 2026, Fiji developers.
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ * 
+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @author Hadrien Mary + * @param image type + * @implNote op names='image.kymograph' + */ +public class DefaultKymograph & NativeType> + implements + Functions.Arity6, Polyline, Integer, Integer, String, String, RandomAccessibleInterval> +{ + + @OpDependency(name = "create.img") + private BiFunction> createOp; + + /** + * Builds the kymograph. + * + * @param input the time series, whose dimensions 0 and 1 are the spatial + * plane of the polyline + * @param polyline the path along which to sample, in the coordinates of + * input dimensions 0 and 1; it must have at least two vertices + * @param timeDim the index of the time dimension; defaults to the last + * dimension. It must be at least 2. + * @param lineWidth the number of parallel lines to sample; defaults to 1 + * @param reduce how to combine the parallel lines: {@code "max"} + * (default), {@code "mean"} or {@code "none"} + * @param interpolation how to sample between pixels: {@code "linear"} + * (default) or {@code "nearest"} + * @return the kymograph, with dimensions {@code [position, time, (line), + * other...]} + */ + @Override + public RandomAccessibleInterval apply( + final RandomAccessibleInterval input, final Polyline polyline, + @Nullable Integer timeDim, @Nullable Integer lineWidth, + @Nullable String reduce, @Nullable String interpolation) + { + final var n = input.numDimensions(); + if (n < 3) throw new IllegalArgumentException( + "Input must have at least 3 dimensions (x, y, time)"); + if (timeDim == null) timeDim = n - 1; + if (timeDim < 2 || timeDim >= n) throw new IllegalArgumentException( + "Invalid time dimension: " + timeDim); + if (lineWidth == null) lineWidth = 1; + if (lineWidth < 1) throw new IllegalArgumentException( + "Line width must be positive"); + if (reduce == null) reduce = "max"; + reduce = reduce.trim().toLowerCase(Locale.ROOT); + if (!reduce.equals("max") && !reduce.equals("mean") && !reduce.equals( + "none")) + { + throw new IllegalArgumentException("Unknown reduce method: '" + reduce + + "'. Expected one of: max, mean, none."); + } + if (interpolation == null) interpolation = "linear"; + final InterpolatorFactory> interp; + switch (interpolation.trim().toLowerCase(Locale.ROOT)) { + case "linear": + interp = new NLinearInterpolatorFactory<>(); + break; + case "nearest": + interp = new NearestNeighborInterpolatorFactory<>(); + break; + default: + throw new IllegalArgumentException("Unknown interpolation: '" + + interpolation + "'. Expected one of: linear, nearest."); + } + + // Compute the sample coordinates of each parallel line. + final var samples = samplePolyline(polyline, lineWidth); + final var xs = samples[0]; + final var ys = samples[1]; + final var length = xs.length; + + // Determine the output dimensions. + final List otherDims = new ArrayList<>(); + for (var d = 2; d < n; d++) + if (d != timeDim) otherDims.add(d); + final var keepLines = reduce.equals("none"); + final var outN = 2 + (keepLines ? 1 : 0) + otherDims.size(); + final var outDims = new long[outN]; + outDims[0] = length; + outDims[1] = input.dimension(timeDim); + if (keepLines) outDims[2] = lineWidth; + final var otherOffset = keepLines ? 3 : 2; + for (var k = 0; k < otherDims.size(); k++) + outDims[otherOffset + k] = input.dimension(otherDims.get(k)); + + final var output = createOp.apply(new FinalDimensions(outDims), input + .getType().createVariable()); + final var outType = output.getType(); + final var round = outType instanceof IntegerType; + final var outMin = outType.getMinValue(); + final var outMax = outType.getMaxValue(); + + // Note: Sampling happens in double precision; outside samples are zero. + final RandomAccessible asDouble = Converters.convert( + (RandomAccessible) Views.extendZero(input), (a, b) -> b.set(a + .getRealDouble()), new DoubleType()); + final var rra = Views.interpolate(asDouble, interp).realRandomAccess(); + + final var useMean = reduce.equals("mean"); + final var cursor = Views.flatIterable(output).localizingCursor(); + final var pos = new long[outN]; + while (cursor.hasNext()) { + cursor.fwd(); + cursor.localize(pos); + final var j = (int) pos[0]; + rra.setPosition(input.min(timeDim) + pos[1], timeDim); + for (var k = 0; k < otherDims.size(); k++) { + final int d = otherDims.get(k); + rra.setPosition(input.min(d) + pos[otherOffset + k], d); + } + final double value; + if (keepLines) { + final var i = (int) pos[2]; + value = sample(rra, xs[j][i], ys[j][i]); + } + else { + var acc = useMean ? 0 : Double.NEGATIVE_INFINITY; + for (var i = 0; i < lineWidth; i++) { + final var v = sample(rra, xs[j][i], ys[j][i]); + acc = useMean ? acc + v : Math.max(acc, v); + } + value = useMean ? acc / lineWidth : acc; + } + final var v = round ? Math.floor(value + 0.5) : value; + cursor.get().setReal(Math.max(outMin, Math.min(outMax, v))); + } + return output; + } + + private static double sample( + final RealRandomAccess rra, final double x, + final double y) + { + rra.setPosition(x, 0); + rra.setPosition(y, 1); + return rra.get().get(); + } + + /** + * Computes sample coordinates along the polyline at unit arc-length steps, + * for each of the {@code lineWidth} parallel lines. + * + * @return {@code {xs, ys}}, each indexed as {@code [sample][line]} + */ + static double[][][] samplePolyline(final Polyline polyline, + final int lineWidth) + { + if (polyline == null) throw new NullPointerException( + "Polyline must be given"); + if (polyline.numDimensions() < 2) throw new IllegalArgumentException( + "Polyline must be at least 2-dimensional"); + + // Collect the vertices, skipping repeated points. + final List vertices = new ArrayList<>(); + for (var v = 0; v < polyline.numVertices(); v++) { + final var p = polyline.vertex(v); + final double[] xy = { p.getDoublePosition(0), p.getDoublePosition(1) }; + if (!vertices.isEmpty()) { + final var last = vertices.get(vertices.size() - 1); + if (last[0] == xy[0] && last[1] == xy[1]) continue; + } + vertices.add(xy); + } + if (vertices.size() < 2) throw new IllegalArgumentException( + "Polyline must have at least two distinct vertices"); + + // Compute the cumulative arc length at each vertex. + final var segments = vertices.size() - 1; + final var cumulative = new double[segments + 1]; + for (var s = 0; s < segments; s++) { + final var a = vertices.get(s); + final var b = vertices.get(s + 1); + cumulative[s + 1] = cumulative[s] + Math.hypot(b[0] - a[0], b[1] - + a[1]); + } + final var total = cumulative[segments]; + // Note: The epsilon keeps a length of exactly k from losing a sample. + final var count = (int) Math.floor(total + 1e-9) + 1; + + final var xs = new double[count][lineWidth]; + final var ys = new double[count][lineWidth]; + var s = 0; + for (var j = 0; j < count; j++) { + final double arc = j; + while (s < segments - 1 && arc >= cumulative[s + 1]) + s++; + final var a = vertices.get(s); + final var b = vertices.get(s + 1); + final var segLength = cumulative[s + 1] - cumulative[s]; + final var ux = (b[0] - a[0]) / segLength; + final var uy = (b[1] - a[1]) / segLength; + final var t = arc - cumulative[s]; + final var x = a[0] + t * ux; + final var y = a[1] + t * uy; + for (var i = 0; i < lineWidth; i++) { + // Note: The normal (-uy, ux) matches KymographBuilder's offsets. + final var offset = i - (lineWidth - 1) / 2.0; + xs[j][i] = x - offset * uy; + ys[j][i] = y + offset * ux; + } + } + return new double[][][] { xs, ys }; + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/image/piv/DefaultPIV.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/image/piv/DefaultPIV.java new file mode 100644 index 000000000..f7695cde6 --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/image/piv/DefaultPIV.java @@ -0,0 +1,673 @@ +/*- + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.image.piv; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.function.BiFunction; + +import net.imglib2.Dimensions; +import net.imglib2.FinalDimensions; +import net.imglib2.FinalInterval; +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.algorithm.fft2.FFTMethods; +import net.imglib2.img.Img; +import net.imglib2.loops.LoopBuilder; +import net.imglib2.type.numeric.RealType; +import net.imglib2.type.numeric.complex.ComplexFloatType; +import net.imglib2.type.numeric.real.FloatType; +import net.imglib2.view.Views; + +import org.scijava.function.Computers; +import org.scijava.function.Functions; +import org.scijava.ops.spi.Nullable; +import org.scijava.ops.spi.OpDependency; + +/** + * Particle image velocimetry (PIV): estimates the displacement field between + * two n-dimensional frames on a regular grid of interrogation windows. + *

+ * For each interrogation window of the first frame, the zero-normalized + * cross-correlation with the second frame is computed for every integer + * displacement within the search range. Where the displaced window leaves the + * second frame, the correlation is restricted to the overlap (masked + * normalized cross-correlation, Padfield (2012), "Masked object registration + * in the Fourier domain", IEEE Trans. Image Process. 21(5):2706-2718), and + * displacements under which less than {@value #MIN_OVERLAP} of the window + * overlaps the second frame are not considered. The correlations are computed + * with the FFT, and the local sums of the second frame with separable box + * sums. + * The displacement of the window is the correlation peak, refined to subpixel + * accuracy in each dimension by the three-point Gaussian peak fit (Willert + * & Gharib (1991), "Digital particle image velocimetry", Experiments in + * Fluids 10:181-193), or by a parabolic fit where the correlations around the + * peak are not all positive. See Raffel, Willert, Scarano, Kähler, + * Wereley & Kompenhans (2018), Particle Image Velocimetry: A Practical + * Guide, 3rd ed., Springer, for an overview of the method. + *

+ *

+ * Optionally, the vectors are validated by the normalized median test of + * Westerweel & Scarano (2005), "Universal outlier detection for PIV data", + * Experiments in Fluids 39:1096-1100: for each vector component, the residual + * of the vector from the median of its (up to {@code 3^n - 1}) neighbors is + * divided by the median residual of the neighbors from that median plus + * {@value #MEDIAN_TEST_EPSILON} pixels. A vector whose normalized residual + * exceeds the threshold in any component is an outlier, and is replaced, + * like any vector that could not be measured, by the component-wise median of + * its valid neighbors. + *

+ *

+ * The windows are placed from the minimum of the first frame with the given + * step, as many as fit inside the frame. Windows without contrast in the + * first frame give {@code NaN} vectors (before replacement). + *

+ *

+ * Implemented from the papers above. Like Fiji's PIV_analyser, this is a + * single-pass analysis without window deformation or iterative refinement; + * unlike it, windows of any size are allowed (not only powers of two). Each + * frame is limited to 231 pixels. + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @param type of the first frame + * @param type of the second frame + * @implNote op names='image.piv' + */ +public class DefaultPIV, U extends RealType> + implements + Functions.Arity7, RandomAccessibleInterval, int[], int[], int[], Boolean, Double, PIVResult> +{ + + /** The noise level, in pixels, of the normalized median test. */ + public static final double MEDIAN_TEST_EPSILON = 0.1; + + /** + * The smallest fraction of a window which must overlap the second frame + * under a candidate displacement. + */ + public static final double MIN_OVERLAP = 0.5; + + @OpDependency(name = "create.img") + private BiFunction> createImg; + + @OpDependency(name = "filter.createFFTOutput") + private Functions.Arity3> createFFTOutput; + + @OpDependency(name = "filter.fft") + private Computers.Arity1, RandomAccessibleInterval> fftOp; + + @OpDependency(name = "filter.ifft") + private Computers.Arity1, RandomAccessibleInterval> ifftOp; + + /** + * Estimates the displacement field between two frames. + * + * @param frame1 the first frame + * @param frame2 the second frame, of the same size as the first + * @param windowSize the size of the interrogation windows, either one value + * for all dimensions or one per dimension (default 32, or the frame + * size if smaller) + * @param step the distance between neighboring windows, either one value + * for all dimensions or one per dimension (default: the window + * size, i.e. no overlap; half of it gives the common 50 percent + * overlap) + * @param searchRange the largest displacement searched for, either one + * value for all dimensions or one per dimension (default: half the + * window size) + * @param subpixel whether to refine the displacements to subpixel accuracy + * (default true) + * @param medianThreshold the threshold of the normalized median test for + * outliers, typically 2; by default, the vectors are not validated + * @return the displacement field + */ + @Override + public PIVResult apply(final RandomAccessibleInterval frame1, + final RandomAccessibleInterval frame2, @Nullable int[] windowSize, + @Nullable int[] step, @Nullable int[] searchRange, + @Nullable Boolean subpixel, @Nullable Double medianThreshold) + { + final var n = frame1.numDimensions(); + if (frame2.numDimensions() != n) throw new IllegalArgumentException( + "The frames must have the same number of dimensions."); + final var dims = new int[n]; + for (var d = 0; d < n; d++) { + if (frame1.dimension(d) != frame2.dimension(d)) + throw new IllegalArgumentException("The frames must have the same size."); + if (frame1.dimension(d) > Integer.MAX_VALUE) + throw new IllegalArgumentException("The frames are too large."); + dims[d] = (int) frame1.dimension(d); + } + if (frame1.size() > Integer.MAX_VALUE) throw new IllegalArgumentException( + "The frames are too large."); + + var w = perDimension(windowSize, n, "window size"); + if (w == null) { + w = new int[n]; + for (var d = 0; d < n; d++) + w[d] = Math.min(32, dims[d]); + } + var s = perDimension(step, n, "step"); + if (s == null) s = w.clone(); + var r = perDimension(searchRange, n, "search range"); + if (r == null) { + r = new int[n]; + for (var d = 0; d < n; d++) + r[d] = w[d] / 2; + } + if (subpixel == null) subpixel = true; + for (var d = 0; d < n; d++) { + if (w[d] < 1 || w[d] > dims[d]) throw new IllegalArgumentException( + "The window size must be between 1 and the frame size."); + if (s[d] < 1) throw new IllegalArgumentException( + "The step must be positive."); + if (r[d] < 0) throw new IllegalArgumentException( + "The search range must not be negative."); + } + + final var data1 = flatten(frame1); + final var data2 = flatten(frame2); + final var gridSize = new long[n]; + var count = 1; + for (var d = 0; d < n; d++) { + gridSize[d] = (dims[d] - w[d]) / s[d] + 1; + count *= gridSize[d]; + } + + final var correlator = new Correlator(dims, w, r, subpixel); + final var displacements = new double[count * n]; + final var correlations = new double[count]; + final var measured = new boolean[count]; + final var gridPoint = new int[n]; + final var origin = new int[n]; + final var vector = new double[n]; + for (var i = 0; i < count; i++) { + for (var d = 0; d < n; d++) + origin[d] = gridPoint[d] * s[d]; + correlations[i] = correlator.correlate(data1, data2, origin, vector); + measured[i] = !Double.isNaN(correlations[i]); + System.arraycopy(vector, 0, displacements, i * n, n); + increment(gridPoint, gridSize); + } + + final var valid = medianThreshold == null ? measured : validate( + displacements, measured, gridSize, medianThreshold); + + final var min = frame1.minAsLongArray(); + return new PIVResult(gridSize, min, w, s, displacements, correlations, + valid); + } + + /** + * Expands a parameter given either once or once per dimension, or returns + * {@code null} if it is {@code null}. + */ + private static int[] perDimension(final int[] values, final int n, + final String name) + { + if (values == null) return null; + if (values.length == n) return values.clone(); + if (values.length == 1) { + final var expanded = new int[n]; + Arrays.fill(expanded, values[0]); + return expanded; + } + throw new IllegalArgumentException("The " + name + + " must have one value or one per dimension."); + } + + /** Copies an image into an array, with dimension 0 varying fastest. */ + private static > double[] flatten( + final RandomAccessibleInterval image) + { + final var data = new double[(int) image.size()]; + var i = 0; + for (final var v : Views.flatIterable(image)) + data[i++] = v.getRealDouble(); + return data; + } + + private static void increment(final int[] point, final long[] size) { + for (var d = 0; d < point.length; d++) { + if (++point[d] < size[d]) return; + point[d] = 0; + } + } + + private static int product(final int[] values) { + var product = 1; + for (final var v : values) + product *= v; + return product; + } + + /** + * Sums each box of the given size within an array of the given dimensions, + * giving an array of size {@code dims - box + 1}. + */ + private static double[] boxSums(double[] data, final int[] dims, + final int[] box) + { + final var n = dims.length; + final var current = dims.clone(); + for (var d = 0; d < n; d++) { + final var next = current.clone(); + next[d] = current[d] - box[d] + 1; + var stride = 1; + for (var e = 0; e < d; e++) + stride *= current[e]; + final var sums = new double[product(next)]; + final var outer = sums.length / (stride * next[d]); + for (var o = 0; o < outer; o++) { + for (var inner = 0; inner < stride; inner++) { + final var src = o * stride * current[d] + inner; + final var dst = o * stride * next[d] + inner; + var sum = 0.0; + for (var k = 0; k < box[d]; k++) + sum += data[src + k * stride]; + sums[dst] = sum; + for (var k = 1; k < next[d]; k++) { + sum += data[src + (k + box[d] - 1) * stride] - data[src + (k - 1) * + stride]; + sums[dst + k * stride] = sum; + } + } + } + data = sums; + current[d] = next[d]; + } + return data; + } + + /** + * Applies the normalized median test, and replaces the outliers and the + * unmeasured vectors by the median of their valid neighbors. + * + * @return whether each vector is valid + */ + private static boolean[] validate(final double[] displacements, + final boolean[] measured, final long[] gridSize, final double threshold) + { + final var n = gridSize.length; + final var count = measured.length; + final var valid = measured.clone(); + final var gridPoint = new int[n]; + final var values = new double[(int) Math.pow(3, n)]; + final var residuals = new double[values.length]; + for (var i = 0; i < count; i++) { + if (measured[i]) { + final var neighbors = neighbors(gridPoint, gridSize, measured); + if (!neighbors.isEmpty()) { + for (var d = 0; d < n && valid[i]; d++) { + final var m = neighbors.size(); + for (var j = 0; j < m; j++) + values[j] = displacements[neighbors.get(j) * n + d]; + final var median = median(values, m); + for (var j = 0; j < m; j++) + residuals[j] = Math.abs(values[j] - median); + final var medianResidual = median(residuals, m); + final var residual = Math.abs(displacements[i * n + d] - median) / + (medianResidual + MEDIAN_TEST_EPSILON); + if (residual > threshold) valid[i] = false; + } + } + } + increment(gridPoint, gridSize); + } + + final var replaced = displacements.clone(); + Arrays.fill(gridPoint, 0); + for (var i = 0; i < count; i++) { + if (!valid[i]) { + final var neighbors = neighbors(gridPoint, gridSize, valid); + for (var d = 0; d < n; d++) { + final var m = neighbors.size(); + for (var j = 0; j < m; j++) + values[j] = displacements[neighbors.get(j) * n + d]; + replaced[i * n + d] = m == 0 ? Double.NaN : median(values, m); + } + } + increment(gridPoint, gridSize); + } + System.arraycopy(replaced, 0, displacements, 0, displacements.length); + return valid; + } + + /** Gets the indices of the neighbors of a grid point that pass a filter. */ + private static List neighbors(final int[] gridPoint, + final long[] gridSize, final boolean[] filter) + { + final var n = gridPoint.length; + final List neighbors = new ArrayList<>(); + final var offset = new int[n]; + Arrays.fill(offset, -1); + final var total = (int) Math.pow(3, n); + for (var k = 0; k < total; k++) { + var index = 0; + var inside = true; + var center = true; + for (var d = n - 1; d >= 0; d--) { + final var p = gridPoint[d] + offset[d]; + if (p < 0 || p >= gridSize[d]) inside = false; + if (offset[d] != 0) center = false; + index = index * (int) gridSize[d] + p; + } + if (inside && !center && filter[index]) neighbors.add(index); + for (var d = 0; d < n; d++) { + if (++offset[d] <= 1) break; + offset[d] = -1; + } + } + return neighbors; + } + + private static double median(final double[] values, final int length) { + final var sorted = Arrays.copyOf(values, length); + Arrays.sort(sorted); + return length % 2 == 1 ? sorted[length / 2] : 0.5 * (sorted[length / 2 - + 1] + sorted[length / 2]); + } + + /** + * Correlates interrogation windows, reusing its buffers from one window to + * the next. + */ + private class Correlator { + + private final int n; + private final int[] dims; + private final int[] window; + private final int[] range; + private final boolean subpixel; + + /** The size of the search region: the window plus the search range. */ + private final int[] region; + + /** The number of candidate displacements in each dimension. */ + private final int[] shifts; + + private final int windowPixels; + private final Img paddedWindow; + private final Img paddedRegion; + private final Img crossCorrelation; + private final RandomAccessibleInterval windowFFT; + private final RandomAccessibleInterval regionFFT; + private final RandomAccessibleInterval maskFFT; + private final RandomAccessibleInterval productFFT; + private final FinalInterval windowInterval; + private final FinalInterval regionInterval; + private final FinalInterval shiftInterval; + + private Correlator(final int[] dims, final int[] window, + final int[] range, final boolean subpixel) + { + n = dims.length; + this.dims = dims; + this.window = window; + this.range = range; + this.subpixel = subpixel; + region = new int[n]; + shifts = new int[n]; + final var regionDims = new long[n]; + final var windowMax = new long[n]; + final var shiftMax = new long[n]; + for (var d = 0; d < n; d++) { + region[d] = window[d] + 2 * range[d]; + shifts[d] = 2 * range[d] + 1; + regionDims[d] = region[d]; + windowMax[d] = window[d] - 1; + shiftMax[d] = shifts[d] - 1; + } + windowPixels = product(window); + windowInterval = new FinalInterval(new long[n], windowMax); + regionInterval = new FinalInterval(regionDims); + shiftInterval = new FinalInterval(new long[n], shiftMax); + + final var padded = new long[n]; + FFTMethods.dimensionsRealToComplexFast(new FinalDimensions(regionDims), + padded, new long[n]); + final var paddedDims = new FinalDimensions(padded); + paddedWindow = createImg.apply(paddedDims, new FloatType()); + paddedRegion = createImg.apply(paddedDims, new FloatType()); + crossCorrelation = createImg.apply(paddedDims, new FloatType()); + windowFFT = createFFTOutput.apply(paddedDims, new ComplexFloatType(), + true); + regionFFT = createFFTOutput.apply(paddedDims, new ComplexFloatType(), + true); + maskFFT = createFFTOutput.apply(paddedDims, new ComplexFloatType(), + true); + productFFT = createFFTOutput.apply(paddedDims, new ComplexFloatType(), + true); + } + + /** + * Correlates the window with the given minimum. + * + * @param vector receives the displacement, or {@code NaN}s + * @return the correlation peak, or {@code NaN} + */ + private double correlate(final double[] data1, final double[] data2, + final int[] origin, final double[] vector) + { + Arrays.fill(vector, Double.NaN); + + // Extract the mean-subtracted window of the first frame. + final var a = extract(data1, origin, window, null); + var mean = 0.0; + for (final var v : a) + mean += v; + mean /= windowPixels; + var windowVariance = 0.0; + for (var i = 0; i < a.length; i++) { + a[i] -= mean; + windowVariance += a[i] * a[i]; + } + if (!(windowVariance > 0)) return Double.NaN; + + // Extract the search region of the second frame. + final var regionOrigin = new int[n]; + for (var d = 0; d < n; d++) + regionOrigin[d] = origin[d] - range[d]; + final var mask = new double[product(region)]; + final var b = extract(data2, regionOrigin, region, mask); + final var b2 = new double[b.length]; + for (var i = 0; i < b2.length; i++) + b2[i] = b[i] * b[i]; + final var sumB = boxSums(b, region, window); + final var sumB2 = boxSums(b2, region, window); + + // Cross-correlate them: sumAB(k) = sum_x a(x) b(x + k). + fill(paddedWindow, windowInterval, a); + fftOp.compute(paddedWindow, windowFFT); + fill(paddedRegion, regionInterval, b); + fftOp.compute(paddedRegion, regionFFT); + final var sumAB = crossCorrelate(windowFFT, regionFFT); + + // Note: where the shifted window leaves the second frame, the + // correlation is restricted to the overlap, following the masked + // normalized cross-correlation of Padfield (2012), "Masked object + // registration in the Fourier domain", IEEE Trans. Image Process. + // 21(5):2706-2718. + final double[] count, sumA, sumA2; + var inside = true; + for (final var m : mask) + if (m == 0) inside = false; + if (inside) { + count = null; + sumA = null; + sumA2 = null; + } + else { + count = boxSums(mask, region, window); + fill(paddedRegion, regionInterval, mask); + fftOp.compute(paddedRegion, maskFFT); + sumA = crossCorrelate(windowFFT, maskFFT); + for (var i = 0; i < a.length; i++) + a[i] *= a[i]; + fill(paddedWindow, windowInterval, a); + fftOp.compute(paddedWindow, windowFFT); + sumA2 = crossCorrelate(windowFFT, maskFFT); + } + + // Normalize the cross-correlation. + final var ncc = new double[sumAB.length]; + var best = -1; + for (var i = 0; i < ncc.length; i++) { + ncc[i] = Double.NaN; + final double numerator, varianceA, varianceB; + if (inside) { + numerator = sumAB[i]; + varianceA = windowVariance; + varianceB = sumB2[i] - sumB[i] * sumB[i] / windowPixels; + } + else { + final var m = Math.round(count[i]); + if (m < MIN_OVERLAP * windowPixels) continue; + numerator = sumAB[i] - sumA[i] * sumB[i] / m; + varianceA = sumA2[i] - sumA[i] * sumA[i] / m; + varianceB = sumB2[i] - sumB[i] * sumB[i] / m; + } + if (varianceA > 1e-9 * windowVariance && varianceB > 1e-9 * sumB2[i]) { + ncc[i] = numerator / Math.sqrt(varianceA * varianceB); + if (best < 0 || ncc[i] > ncc[best]) best = i; + } + } + if (best < 0) return Double.NaN; + + // Locate the peak. + var stride = 1; + var rest = best; + for (var d = 0; d < n; d++) { + final var k = rest % shifts[d]; + rest /= shifts[d]; + vector[d] = k - range[d]; + if (subpixel && k > 0 && k < shifts[d] - 1) { + vector[d] += peakOffset(ncc[best - stride], ncc[best], ncc[best + + stride]); + } + stride *= shifts[d]; + } + return ncc[best]; + } + + /** + * Computes {@code c(k) = sum_x a(x) b(x + k)} for each candidate + * displacement index {@code k}, from the transforms of {@code a} and + * {@code b}. + */ + private double[] crossCorrelate( + final RandomAccessibleInterval fa, + final RandomAccessibleInterval fb) + { + LoopBuilder.setImages(fa, fb, productFFT).forEachPixel((x, y, p) -> { + // y * conj(x) + final var re = y.getRealDouble() * x.getRealDouble() + y + .getImaginaryDouble() * x.getImaginaryDouble(); + final var im = y.getImaginaryDouble() * x.getRealDouble() - y + .getRealDouble() * x.getImaginaryDouble(); + p.setComplexNumber(re, im); + }); + ifftOp.compute(productFFT, crossCorrelation); + final var values = new double[product(shifts)]; + var i = 0; + for (final var c : Views.flatIterable(Views.interval(crossCorrelation, + shiftInterval))) + values[i++] = c.getRealDouble(); + return values; + } + + /** + * Copies the box with the given minimum and size out of the frame data, + * with zeros outside of the frame. + * + * @param mask if not {@code null}, receives 1 inside the frame and 0 + * outside + */ + private double[] extract(final double[] data, final int[] min, + final int[] size, final double[] mask) + { + final var values = new double[product(size)]; + final var position = new int[n]; + for (var i = 0; i < values.length; i++) { + var index = 0; + var inside = true; + for (var d = n - 1; d >= 0; d--) { + final var p = min[d] + position[d]; + if (p < 0 || p >= dims[d]) inside = false; + index = index * dims[d] + p; + } + if (inside) { + values[i] = data[index]; + if (mask != null) mask[i] = 1; + } + for (var d = 0; d < n; d++) { + if (++position[d] < size[d]) break; + position[d] = 0; + } + } + return values; + } + + /** Zeroes the image, then copies the values into the given interval. */ + private void fill(final Img image, final FinalInterval interval, + final double[] values) + { + for (final var v : image) + v.setZero(); + var i = 0; + for (final var v : Views.flatIterable(Views.interval(image, interval))) + v.setReal(values[i++]); + } + } + + /** + * Fits a peak to three equally spaced samples around a maximum, with a + * Gaussian if they are all positive and a parabola otherwise. + * + * @return the offset of the fitted peak from the middle sample, or 0 if no + * peak within half a sample fits + */ + private static double peakOffset(final double below, final double center, + final double above) + { + if (Double.isNaN(below) || Double.isNaN(above)) return 0; + double a = below, b = center, c = above; + if (below > 0 && center > 0 && above > 0) { + a = Math.log(below); + b = Math.log(center); + c = Math.log(above); + } + final var curvature = a - 2 * b + c; + if (!(curvature < 0)) return 0; + final var offset = (a - c) / (2 * curvature); + return Math.abs(offset) <= 0.5 ? offset : 0; + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/image/piv/PIVResult.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/image/piv/PIVResult.java new file mode 100644 index 000000000..88998e295 --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/image/piv/PIVResult.java @@ -0,0 +1,234 @@ +/*- + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.image.piv; + +import java.util.Arrays; + +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.img.array.ArrayImgs; +import net.imglib2.type.logic.BitType; +import net.imglib2.type.numeric.real.DoubleType; + +/** + * The result of {@code image.piv}: a vector field of displacements on a + * regular grid of interrogation windows. + *

+ * Grid point {@code g} (a position in the grid, of the same dimensionality as + * the frames) belongs to the interrogation window whose minimum is + * {@code min + g * step} in the coordinates of the first frame. The vector of + * a window is the displacement {@code u} such that the pattern at {@code x} + * in the first frame appears at {@code x + u} in the second frame. + *

+ *

+ * The result is immutable: each getter returns a copy. + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +public class PIVResult { + + private final long[] gridSize; + private final long[] min; + private final int[] windowSize; + private final int[] step; + private final double[] displacements; + private final double[] correlations; + private final boolean[] valid; + + /** + * Creates a result. + * + * @param gridSize the number of windows in each dimension + * @param min the minimum of the first window, in frame coordinates + * @param windowSize the size of the windows in each dimension + * @param step the distance between the windows in each dimension + * @param displacements the displacement vectors, flattened with the vector + * component varying fastest, then grid dimension 0, and so on + * @param correlations the correlation peak of each window, flattened with + * grid dimension 0 varying fastest + * @param valid whether each vector was measured and passed validation, + * flattened like the correlations + */ + public PIVResult(final long[] gridSize, final long[] min, + final int[] windowSize, final int[] step, final double[] displacements, + final double[] correlations, final boolean[] valid) + { + this.gridSize = gridSize.clone(); + this.min = min.clone(); + this.windowSize = windowSize.clone(); + this.step = step.clone(); + this.displacements = displacements.clone(); + this.correlations = correlations.clone(); + this.valid = valid.clone(); + } + + /** Gets the dimensionality of the frames and of the vectors. */ + public int numDimensions() { + return gridSize.length; + } + + /** Gets the number of interrogation windows in each dimension. */ + public long[] getGridSize() { + return gridSize.clone(); + } + + /** Gets the size of the interrogation windows. */ + public int[] getWindowSize() { + return windowSize.clone(); + } + + /** Gets the distance between neighboring interrogation windows. */ + public int[] getStep() { + return step.clone(); + } + + /** + * Gets the center, in the coordinates of the first frame, of the + * interrogation window at the given grid point. + */ + public double[] getPosition(final long... gridPoint) { + final var n = numDimensions(); + final var position = new double[n]; + for (var d = 0; d < n; d++) + position[d] = min[d] + gridPoint[d] * step[d] + (windowSize[d] - 1) / + 2.0; + return position; + } + + /** + * Gets the displacement vector of the window at the given grid point, whose + * components are {@code NaN} if it could not be measured. + */ + public double[] getDisplacement(final long... gridPoint) { + final var n = numDimensions(); + final var i = index(gridPoint) * n; + return Arrays.copyOfRange(displacements, i, i + n); + } + + /** + * Gets the normalized cross-correlation at the displacement peak of the + * window at the given grid point, or {@code NaN} if it could not be + * measured. + */ + public double getCorrelation(final long... gridPoint) { + return correlations[index(gridPoint)]; + } + + /** + * Gets whether the vector of the window at the given grid point was + * measured and passed validation. Otherwise, it was replaced by the median + * of its neighbors (if validation was enabled) or is {@code NaN}. + */ + public boolean isValid(final long... gridPoint) { + return valid[index(gridPoint)]; + } + + /** + * Gets the window centers as an image of the grid size plus one last + * dimension holding the coordinates. + */ + public RandomAccessibleInterval getPositions() { + final var n = numDimensions(); + final var data = new double[displacements.length]; + final var gridPoint = new long[n]; + for (var i = 0; i < correlations.length; i++) { + System.arraycopy(getPosition(gridPoint), 0, data, i * n, n); + increment(gridPoint); + } + return vectorImage(data); + } + + /** + * Gets the displacement vectors as an image of the grid size plus one last + * dimension holding the components. + */ + public RandomAccessibleInterval getDisplacements() { + return vectorImage(displacements); + } + + /** Gets the correlation peaks as an image of the grid size. */ + public RandomAccessibleInterval getCorrelations() { + return ArrayImgs.doubles(correlations.clone(), gridSize); + } + + /** Gets the validity of the vectors as an image of the grid size. */ + public RandomAccessibleInterval getValid() { + final var img = ArrayImgs.bits(gridSize); + final var cursor = img.cursor(); + for (final var v : valid) + cursor.next().set(v); + return img; + } + + /** + * Copies vector data, flattened with the component fastest, into an image + * with the component as its last dimension. + */ + private RandomAccessibleInterval vectorImage( + final double[] data) + { + final var n = numDimensions(); + final var count = correlations.length; + final var transposed = new double[data.length]; + for (var i = 0; i < count; i++) + for (var d = 0; d < n; d++) + transposed[d * count + i] = data[i * n + d]; + final var dims = Arrays.copyOf(gridSize, n + 1); + dims[n] = n; + return ArrayImgs.doubles(transposed, dims); + } + + private int index(final long[] gridPoint) { + if (gridPoint.length != numDimensions()) throw new IllegalArgumentException( + "Expected a grid point with " + numDimensions() + " coordinates."); + long index = 0; + for (var d = numDimensions() - 1; d >= 0; d--) { + if (gridPoint[d] < 0 || gridPoint[d] >= gridSize[d]) + throw new IndexOutOfBoundsException("Grid point " + Arrays.toString( + gridPoint) + " is outside the grid " + Arrays.toString(gridSize)); + index = index * gridSize[d] + gridPoint[d]; + } + return (int) index; + } + + private void increment(final long[] gridPoint) { + for (var d = 0; d < gridPoint.length; d++) { + if (++gridPoint[d] < gridSize[d]) return; + gridPoint[d] = 0; + } + } + + @Override + public String toString() { + return "PIVResult[grid = " + Arrays.toString(gridSize) + ", window = " + + Arrays.toString(windowSize) + ", step = " + Arrays.toString(step) + "]"; + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/image/quantileNormalize/QuantileNormalize.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/image/quantileNormalize/QuantileNormalize.java new file mode 100644 index 000000000..a584c4386 --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/image/quantileNormalize/QuantileNormalize.java @@ -0,0 +1,195 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.image.quantileNormalize; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.function.BiFunction; + +import net.imglib2.Dimensions; +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.type.BooleanType; +import net.imglib2.type.numeric.RealType; +import net.imglib2.type.numeric.real.DoubleType; +import net.imglib2.util.Intervals; +import net.imglib2.view.Views; + +import org.scijava.function.Functions; +import org.scijava.ops.spi.Nullable; +import org.scijava.ops.spi.OpDependency; + +/** + * Quantile normalization of a set of images (Bolstad et al. 2003), which + * gives all images the same intensity distribution. + *

+ * The pixels of each image (within its mask, if given) are sorted and split + * into {@code q} quantile classes of (nearly) equal size: the pixel of rank + * {@code r} (from 0) among {@code N} pixels is in class + * {@code floor(r · q / N)}. For each class, the mean intensity of its pixels + * in each image is computed, and the replacement value of the class is the + * average of these means over all images in which the class is not empty. + * Every pixel is then replaced by the replacement value of its class. Pixels + * outside the masks keep their values. Equal intensities are ranked in flat + * iteration order, so tied pixels at a class boundary may be split + * deterministically between two classes. + *

+ *

+ * With {@code q} equal to the (common) number of pixels, this is the classic + * quantile normalization of Bolstad et al.: the k-th smallest value of each + * image becomes the mean of the k-th smallest values. The images may have + * different sizes and dimensions. + *

+ *

+ * Implemented from the paper. For matching a single image to a reference + * distribution, see {@code image.histogramMatch}. Each image is sorted as a + * list of boxed pixel indices, which needs about 20 bytes of memory per + * pixel, so the op suits typical microscopy images rather than very large + * volumes. + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @param input pixel type + * @param mask pixel type + * @see B. M. + * Bolstad, R. A. Irizarry, M. Astrand and T. P. Speed, "A comparison of + * normalization methods for high density oligonucleotide array data + * based on variance and bias", Bioinformatics 19(2):185-193, 2003 + * @implNote op names='image.quantileNormalize' + */ +public class QuantileNormalize, B extends BooleanType> + implements + Functions.Arity3>, Integer, List>, List>> +{ + + @OpDependency(name = "create.img") + private BiFunction> createOp; + + /** + * Normalizes the images. + * + * @param images the images to normalize + * @param quantiles the number of quantile classes (default: 256) + * @param masks a mask for each image, with the same dimensions, selecting + * the pixels to normalize (default: all pixels) + * @return the normalized images, in the same order + */ + @Override + public List> apply( + final List> images, + @Nullable final Integer quantiles, + @Nullable final List> masks) + { + if (images.isEmpty()) { + throw new IllegalArgumentException("No images given"); + } + final int q = quantiles == null ? 256 : quantiles; + if (q < 1) { + throw new IllegalArgumentException("Number of quantiles must be " + + "positive: " + q); + } + if (masks != null && masks.size() != images.size()) { + throw new IllegalArgumentException("Expected " + images.size() + + " masks but got " + masks.size()); + } + final var k = images.size(); + final var values = new ArrayList(k); + final var selected = new ArrayList(k); + final var classes = new ArrayList(k); + final var sums = new double[q]; + final var counts = new int[q]; + for (var i = 0; i < k; i++) { + final var image = images.get(i); + final var size = Intervals.numElements(image); + if (size > Integer.MAX_VALUE) { + throw new IllegalArgumentException("Image is too large: " + size + + " pixels"); + } + final var v = new double[(int) size]; + final var sel = new boolean[(int) size]; + var p = 0; + for (final var t : Views.flatIterable(image)) + v[p++] = t.getRealDouble(); + if (masks == null) Arrays.fill(sel, true); + else { + final var mask = masks.get(i); + if (!Intervals.equalDimensions(image, mask)) { + throw new IllegalArgumentException( + "Each mask must have the same dimensions as its image"); + } + p = 0; + for (final var b : Views.flatIterable(mask)) + sel[p++] = b.get(); + } + + // Rank the selected pixels. + final var order = new ArrayList(); + for (p = 0; p < v.length; p++) + if (sel[p]) order.add(p); + order.sort((a, b) -> Double.compare(v[a], v[b])); + final var n = order.size(); + final var cls = new int[v.length]; + final var classSums = new double[q]; + final var classCounts = new int[q]; + for (var r = 0; r < n; r++) { + final var c = (int) ((long) r * q / n); + final int idx = order.get(r); + cls[idx] = c; + classSums[c] += v[idx]; + classCounts[c]++; + } + for (var c = 0; c < q; c++) { + if (classCounts[c] > 0) { + sums[c] += classSums[c] / classCounts[c]; + counts[c]++; + } + } + values.add(v); + selected.add(sel); + classes.add(cls); + } + + final var output = new ArrayList>(k); + for (var i = 0; i < k; i++) { + final var v = values.get(i); + final var sel = selected.get(i); + final var cls = classes.get(i); + final var out = createOp.apply(images.get(i), new DoubleType()); + var p = 0; + for (final var t : Views.flatIterable(out)) { + t.set(sel[p] ? sums[cls[p]] / counts[cls[p]] : v[p]); + p++; + } + output.add(out); + } + return output; + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/image/sdt/SignedDistanceTransform.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/image/sdt/SignedDistanceTransform.java new file mode 100644 index 000000000..337b0b254 --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/image/sdt/SignedDistanceTransform.java @@ -0,0 +1,142 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.image.sdt; + +import java.util.function.BiFunction; + +import net.imglib2.Dimensions; +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.converter.Converters; +import net.imglib2.img.array.ArrayImgs; +import net.imglib2.loops.LoopBuilder; +import net.imglib2.type.BooleanType; +import net.imglib2.type.logic.BitType; +import net.imglib2.type.numeric.real.DoubleType; +import net.imglib2.type.numeric.real.FloatType; +import net.imglib2.view.Views; + +import org.scijava.function.Computers; +import org.scijava.ops.image.util.Calibrations; +import org.scijava.ops.spi.Nullable; +import org.scijava.ops.spi.OpDependency; + +/** + * Signed Euclidean distance transform of a binary mask: positive inside the + * mask, negative outside. + *

+ * Each foreground pixel gets its distance to the nearest background pixel, + * and each background pixel minus its distance to the nearest foreground + * pixel, both computed with {@code image.distanceTransform}. Both are then + * reduced by half a pixel (in the calibrated units of the nearest neighbour + * step), so that values are {@code ±0.5} next to the boundary and the zero + * level set lies between foreground and background pixels. If the mask has no + * background (or no foreground), the missing distances are replaced by the + * length of the image diagonal. + *

+ *

+ * Signed distance maps are the basis of shape-based interpolation (Raya and + * Udupa 1990; Herman, Zheng and Bucholtz 1992); see + * {@code morphology.interpolateSlices}. + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @param mask type + * @see S. P. Raya and J. K. Udupa, + * "Shape-based interpolation of multidimensional objects", IEEE Trans. + * Med. Imaging 9(1):32-42, 1990 + * @implNote op names='image.signedDistanceTransform' + */ +public class SignedDistanceTransform> implements + BiFunction, double[], RandomAccessibleInterval> +{ + + @OpDependency(name = "image.distanceTransform") + private Computers.Arity2, double[], RandomAccessibleInterval> dtOp; + + @OpDependency(name = "create.img") + private BiFunction> createOp; + + /** + * Computes the signed distance transform. + * + * @param mask the binary mask + * @param calibration the pixel spacing along each dimension (default: 1 in + * every dimension) + * @return the signed distances, positive inside the mask + */ + @Override + public RandomAccessibleInterval apply( + final RandomAccessibleInterval mask, + @Nullable final double[] calibration) + { + final var n = mask.numDimensions(); + final var cal = Calibrations.spacing(calibration, n, "calibration"); + + var hasFg = false; + var hasBg = false; + for (final var b : Views.iterable(mask)) { + if (b.get()) hasFg = true; + else hasBg = true; + if (hasFg && hasBg) break; + } + var diagonal = 0.0; + var step = Double.POSITIVE_INFINITY; + for (var d = 0; d < n; d++) { + diagonal += Math.pow(mask.dimension(d) * cal[d], 2); + step = Math.min(step, cal[d]); + } + diagonal = Math.sqrt(diagonal); + final var half = step / 2; + + final var dims = mask.dimensionsAsLongArray(); + final var inside = ArrayImgs.doubles(dims); + final var outside = ArrayImgs.doubles(dims); + final RandomAccessibleInterval fg = Converters.convert(mask, (a, + b) -> b.set(a.get()), new BitType()); + final RandomAccessibleInterval bg = Converters.convert(mask, (a, + b) -> b.set(!a.get()), new BitType()); + if (hasFg && hasBg) { + dtOp.compute(fg, cal, inside); + dtOp.compute(bg, cal, outside); + } + + final var output = createOp.apply(mask, new FloatType()); + final var fgOnly = !hasBg; + final var bgOnly = !hasFg; + final var big = diagonal; + LoopBuilder.setImages(mask, inside, outside, output).forEachPixel((m, i, + o, s) -> { + if (m.get()) s.setReal((fgOnly ? big : i.get()) - half); + else s.setReal(half - (bgOnly ? big : o.get())); + }); + return output; + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/labeling/distance/LabelDistances.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/labeling/distance/LabelDistances.java new file mode 100644 index 000000000..e49643986 --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/labeling/distance/LabelDistances.java @@ -0,0 +1,477 @@ +/* #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.labeling.distance; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Set; + +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.algorithm.morphology.distance.DistanceTransform; +import net.imglib2.img.Img; +import net.imglib2.img.array.ArrayImgs; +import net.imglib2.roi.labeling.ImgLabeling; +import net.imglib2.type.BooleanType; +import net.imglib2.type.numeric.IntegerType; +import net.imglib2.type.numeric.real.DoubleType; +import net.imglib2.util.Intervals; +import net.imglib2.util.Util; +import net.imglib2.view.Views; + +import org.scijava.ops.image.util.Calibrations; +import org.scijava.ops.spi.Nullable; + +/** + * Distance maps and morphological operations of labelings, based on exact + * Euclidean distances. + *

+ * All ops treat each distinct label set of the labeling as one region: a + * pixel carrying the labels {A, B} belongs to a region distinct from the + * pixels carrying only A. For labelings without overlaps, the regions are + * simply the labels. Distances are measured between pixel centers, in the + * units of the given pixel spacing (default 1 along each dimension), and the + * labeling interval is not extended: pixels outside it are neither + * background nor other labels. + *

+ *

+ * These are clean-room implementations of the label distance map and label + * morphology tools of MorphoLibJ (D. Legland, I. Arganda-Carreras and P. + * Andrey, "MorphoLibJ: integrated library and plugins for mathematical + * morphology with ImageJ", Bioinformatics 32(22): 3532–3534, + * 2016), generalized to n-dimensional, calibrated labelings and using exact + * rather than chamfer distances. The distance map uses the separable + * algorithm of T. Saito and J.-I. Toriwaki ("New algorithms for Euclidean + * distance transformation of an n-dimensional digitized picture with + * applications", Pattern Recognition 27(11): 1551–1565, 1994), + * with the lower envelope computation of P. F. Felzenszwalb and D. P. + * Huttenlocher ("Distance transforms of sampled functions", Theory of + * Computing 8: 415–428, 2012), restricted to runs of equal regions + * so that touching regions are separated. Dilation and influence zones use + * the Voronoi distance transform of ImgLib2. + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +public final class LabelDistances { + + private LabelDistances() { + // Prevent instantiation of static utility class. + } + + /** + * Computes, for each labeled pixel, the Euclidean distance to the nearest + * pixel that does not belong to its region, i.e. that is unlabeled or + * carries different labels. Pixels at the edge of a region thus have a + * distance of one pixel spacing, also where two regions touch. Unlabeled + * pixels have distance 0. A region whose distance is not bounded within + * the labeling interval (such as a region filling it) has infinite + * distance. + * + * @param labeling the labeled objects + * @param calibration the pixel spacing along each dimension (default 1) + * @return the distance map, with the interval of the labeling + * @param label type + * @param index type of the labeling + * @implNote op names='labeling.distanceMap', type=Function + */ + public static > RandomAccessibleInterval + distanceMap(final ImgLabeling labeling, + @Nullable double[] calibration) + { + final var regions = Regions.of(labeling); + final var d = squaredDistances(regions.ids, regions.empty, regions.dims, + Calibrations.spacing(calibration, regions.dims.length, + "calibration")); + for (var i = 0; i < d.length; i++) + d[i] = Math.sqrt(d[i]); + final RandomAccessibleInterval img = ArrayImgs.doubles(d, + regions.dims); + return Views.translate(img, labeling.minAsLongArray()); + } + + /** + * Dilates the labels by the given radius. Each unlabeled pixel within the + * radius of a labeled pixel takes the labels of the nearest labeled pixel, + * so labels never overwrite each other: where two labels grow into each + * other, they meet halfway (ties are broken arbitrarily). + * + * @param labeling the labeled objects + * @param radius the dilation radius, in calibrated units + * @param calibration the pixel spacing along each dimension (default 1) + * @return a new labeling with the labels dilated + * @param label type + * @param index type of the labeling + * @implNote op names='labeling.dilate', type=Function + */ + public static > ImgLabeling dilate( + final ImgLabeling labeling, final Double radius, + @Nullable double[] calibration) + { + final var regions = Regions.of(labeling); + final var r2 = radius * radius; + final var d = propagate(regions, calibration); + for (var i = 0; i < d.length; i++) + if (d[i] > r2) regions.ids[i] = regions.empty; + return regions.toLabeling(labeling); + } + + /** + * Erodes the labels by the given radius: each labeled pixel within the + * radius of a pixel outside its region becomes unlabeled. By default, + * regions are eroded from both the unlabeled pixels and the pixels of other + * regions, so touching labels separate. + * + * @param labeling the labeled objects + * @param radius the erosion radius, in calibrated units + * @param calibration the pixel spacing along each dimension (default 1) + * @param backgroundOnly if true, erode only from the unlabeled pixels, + * leaving the borders between touching regions in place (default + * false) + * @return a new labeling with the labels eroded + * @param label type + * @param index type of the labeling + * @implNote op names='labeling.erode', type=Function + */ + public static > ImgLabeling erode( + final ImgLabeling labeling, final Double radius, + @Nullable double[] calibration, @Nullable Boolean backgroundOnly) + { + final var regions = Regions.of(labeling); + erode(regions, radius, calibration, Boolean.TRUE.equals( + backgroundOnly)); + return regions.toLabeling(labeling); + } + + /** + * Opens the labels by the given radius, i.e. erodes and then dilates them. + * Each region is reduced to the union of the balls of the given radius + * that fit inside it, removing details (and regions) thinner than the + * ball. + * + * @param labeling the labeled objects + * @param radius the radius of the ball, in calibrated units + * @param calibration the pixel spacing along each dimension (default 1) + * @return a new labeling with the labels opened + * @param label type + * @param index type of the labeling + * @implNote op names='labeling.open', type=Function + */ + public static > ImgLabeling open( + final ImgLabeling labeling, final Double radius, + @Nullable double[] calibration) + { + final var regions = Regions.of(labeling); + erode(regions, radius, calibration, false); + final var d = propagate(regions, calibration); + final var r2 = radius * radius; + for (var i = 0; i < d.length; i++) + if (d[i] > r2) regions.ids[i] = regions.empty; + return regions.toLabeling(labeling); + } + + /** + * Closes the labels by the given radius, i.e. dilates them (without + * overwriting each other) and then erodes the result from the unlabeled + * pixels only. Gaps narrower than the ball are filled, and the pixels of a + * filled gap between two regions are split between them by proximity. + * Labeled pixels of the input keep their labels. + * + * @param labeling the labeled objects + * @param radius the radius of the ball, in calibrated units + * @param calibration the pixel spacing along each dimension (default 1) + * @return a new labeling with the labels closed + * @param label type + * @param index type of the labeling + * @implNote op names='labeling.close', type=Function + */ + public static > ImgLabeling close( + final ImgLabeling labeling, final Double radius, + @Nullable double[] calibration) + { + final var regions = Regions.of(labeling); + final var d = propagate(regions, calibration); + final var r2 = radius * radius; + for (var i = 0; i < d.length; i++) + if (d[i] > r2) regions.ids[i] = regions.empty; + erode(regions, radius, calibration, true); + return regions.toLabeling(labeling); + } + + /** + * Computes the influence zones of the labels, i.e. their Voronoi + * tessellation: each pixel takes the labels of the nearest labeled pixel + * (ties are broken arbitrarily). Labeled pixels keep their labels. + * + * @param labeling the labeled objects + * @param calibration the pixel spacing along each dimension (default 1) + * @param mask a mask restricting the influence zones: pixels where it is + * false are left unlabeled (default: no restriction). Note that + * distances are still Euclidean, not geodesic within the mask. + * @return a new labeling of the influence zones + * @param label type + * @param index type of the labeling + * @param mask type + * @implNote op names='labeling.influenceZones', type=Function + */ + public static , B extends BooleanType> + ImgLabeling influenceZones(final ImgLabeling labeling, + @Nullable double[] calibration, + @Nullable RandomAccessibleInterval mask) + { + final var regions = Regions.of(labeling); + propagate(regions, calibration); + if (mask != null) { + if (!Intervals.equalDimensions(mask, labeling)) { + throw new IllegalArgumentException( + "Mask and labeling dimensions differ"); + } + var i = 0; + for (final var m : Views.flatIterable(mask)) { + if (!m.get()) regions.ids[i] = regions.empty; + i++; + } + } + return regions.toLabeling(labeling); + } + + // -- Helper methods -- + + private static void erode(final Regions regions, final double radius, + final double[] calibration, final boolean backgroundOnly) + { + final var ids = regions.ids; + final int[] areas; + if (backgroundOnly) { + areas = new int[ids.length]; + for (var i = 0; i < ids.length; i++) + areas[i] = ids[i] == regions.empty ? 0 : 1; + } + else areas = ids; + final var empty = backgroundOnly ? 0 : regions.empty; + final var d = squaredDistances(areas, empty, regions.dims, + Calibrations.spacing(calibration, regions.dims.length, + "calibration")); + final var r2 = radius * radius; + for (var i = 0; i < d.length; i++) + if (d[i] <= r2) ids[i] = regions.empty; + } + + /** + * Propagates the region ids to all unlabeled pixels, by nearest labeled + * pixel, and returns the squared distances to it. + */ + private static double[] propagate(final Regions regions, + final double[] calibration) + { + final var n = regions.dims.length; + final var s = Calibrations.spacing(calibration, n, "calibration"); + final var weights = new double[n]; + for (var d = 0; d < n; d++) + weights[d] = s[d] * s[d]; + final var ids = ArrayImgs.ints(regions.ids, regions.dims); + final var distances = DistanceTransform.voronoiDistanceTransform(ids, + regions.empty, weights); + final var result = new double[regions.ids.length]; + var i = 0; + for (final var v : Views.flatIterable(distances)) + result[i++] = v.get(); + return result; + } + + /** + * Computes the squared Euclidean distance of each pixel to the nearest + * pixel with a different region id. Pixels of the {@code empty} region + * get distance 0. One pass per dimension computes the exact distance + * restricted to the subspace of the dimensions processed so far: along + * each line, a pixel of region k is either closest to the first pixel + * outside its run of region k, or to a pixel inside that run plus that + * pixel's partial distance from the previous passes. + */ + static double[] squaredDistances(final int[] ids, final int empty, + final long[] dims, final double[] calibration) + { + final var total = ids.length; + final var f = new double[total]; + for (var i = 0; i < total; i++) + f[i] = ids[i] == empty ? 0 : Double.POSITIVE_INFINITY; + var stride = 1; + final var maxLength = (int) Arrays.stream(dims).max().orElse(1); + final var line = new double[maxLength]; + final var pos = new int[maxLength + 2]; + final var val = new double[maxLength + 2]; + final var v = new int[maxLength + 2]; + final var z = new double[maxLength + 3]; + for (var d = 0; d < dims.length; d++) { + final var length = (int) dims[d]; + final var w = calibration[d] * calibration[d]; + final var block = stride * length; + for (var outer = 0; outer < total; outer += block) { + for (var inner = 0; inner < stride; inner++) { + final var start = outer + inner; + processLine(ids, f, empty, start, stride, length, w, line, pos, val, + v, z); + } + } + stride = block; + } + return f; + } + + private static void processLine(final int[] ids, final double[] f, + final int empty, final int start, final int stride, final int length, + final double w, final double[] line, final int[] pos, final double[] val, + final int[] v, final double[] z) + { + var a = 0; + while (a < length) { + final var id = ids[start + a * stride]; + var b = a; + while (b + 1 < length && ids[start + (b + 1) * stride] == id) + b++; + if (id != empty) { + // Collect the sample points: the run and its outside neighbors. + var count = 0; + if (a > 0) { + pos[count] = a - 1; + val[count++] = 0; + } + for (var i = a; i <= b; i++) { + final var g = f[start + i * stride]; + if (g == Double.POSITIVE_INFINITY) continue; + pos[count] = i; + val[count++] = g; + } + if (b < length - 1) { + pos[count] = b + 1; + val[count++] = 0; + } + lowerEnvelope(pos, val, count, a, b, w, line, v, z); + for (var i = a; i <= b; i++) + f[start + i * stride] = line[i]; + } + a = b + 1; + } + } + + /** + * Evaluates min_q (val[q] + w (i - pos[q])^2) for each i in [a, b], via the + * lower envelope of parabolas. + */ + private static void lowerEnvelope(final int[] pos, final double[] val, + final int count, final int a, final int b, final double w, + final double[] out, final int[] v, final double[] z) + { + if (count == 0) { + for (var i = a; i <= b; i++) + out[i] = Double.POSITIVE_INFINITY; + return; + } + var k = 0; + v[0] = 0; + z[0] = Double.NEGATIVE_INFINITY; + z[1] = Double.POSITIVE_INFINITY; + for (var q = 1; q < count; q++) { + double s; + while (true) { + final var p = v[k]; + s = ((val[q] + w * pos[q] * pos[q]) - (val[p] + w * pos[p] * + pos[p])) / (2 * w * (pos[q] - pos[p])); + if (k == 0 || s > z[k]) break; + k--; + } + k++; + v[k] = q; + z[k] = s; + z[k + 1] = Double.POSITIVE_INFINITY; + } + k = 0; + for (var i = a; i <= b; i++) { + while (z[k + 1] < i) + k++; + final var p = v[k]; + final double dx = i - pos[p]; + out[i] = val[p] + w * dx * dx; + } + } + + /** The label set index of each pixel, as a flat array. */ + private static final class Regions { + + private final int[] ids; + private final long[] dims; + private final int empty; + private final List> sets; + + private Regions(final int[] ids, final long[] dims, final int empty, + final List> sets) + { + this.ids = ids; + this.dims = dims; + this.empty = empty; + this.sets = sets; + } + + private static Regions of(final ImgLabeling labeling) { + final var dims = labeling.dimensionsAsLongArray(); + final var size = Intervals.numElements(labeling); + if (size > Integer.MAX_VALUE) { + throw new IllegalArgumentException("Labeling too large: " + size + + " pixels"); + } + final var mapping = labeling.getMapping(); + final List> sets = new ArrayList<>(); + for (var i = 0; i < mapping.numSets(); i++) + sets.add(mapping.labelsAtIndex(i)); + final var ids = new int[(int) size]; + var i = 0; + for (final var index : Views.flatIterable(labeling.getIndexImg())) + ids[i++] = index.getInteger(); + // Note: Index 0 of a labeling mapping is always the empty label set. + return new Regions(ids, dims, 0, sets); + } + + @SuppressWarnings({ "unchecked", "rawtypes" }) + private > ImgLabeling toLabeling( + final ImgLabeling like) + { + final var indexImg = like.getIndexImg(); + final var type = indexImg.getType().createVariable(); + final Img img = Util.getSuitableImgFactory(indexImg, type).create( + indexImg); + var i = 0; + for (final var index : Views.flatIterable(img)) + index.setInteger(ids[i++]); + final RandomAccessibleInterval out = Views.isZeroMin(indexImg) ? img + : Views.translate(img, indexImg.minAsLongArray()); + return ImgLabeling.fromImageAndLabelSets(out, (List) sets); + } + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/labeling/edgecorrection/EdgeCorrection.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/labeling/edgecorrection/EdgeCorrection.java new file mode 100644 index 000000000..b17501711 --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/labeling/edgecorrection/EdgeCorrection.java @@ -0,0 +1,170 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.labeling.edgecorrection; + +import java.util.LinkedHashMap; +import java.util.Map; + +import net.imglib2.roi.labeling.ImgLabeling; +import net.imglib2.roi.labeling.LabelRegions; +import net.imglib2.type.numeric.IntegerType; + +/** + * Ops implementing the Miles–Lantuéjoul edge correction for + * counting objects in a bounded field of view. + *

+ * Objects that touch the border of the field of view cannot be measured, but + * simply excluding them biases the count (and size statistics) downwards, + * because large objects are more likely to touch the border. The + * Miles–Lantuéjoul correction excludes the border-touching + * objects and weights each remaining object by the inverse of the probability + * that a randomly placed copy of its bounding box lies entirely inside the + * field of view: + *

+ * + *
+ * weight = ∏_d W_d / (W_d - w_d)
+ * 
+ *

+ * where {@code W_d} is the size of the field of view and {@code w_d} the + * bounding-box size of the object in dimension {@code d}. Edge-corrected + * statistics of any per-object measurement {@code m} can be computed as + * {@code Σ weight·m / Σ weight}. + *

+ *

+ * Based on the "Field-of-view measure correction" ({@code CountCorrection}) + * macro of the BioVoxxel Toolbox by Jan Brocher / BioVoxxel (BSD 3-Clause + * license, Copyright (C) Jan Brocher / BioVoxxel), generalized from 2D to n + * dimensions. References: R. E. Miles, "On the elimination of edge effects in + * planar sampling", in Stochastic Geometry, Wiley, 1974; C. + * Lantuéjoul, "Computation of the histograms of the number of edges and + * neighbours of cells in a tessellation", Geometrical Probability and + * Biological Structures, Springer, 1978. + *

+ *

+ * The original BioVoxxel Toolbox license notice follows. + *

+ * + *
+ * Copyright (c) 2015, BioVoxxel / Jan Brocher
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ *
+ * * Redistributions of source code must retain the above copyright notice, this
+ *   list of conditions and the following disclaimer.
+ *
+ * * 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.
+ *
+ * * Neither the name of BioVoxxel nor the names of its
+ *   contributors may be used to endorse or promote products derived from
+ *   this software without specific prior written permission.
+ *
+ * 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 HOLDER 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.
+ * 
+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @author Jan Brocher (BioVoxxel) + * @see BioVoxxel + * Toolbox + */ +public final class EdgeCorrection { + + private EdgeCorrection() { + // Prevent instantiation of static utility class. + } + + /** + * Computes the Miles–Lantuéjoul edge-correction weight of each + * label, taking the interval of the labeling as the field of view. Labels + * touching the border of the field of view get weight 0. + * + * @param labeling the labeled objects + * @return a map from each label to its correction weight + * @param label type + * @param index type of the labeling + * @implNote op names='labeling.edgeCorrectionWeights', type=Function + */ + public static > Map weights( + final ImgLabeling labeling) + { + final var n = labeling.numDimensions(); + final var weights = new LinkedHashMap(); + for (final var region : new LabelRegions<>(labeling)) { + double weight = 1; + for (var d = 0; d < n; d++) { + if (region.min(d) <= labeling.min(d) || region.max(d) >= labeling.max( + d)) + { + weight = 0; + break; + } + final double fieldSize = labeling.dimension(d); + weight *= fieldSize / (fieldSize - region.dimension(d)); + } + weights.put(region.getLabel(), weight); + } + return weights; + } + + /** + * Computes the Miles–Lantuéjoul edge-corrected number of + * objects, i.e. the sum of the edge-correction weights of all labels, taking + * the interval of the labeling as the field of view. Labels touching the + * border of the field of view are excluded. + * + * @param labeling the labeled objects + * @return the edge-corrected object count + * @param label type + * @param index type of the labeling + * @implNote op names='labeling.edgeCorrectedCount', type=Function + */ + public static > Double count( + final ImgLabeling labeling) + { + double sum = 0; + for (final var w : weights(labeling).values()) + sum += w; + return sum; + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/labeling/featuremap/DefaultFeatureMap.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/labeling/featuremap/DefaultFeatureMap.java new file mode 100644 index 000000000..caf533fb4 --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/labeling/featuremap/DefaultFeatureMap.java @@ -0,0 +1,143 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.labeling.featuremap; + +import java.util.Map; + +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.loops.LoopBuilder; +import net.imglib2.roi.labeling.ImgLabeling; +import net.imglib2.type.numeric.IntegerType; +import net.imglib2.type.numeric.RealType; + +import org.scijava.function.Computers; +import org.scijava.ops.spi.Nullable; + +/** + * Paints a per-label value (such as a shape descriptor, size or mean + * intensity) into an image: each labeled pixel is set to the value of its + * label, producing a "feature map" for visualizing or thresholding objects by + * a measurement. + *

+ * Pixels without a label, or whose labels have no value in the map, are set to + * the background value. Pixels with several labels are set to the mean of + * their labels' values. + *

+ *

+ * Based on the "Shape Descriptor Maps" macro of the BioVoxxel Toolbox by Jan + * Brocher / BioVoxxel (BSD 3-Clause license, Copyright (C) Jan Brocher / + * BioVoxxel), generalized from a fixed set of 2D shape descriptors to any + * per-label value in n dimensions. + *

+ *

+ * The original BioVoxxel Toolbox license notice follows. + *

+ * + *
+ * Copyright (c) 2015, BioVoxxel / Jan Brocher
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ *
+ * * Redistributions of source code must retain the above copyright notice, this
+ *   list of conditions and the following disclaimer.
+ *
+ * * 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.
+ *
+ * * Neither the name of BioVoxxel nor the names of its
+ *   contributors may be used to endorse or promote products derived from
+ *   this software without specific prior written permission.
+ *
+ * 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 HOLDER 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.
+ * 
+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @author Jan Brocher (BioVoxxel) + * @param label type + * @param index type of the labeling + * @param value type + * @param output pixel type + * @see BioVoxxel + * Toolbox + * @implNote op names='labeling.featureMap' + */ +public class DefaultFeatureMap, N extends Number, V extends RealType> + implements + Computers.Arity3, Map, Double, RandomAccessibleInterval> +{ + + /** + * Paints the feature map. + * + * @param labeling the labeled objects + * @param values the value of each label + * @param background the value of pixels without a (mapped) label (default + * 0) + * @param output the feature map + */ + @Override + public void compute(final ImgLabeling labeling, final Map values, + @Nullable Double background, final RandomAccessibleInterval output) + { + final var bg = background == null ? 0 : background; + final var mapping = labeling.getMapping(); + final var indexCount = mapping.numSets(); + + // Precompute the value for each index of the labeling. + final var indexValues = new double[indexCount]; + for (var i = 0; i < indexCount; i++) { + double sum = 0; + var count = 0; + for (final var label : mapping.labelsAtIndex(i)) { + final var value = values.get(label); + if (value == null) continue; + sum += value.doubleValue(); + count++; + } + indexValues[i] = count == 0 ? bg : sum / count; + } + + LoopBuilder.setImages(labeling.getIndexImg(), output).forEachPixel(( + index, o) -> o.setReal(indexValues[index.getInteger()])); + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/labeling/measure/EquivalentEllipsoid.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/labeling/measure/EquivalentEllipsoid.java new file mode 100644 index 000000000..e197ed27f --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/labeling/measure/EquivalentEllipsoid.java @@ -0,0 +1,98 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.labeling.measure; + +import java.util.Arrays; + +/** + * An n-dimensional ellipsoid with arbitrary orientation, as computed by + * {@code labeling.equivalentEllipsoid}: a center, the semi-axis lengths + * (radii) in decreasing order, and the unit direction of each semi-axis. + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +public final class EquivalentEllipsoid { + + private final double[] center; + private final double[] radii; + private final double[][] axes; + + /** + * Creates an ellipsoid. + * + * @param center the center + * @param radii the semi-axis lengths, in decreasing order + * @param axes the unit direction of each semi-axis, {@code axes[k]} being + * that of {@code radii[k]} + */ + public EquivalentEllipsoid(final double[] center, final double[] radii, + final double[][] axes) + { + this.center = center.clone(); + this.radii = radii.clone(); + this.axes = new double[axes.length][]; + for (var k = 0; k < axes.length; k++) + this.axes[k] = axes[k].clone(); + } + + /** @return the number of dimensions */ + public int numDimensions() { + return center.length; + } + + /** @return a copy of the center */ + public double[] center() { + return center.clone(); + } + + /** @return a copy of the semi-axis lengths, in decreasing order */ + public double[] radii() { + return radii.clone(); + } + + /** + * @param k the index of the semi-axis + * @return a copy of the unit direction of the {@code k}-th semi-axis + */ + public double[] axis(final int k) { + return axes[k].clone(); + } + + @Override + public String toString() { + final var sb = new StringBuilder("EquivalentEllipsoid[center="); + sb.append(Arrays.toString(center)).append(", radii=").append(Arrays + .toString(radii)).append(", axes="); + for (final var a : axes) + sb.append(Arrays.toString(a)); + return sb.append("]").toString(); + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/labeling/measure/EquivalentEllipsoids.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/labeling/measure/EquivalentEllipsoids.java new file mode 100644 index 000000000..f8f8d7781 --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/labeling/measure/EquivalentEllipsoids.java @@ -0,0 +1,154 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.labeling.measure; + +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.Map; + +import net.imglib2.roi.labeling.ImgLabeling; +import net.imglib2.roi.labeling.LabelRegions; +import net.imglib2.type.numeric.IntegerType; + +import org.apache.commons.math3.linear.EigenDecomposition; +import org.apache.commons.math3.linear.MatrixUtils; +import org.scijava.ops.image.util.Calibrations; +import org.scijava.ops.spi.Nullable; + +/** + * The equivalent ellipsoid of each labeled region: the solid ellipsoid with + * the same centroid and second-order central moments as the region, in any + * number of dimensions. + *

+ * Each pixel counts as a solid box of the size of the pixel spacing, so that + * the covariance matrix of the region is that of its pixel centers plus + * {@code s_d² / 12} on the diagonal (the variance of a uniform distribution + * over one pixel). A solid ellipsoid in {@code n} dimensions with semi-axis + * length {@code a} has variance {@code a² / (n + 2)} along that axis, so the + * radii are {@code sqrt((n + 2) λ_k)} for the eigenvalues {@code λ_k} of the + * covariance matrix, along the corresponding eigenvectors (see e.g. R. J. + * Prokop and A. P. Reeves, "A survey of moment-based techniques for + * unoccluded object representation and recognition", CVGIP: Graphical + * Models and Image Processing 54(5): 438–460, 1992). In 2D this is + * the familiar moment-based equivalent ellipse. + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +public final class EquivalentEllipsoids { + + private EquivalentEllipsoids() { + // Prevent instantiation of static utility class. + } + + /** + * Computes the equivalent ellipsoid of each label. Centers are in + * calibrated coordinates (the pixel position times the calibration, + * measured between pixel centers), radii in calibrated units, in + * decreasing order. Each axis direction is a unit vector whose first non-zero + * component is positive; in 2D, the major axis direction is thus + * {@code (cos θ, sin θ)} with the angle {@code θ} in (-90°, 90°]. + * + * @param labeling the labeled objects + * @param calibration the pixel spacing along each dimension (default 1) + * @return a map from each label to its equivalent ellipsoid + * @param label type + * @param index type of the labeling + * @implNote op names='labeling.equivalentEllipsoid', type=Function + */ + public static > Map + equivalentEllipsoids(final ImgLabeling labeling, + @Nullable double[] calibration) + { + final var n = labeling.numDimensions(); + final var s = Calibrations.spacing(calibration, n, "calibration"); + final var result = new LinkedHashMap(); + final var pos = new double[n]; + for (final var region : new LabelRegions<>(labeling)) { + final var count = region.inside().size(); + if (count == 0) continue; + final var center = new double[n]; + var cursor = region.inside().localizingCursor(); + while (cursor.hasNext()) { + cursor.fwd(); + for (var d = 0; d < n; d++) + center[d] += cursor.getDoublePosition(d) * s[d]; + } + for (var d = 0; d < n; d++) + center[d] /= count; + final var cov = new double[n][n]; + cursor = region.inside().localizingCursor(); + while (cursor.hasNext()) { + cursor.fwd(); + for (var d = 0; d < n; d++) + pos[d] = cursor.getDoublePosition(d) * s[d] - center[d]; + for (var i = 0; i < n; i++) + for (var j = 0; j <= i; j++) + cov[i][j] += pos[i] * pos[j]; + } + for (var i = 0; i < n; i++) { + for (var j = 0; j <= i; j++) { + cov[i][j] /= count; + cov[j][i] = cov[i][j]; + } + cov[i][i] += s[i] * s[i] / 12; + } + result.put(region.getLabel(), ellipsoid(center, cov)); + } + return result; + } + + private static EquivalentEllipsoid ellipsoid(final double[] center, + final double[][] cov) + { + final var n = center.length; + final var eigen = new EigenDecomposition(MatrixUtils.createRealMatrix( + cov)); + final var values = eigen.getRealEigenvalues(); + final var order = new Integer[n]; + for (var k = 0; k < n; k++) + order[k] = k; + Arrays.sort(order, (a, b) -> Double.compare(values[b], values[a])); + final var radii = new double[n]; + final var axes = new double[n][]; + for (var k = 0; k < n; k++) { + radii[k] = Math.sqrt((n + 2) * Math.max(0, values[order[k]])); + final var v = eigen.getEigenvector(order[k]).unitVector().toArray(); + var first = 0; + while (first < n - 1 && Math.abs(v[first]) < 1e-12) + first++; + if (v[first] < 0) for (var d = 0; d < n; d++) + v[d] = -v[d]; + axes[k] = v; + } + return new EquivalentEllipsoid(center, radii, axes); + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/labeling/measure/FeretDiameters.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/labeling/measure/FeretDiameters.java new file mode 100644 index 000000000..8116efaa4 --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/labeling/measure/FeretDiameters.java @@ -0,0 +1,220 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.labeling.measure; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.Map; + +import net.imglib2.RealLocalizable; +import net.imglib2.RealPoint; +import net.imglib2.roi.labeling.ImgLabeling; +import net.imglib2.roi.labeling.LabelRegion; +import net.imglib2.roi.labeling.LabelRegions; +import net.imglib2.type.numeric.IntegerType; +import net.imglib2.util.Pair; +import net.imglib2.util.ValuePair; + +import org.scijava.ops.image.util.Calibrations; +import org.scijava.ops.spi.Nullable; + +/** + * The maximum Feret diameter (caliper diameter) of each labeled region, in + * any number of dimensions: the largest distance between two points of the + * region, each pixel counting as a solid box of the size of the pixel + * spacing. + *

+ * The farthest pair of points of a set is a pair of vertices of its convex + * hull, so only pixel corners that can be hull vertices are compared: a + * corner strictly between two other corners of the region on an axis-aligned + * line cannot be a vertex, which leaves the corners that are extreme on all + * axis-aligned lines through them. The remaining candidates are compared + * exhaustively, which takes time quadratic in their number: fine for typical + * cells and nuclei, but slow for very large 3D regions (a digital ball of + * radius 200 leaves on the order of 105 candidates). For 2D polygons, see + * also {@code geom.maximumFeretsDiameter}. + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +public final class FeretDiameters { + + private FeretDiameters() { + // Prevent instantiation of static utility class. + } + + /** + * Computes the maximum Feret diameter of each label, in the units of the + * pixel spacing. A single pixel has the length of its diagonal (√2 in + * 2D with unit calibration). + * + * @param labeling the labeled objects + * @param calibration the pixel spacing along each dimension (default 1) + * @return a map from each label to its maximum Feret diameter + * @param label type + * @param index type of the labeling + * @implNote op names='labeling.maximumFeretsDiameter', type=Function + */ + public static > Map diameters( + final ImgLabeling labeling, @Nullable double[] calibration) + { + final var result = new LinkedHashMap(); + for (final var entry : ferets(labeling, calibration).entrySet()) { + final var p = entry.getValue(); + double d2 = 0; + for (var d = 0; d < p.getA().numDimensions(); d++) { + final var delta = p.getA().getDoublePosition(d) - p.getB() + .getDoublePosition(d); + d2 += delta * delta; + } + result.put(entry.getKey(), Math.sqrt(d2)); + } + return result; + } + + /** + * Finds the endpoints of the maximum Feret diameter of each label: two + * pixel corners at the largest distance from each other, in calibrated + * coordinates (the pixel position times the calibration, with pixel centers + * at integer positions, so corners lie half a pixel away from them). + * + * @param labeling the labeled objects + * @param calibration the pixel spacing along each dimension (default 1) + * @return a map from each label to the two endpoints of its maximum Feret + * diameter + * @param label type + * @param index type of the labeling + * @implNote op names='labeling.maximumFeret', type=Function + */ + public static > + Map> ferets( + final ImgLabeling labeling, @Nullable double[] calibration) + { + final var n = labeling.numDimensions(); + final var s = Calibrations.spacing(calibration, n, "calibration"); + final var result = + new LinkedHashMap>(); + for (final var region : new LabelRegions<>(labeling)) { + if (region.inside().size() == 0) continue; + result.put(region.getLabel(), feret(region, s)); + } + return result; + } + + private static Pair feret( + final LabelRegion region, final double[] s) + { + final var n = region.numDimensions(); + final var min = region.minAsLongArray(); + // The corner grid has one more node than pixels along each dimension. + final var dims = new int[n]; + final var strides = new int[n]; + long total = 1; + for (var d = 0; d < n; d++) { + dims[d] = (int) region.dimension(d) + 1; + strides[d] = (int) total; + total *= dims[d]; + } + if (total > Integer.MAX_VALUE - 8) { + throw new IllegalArgumentException("Region too large: " + total + + " corners"); + } + final var size = (int) total; + + // Mark the corners of all region pixels. + final var corner = new boolean[size]; + final var cursor = region.inside().localizingCursor(); + final var cornerCount = 1 << n; + while (cursor.hasNext()) { + cursor.fwd(); + var base = 0; + for (var d = 0; d < n; d++) + base += (int) (cursor.getLongPosition(d) - min[d]) * strides[d]; + for (var c = 0; c < cornerCount; c++) { + var index = base; + for (var d = 0; d < n; d++) + if ((c & (1 << d)) != 0) index += strides[d]; + corner[index] = true; + } + } + + // Keep the corners that are extreme on every axis-aligned line. + final var extremeCount = new byte[size]; + for (var d = 0; d < n; d++) { + final var len = dims[d]; + final var stride = strides[d]; + for (var start = 0; start < size; start++) { + if ((start / stride) % len != 0) continue; + var first = -1; + var last = -1; + for (int k = 0, i = start; k < len; k++, i += stride) { + if (!corner[i]) continue; + if (first < 0) first = i; + last = i; + } + if (first < 0) continue; + extremeCount[first]++; + if (last != first) extremeCount[last]++; + } + } + final var candidates = new ArrayList(); + for (var i = 0; i < size; i++) { + if (!corner[i] || extremeCount[i] < n) continue; + final var p = new double[n]; + var rem = i; + for (var d = 0; d < n; d++) { + p[d] = (min[d] + rem % dims[d] - 0.5) * s[d]; + rem /= dims[d]; + } + candidates.add(p); + } + + var best = -1.0; + double[] a = null, b = null; + for (var i = 0; i < candidates.size(); i++) { + final var p = candidates.get(i); + for (var j = i + 1; j < candidates.size(); j++) { + final var q = candidates.get(j); + double d2 = 0; + for (var d = 0; d < n; d++) { + final var delta = p[d] - q[d]; + d2 += delta * delta; + } + if (d2 > best) { + best = d2; + a = p; + b = q; + } + } + } + return new ValuePair<>(new RealPoint(a), new RealPoint(b)); + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/labeling/measure/InscribedBalls.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/labeling/measure/InscribedBalls.java new file mode 100644 index 000000000..05d34175b --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/labeling/measure/InscribedBalls.java @@ -0,0 +1,147 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.labeling.measure; + +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.function.BiFunction; + +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.roi.geom.real.ClosedWritableSphere; +import net.imglib2.roi.geom.real.Sphere; +import net.imglib2.roi.labeling.ImgLabeling; +import net.imglib2.type.numeric.IntegerType; +import net.imglib2.type.numeric.real.DoubleType; +import net.imglib2.view.Views; + +import org.scijava.ops.image.util.Calibrations; +import org.scijava.ops.spi.Nullable; +import org.scijava.ops.spi.OpDependency; + +/** + * The largest inscribed ball of each labeled region: a ball centered at the + * pixel farthest from the region boundary, whose radius is that distance. + *

+ * Distances are those of {@code labeling.distanceMap}: exact Euclidean + * distances from each pixel center to the nearest pixel center outside its + * region, in the units of the pixel spacing. As there, the edge of the + * labeling interval does not bound the regions. This is the classical + * maximal-ball characterization of the medial axis (H. Blum, "A + * transformation for extracting new descriptors of shape", in Models for + * the Perception of Speech and Visual Form, MIT Press, 1967), restricted + * to the largest ball. + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +public final class InscribedBalls { + + private InscribedBalls() { + // Prevent instantiation of static utility class. + } + + /** + * Finds the largest inscribed ball of each label. Its center is the center + * of the pixel with the largest distance to the region boundary (the first + * such pixel in flat iteration order), in calibrated coordinates (the + * pixel position times the calibration), and its radius is that distance. A + * single pixel has the smallest pixel spacing as radius. With overlapping + * labels, the distance map is that of the label sets, so the regions of + * overlap count as separate regions. + * + * @param distanceMap the op computing the label distance map + * @param labeling the labeled objects + * @param calibration the pixel spacing along each dimension (default 1) + * @return a map from each label to its largest inscribed ball + * @param label type + * @param index type of the labeling + * @implNote op names='labeling.inscribedBall', type=Function + */ + public static > Map inscribedBalls( + @OpDependency( + name = "labeling.distanceMap") BiFunction, double[], RandomAccessibleInterval> distanceMap, + final ImgLabeling labeling, @Nullable double[] calibration) + { + final var n = labeling.numDimensions(); + final var s = Calibrations.spacing(calibration, n, "calibration"); + final var map = distanceMap.apply(labeling, s); + final var mapping = labeling.getMapping(); + final var best = new double[mapping.numSets()]; + final var where = new long[mapping.numSets()][]; + Arrays.fill(best, -1); + final var labels = Views.flatIterable(labeling.getIndexImg()) + .localizingCursor(); + final var distances = Views.flatIterable(Views.interval(map, labeling)) + .cursor(); + while (labels.hasNext()) { + final var index = labels.next().getInteger(); + final var d = distances.next().get(); + if (d > best[index]) { + best[index] = d; + where[index] = labels.positionAsLongArray(); + } + } + // Combine the label sets of each label. + final var radii = new LinkedHashMap(); + final var centers = new LinkedHashMap(); + for (var i = 0; i < best.length; i++) { + if (where[i] == null) continue; + for (final var label : mapping.labelsAtIndex(i)) { + final var r = radii.get(label); + if (r == null || best[i] > r || best[i] == r && before(where[i], + centers.get(label))) + { + radii.put(label, best[i]); + centers.put(label, where[i]); + } + } + } + final var result = new LinkedHashMap(); + for (final var entry : radii.entrySet()) { + final var pos = centers.get(entry.getKey()); + final var center = new double[n]; + for (var d = 0; d < n; d++) + center[d] = pos[d] * s[d]; + result.put(entry.getKey(), new ClosedWritableSphere(center, entry + .getValue())); + } + return result; + } + + /** Checks whether position a comes before b in flat iteration order. */ + private static boolean before(final long[] a, final long[] b) { + for (var d = a.length - 1; d >= 0; d--) + if (a[d] != b[d]) return a[d] < b[d]; + return false; + } + +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/labeling/nearestneighbor/NearestNeighbors.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/labeling/nearestneighbor/NearestNeighbors.java new file mode 100644 index 000000000..b7bdfa28b --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/labeling/nearestneighbor/NearestNeighbors.java @@ -0,0 +1,199 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.labeling.nearestneighbor; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.Map; + +import net.imglib2.KDTree; +import net.imglib2.RealLocalizable; +import net.imglib2.RealPoint; +import net.imglib2.neighborsearch.KNearestNeighborSearchOnKDTree; +import net.imglib2.roi.labeling.ImgLabeling; +import net.imglib2.roi.labeling.LabelRegions; +import net.imglib2.type.numeric.IntegerType; +import net.imglib2.util.Intervals; + +import org.apache.commons.math3.distribution.NormalDistribution; +import org.apache.commons.math3.special.Gamma; +import org.scijava.ops.image.util.Calibrations; +import org.scijava.ops.spi.Nullable; + +/** + * Ops analyzing the spatial distribution of labeled objects via the distances + * between their centroids and those of their nearest neighbors. + *

+ * Inspired by the {@code Particle_Distribution2D} and + * {@code Distribution_Analysis} plugins of the BioVoxxel Toolbox by Jan + * Brocher / BioVoxxel (BSD 3-Clause license, Copyright (C) Jan Brocher / + * BioVoxxel). The statistics here follow Clark and Evans (1954) directly, + * generalized to n dimensions via the nearest-neighbor distance distribution + * of a homogeneous Poisson process, instead of the plugins' tabulated F/t + * tests. + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @see BioVoxxel + * Toolbox + */ +public final class NearestNeighbors { + + private NearestNeighbors() { + // Prevent instantiation of static utility class. + } + + /** + * Computes, for each label, the Euclidean distance (in calibrated units) + * from its centroid to the nearest centroid of any other label. + * + * @param labeling the labeled objects + * @param calibration the pixel spacing along each dimension (default 1) + * @return a map from each label to its nearest-neighbor distance (positive + * infinity if there is only one label) + * @param label type + * @param index type of the labeling + * @implNote op names='labeling.nearestNeighborDistances', type=Function + */ + public static > Map distances( + final ImgLabeling labeling, @Nullable double[] calibration) + { + final var n = labeling.numDimensions(); + final var s = Calibrations.spacing(calibration, n, "calibration"); + final var labels = new ArrayList(); + final var centroids = new ArrayList(); + for (final var region : new LabelRegions<>(labeling)) { + if (region.inside().size() == 0) continue; + labels.add(region.getLabel()); + final var centroid = new RealPoint(n); + final var center = region.getCenterOfMass(); + for (var d = 0; d < n; d++) + centroid.setPosition(center.getDoublePosition(d) * s[d], d); + centroids.add(centroid); + } + final var result = new LinkedHashMap(); + if (labels.size() < 2) { + for (final var label : labels) + result.put(label, Double.POSITIVE_INFINITY); + return result; + } + final var tree = new KDTree<>(labels, centroids); + // The nearest point is the query point itself; take the second nearest. + final var search = new KNearestNeighborSearchOnKDTree<>(tree, 2); + for (var i = 0; i < labels.size(); i++) { + search.search(centroids.get(i)); + result.put(labels.get(i), search.getDistance(1)); + } + return result; + } + + /** + * Computes the Clark–Evans aggregation index of the labeled objects' + * centroids, with its significance test against complete spatial + * randomness. + *

+ * The index is {@code R = r_obs / r_exp}, the ratio of the observed mean + * nearest-neighbor distance to the one expected for a homogeneous Poisson + * process of the same density {@code λ = n / V}. In {@code d} dimensions, + * with {@code c_d} the volume of the unit ball, the expected distance is + * {@code Γ(1 + 1/d) (λ c_d)^(-1/d)} and its variance is + * {@code (Γ(1 + 2/d) - Γ(1 + 1/d)²) (λ c_d)^(-2/d)}; in 2D these reduce to + * Clark and Evans' {@code 0.5 / sqrt(λ)} and standard error + * {@code 0.26136 / sqrt(n λ)}. {@code R < 1} indicates clustering, + * {@code R ≈ 1} randomness and {@code R > 1} regularity (at most 2.1491 in + * 2D, for a hexagonal lattice). No edge correction is applied, which biases + * {@code R} slightly upwards for objects near the border. + *

+ *

+ * Reference: P. J. Clark and F. C. Evans, "Distance to nearest neighbor as a + * measure of spatial relationships in populations", Ecology 35(4): + * 445–453, 1954. + *

+ * + * @param label type + * @param index type of the labeling + * @param labeling the labeled objects (at least two) + * @param volume the area or volume (in calibrated units) of the region the + * objects were sampled from (default: the calibrated size of the + * interval of the labeling) + * @param calibration the pixel spacing along each dimension (default 1) + * @return a map with the entries {@code "n"} (number of objects), + * {@code "observedMeanDistance"}, {@code "expectedMeanDistance"}, + * {@code "R"}, {@code "standardError"}, {@code "z"} (the z-score of + * the observed mean distance) and {@code "p"} (the two-sided p-value + * of the z-score) + * @implNote op names='labeling.clarkEvans', type=Function + */ + public static > Map clarkEvans( + final ImgLabeling labeling, @Nullable Double volume, + @Nullable double[] calibration) + { + final var s = Calibrations.spacing(calibration, labeling.numDimensions(), + "calibration"); + if (volume == null) { + volume = (double) Intervals.numElements(labeling); + for (final var v : s) + volume *= v; + } + final var distances = distances(labeling, s).values(); + final var n = distances.size(); + if (n < 2) { + throw new IllegalArgumentException( + "At least two objects are required, but there are " + n); + } + double sum = 0; + for (final var distance : distances) + sum += distance; + final var observed = sum / n; + + final var d = labeling.numDimensions(); + final var unitBall = Math.pow(Math.PI, d / 2.0) / Gamma.gamma(d / 2.0 + + 1); + final var scale = Math.pow(n / volume * unitBall, -1.0 / d); + final var g1 = Gamma.gamma(1 + 1.0 / d); + final var g2 = Gamma.gamma(1 + 2.0 / d); + final var expected = g1 * scale; + final var standardError = Math.sqrt((g2 - g1 * g1) / n) * scale; + final var z = (observed - expected) / standardError; + final var p = 2 * new NormalDistribution(null, 0, 1).cumulativeProbability( + -Math.abs(z)); + + final var result = new LinkedHashMap(); + result.put("n", (double) n); + result.put("observedMeanDistance", observed); + result.put("expectedMeanDistance", expected); + result.put("R", observed / expected); + result.put("standardError", standardError); + result.put("z", z); + result.put("p", p); + return result; + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/labeling/overlap/OverlapMeasures.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/labeling/overlap/OverlapMeasures.java new file mode 100644 index 000000000..1842e408d --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/labeling/overlap/OverlapMeasures.java @@ -0,0 +1,400 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.labeling.overlap; + +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.Map; + +import net.imglib2.roi.labeling.ImgLabeling; +import net.imglib2.type.numeric.IntegerType; +import net.imglib2.view.Views; + +/** + * Measures of agreement between two labelings of the same image, such as a + * segmentation (the source) and a ground truth (the target), following the + * definitions of N. J. Tustison and J. C. Gee ("Introducing Dice, Jaccard, + * and other label overlap measures to ITK", The Insight Journal, + * 2009). + *

+ * Labels are matched by equality: for each label {@code r}, {@code S_r} is + * the set of pixels carrying {@code r} in the source and {@code T_r} the set + * of pixels carrying {@code r} in the target. The per-label ops give one + * value for each label occurring in either labeling (which may be + * {@code NaN} when undefined, e.g. a target overlap for a label absent from + * the target). The total ops sum numerators and denominators over all + * labels, e.g. the total Jaccard index is + * {@code Σ|S_r ∩ T_r| / Σ|S_r ∪ T_r|}. Both labelings must have the same + * dimensions. + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +public final class OverlapMeasures { + + private OverlapMeasures() { + // Prevent instantiation of static utility class. + } + + /** + * Computes the total target overlap (sensitivity), {@code Σ|S_r ∩ T_r| / + * Σ|T_r|}. + * + * @param source the labeling to evaluate + * @param target the reference labeling + * @return the total target overlap, between 0 and 1 + * @param label type + * @param index type of the source labeling + * @param index type of the target labeling + * @implNote op names='labeling.overlap.targetOverlap', type=Function + */ + public static , J extends IntegerType> Double + targetOverlap(final ImgLabeling source, + final ImgLabeling target) + { + return Counts.of(source, target).total(Measure.TARGET_OVERLAP); + } + + /** + * Computes the target overlap {@code |S_r ∩ T_r| / |T_r|} of each label. + * + * @param source the labeling to evaluate + * @param target the reference labeling + * @return a map from each label to its target overlap + * @param label type + * @param index type of the source labeling + * @param index type of the target labeling + * @implNote op names='labeling.overlap.targetOverlapByLabel', type=Function + */ + public static , J extends IntegerType> + Map targetOverlapByLabel(final ImgLabeling source, + final ImgLabeling target) + { + return Counts.of(source, target).byLabel(Measure.TARGET_OVERLAP); + } + + /** + * Computes the total Jaccard index (union overlap), {@code Σ|S_r ∩ T_r| / + * Σ|S_r ∪ T_r|}. + * + * @param source the labeling to evaluate + * @param target the reference labeling + * @return the total Jaccard index, between 0 and 1 + * @param label type + * @param index type of the source labeling + * @param index type of the target labeling + * @implNote op names='labeling.overlap.jaccard', type=Function + */ + public static , J extends IntegerType> Double + jaccard(final ImgLabeling source, final ImgLabeling target) + { + return Counts.of(source, target).total(Measure.JACCARD); + } + + /** + * Computes the Jaccard index {@code |S_r ∩ T_r| / |S_r ∪ T_r|} of each + * label. + * + * @param source the labeling to evaluate + * @param target the reference labeling + * @return a map from each label to its Jaccard index + * @param label type + * @param index type of the source labeling + * @param index type of the target labeling + * @implNote op names='labeling.overlap.jaccardByLabel', type=Function + */ + public static , J extends IntegerType> + Map jaccardByLabel(final ImgLabeling source, + final ImgLabeling target) + { + return Counts.of(source, target).byLabel(Measure.JACCARD); + } + + /** + * Computes the total Dice coefficient (mean overlap), {@code 2 Σ|S_r ∩ T_r| + * / Σ(|S_r| + |T_r|)}. + * + * @param source the labeling to evaluate + * @param target the reference labeling + * @return the total Dice coefficient, between 0 and 1 + * @param label type + * @param index type of the source labeling + * @param index type of the target labeling + * @implNote op names='labeling.overlap.dice', type=Function + */ + public static , J extends IntegerType> Double + dice(final ImgLabeling source, final ImgLabeling target) + { + return Counts.of(source, target).total(Measure.DICE); + } + + /** + * Computes the Dice coefficient {@code 2 |S_r ∩ T_r| / (|S_r| + |T_r|)} of + * each label. + * + * @param source the labeling to evaluate + * @param target the reference labeling + * @return a map from each label to its Dice coefficient + * @param label type + * @param index type of the source labeling + * @param index type of the target labeling + * @implNote op names='labeling.overlap.diceByLabel', type=Function + */ + public static , J extends IntegerType> + Map diceByLabel(final ImgLabeling source, + final ImgLabeling target) + { + return Counts.of(source, target).byLabel(Measure.DICE); + } + + /** + * Computes the total volume similarity, {@code 2 Σ(|S_r| - |T_r|) / + * Σ(|S_r| + |T_r|)}. + * + * @param source the labeling to evaluate + * @param target the reference labeling + * @return the total volume similarity, between -2 and 2 (positive when the + * source labels are larger) + * @param label type + * @param index type of the source labeling + * @param index type of the target labeling + * @implNote op names='labeling.overlap.volumeSimilarity', type=Function + */ + public static , J extends IntegerType> Double + volumeSimilarity(final ImgLabeling source, + final ImgLabeling target) + { + return Counts.of(source, target).total(Measure.VOLUME_SIMILARITY); + } + + /** + * Computes the volume similarity {@code 2 (|S_r| - |T_r|) / (|S_r| + + * |T_r|)} of each label. + * + * @param source the labeling to evaluate + * @param target the reference labeling + * @return a map from each label to its volume similarity + * @param label type + * @param index type of the source labeling + * @param index type of the target labeling + * @implNote op names='labeling.overlap.volumeSimilarityByLabel', type=Function + */ + public static , J extends IntegerType> + Map volumeSimilarityByLabel(final ImgLabeling source, + final ImgLabeling target) + { + return Counts.of(source, target).byLabel(Measure.VOLUME_SIMILARITY); + } + + /** + * Computes the total false negative error, {@code Σ|T_r \ S_r| / Σ|T_r|}. + * + * @param source the labeling to evaluate + * @param target the reference labeling + * @return the total false negative error, between 0 and 1 + * @param label type + * @param index type of the source labeling + * @param index type of the target labeling + * @implNote op names='labeling.overlap.falseNegativeError', type=Function + */ + public static , J extends IntegerType> Double + falseNegativeError(final ImgLabeling source, + final ImgLabeling target) + { + return Counts.of(source, target).total(Measure.FALSE_NEGATIVE); + } + + /** + * Computes the false negative error {@code |T_r \ S_r| / |T_r|} of each + * label. + * + * @param source the labeling to evaluate + * @param target the reference labeling + * @return a map from each label to its false negative error + * @param label type + * @param index type of the source labeling + * @param index type of the target labeling + * @implNote op names='labeling.overlap.falseNegativeErrorByLabel', type=Function + */ + public static , J extends IntegerType> + Map falseNegativeErrorByLabel(final ImgLabeling source, + final ImgLabeling target) + { + return Counts.of(source, target).byLabel(Measure.FALSE_NEGATIVE); + } + + /** + * Computes the total false positive error, {@code Σ|S_r \ T_r| / Σ|S_r|}. + * + * @param source the labeling to evaluate + * @param target the reference labeling + * @return the total false positive error, between 0 and 1 + * @param label type + * @param index type of the source labeling + * @param index type of the target labeling + * @implNote op names='labeling.overlap.falsePositiveError', type=Function + */ + public static , J extends IntegerType> Double + falsePositiveError(final ImgLabeling source, + final ImgLabeling target) + { + return Counts.of(source, target).total(Measure.FALSE_POSITIVE); + } + + /** + * Computes the false positive error {@code |S_r \ T_r| / |S_r|} of each + * label. + * + * @param source the labeling to evaluate + * @param target the reference labeling + * @return a map from each label to its false positive error + * @param label type + * @param index type of the source labeling + * @param index type of the target labeling + * @implNote op names='labeling.overlap.falsePositiveErrorByLabel', type=Function + */ + public static , J extends IntegerType> + Map falsePositiveErrorByLabel(final ImgLabeling source, + final ImgLabeling target) + { + return Counts.of(source, target).byLabel(Measure.FALSE_POSITIVE); + } + + /** An overlap measure, as a ratio of sums of pixel counts. */ + private enum Measure { + TARGET_OVERLAP, JACCARD, DICE, VOLUME_SIMILARITY, FALSE_NEGATIVE, + FALSE_POSITIVE; + + /** + * Gets the numerator, given the source size, target size and + * intersection size. + */ + double numerator(final long s, final long t, final long st) { + switch (this) { + case TARGET_OVERLAP: + case JACCARD: + return st; + case DICE: + return 2.0 * st; + case VOLUME_SIMILARITY: + return 2.0 * (s - t); + case FALSE_NEGATIVE: + return t - st; + default: + return s - st; + } + } + + double denominator(final long s, final long t, final long st) { + switch (this) { + case TARGET_OVERLAP: + case FALSE_NEGATIVE: + return t; + case JACCARD: + return s + t - st; + case DICE: + case VOLUME_SIMILARITY: + return s + t; + default: + return s; + } + } + } + + /** Pixel counts of each label in the source, target and both. */ + private static final class Counts { + + /** Per label: source count, target count, intersection count. */ + private final Map counts = new LinkedHashMap<>(); + + static , J extends IntegerType> Counts + of(final ImgLabeling source, final ImgLabeling target) + { + if (!Arrays.equals(source.dimensionsAsLongArray(), target + .dimensionsAsLongArray())) + { + throw new IllegalArgumentException( + "Labeling dimensions differ: " + Arrays.toString(source + .dimensionsAsLongArray()) + " vs " + Arrays.toString(target + .dimensionsAsLongArray())); + } + // Count pixels per pair of label sets, then expand the sets. + final var pairs = new LinkedHashMap(); + final var s = Views.flatIterable(source.getIndexImg()).cursor(); + final var t = Views.flatIterable(target.getIndexImg()).cursor(); + while (s.hasNext()) { + final long key = ((long) s.next().getInteger() << 32) | t.next() + .getInteger(); + pairs.computeIfAbsent(key, k -> new long[1])[0]++; + } + final var sm = source.getMapping(); + final var tm = target.getMapping(); + final var result = new Counts(); + for (final var entry : pairs.entrySet()) { + final var n = entry.getValue()[0]; + final var sLabels = sm.labelsAtIndex((int) (entry.getKey() >>> 32)); + final var tLabels = tm.labelsAtIndex((int) (entry + .getKey() & 0xffffffffL)); + for (final var label : sLabels) { + final var c = result.counts(label); + c[0] += n; + if (tLabels.contains(label)) c[2] += n; + } + for (final var label : tLabels) + result.counts(label)[1] += n; + } + return result; + } + + private long[] counts(final L label) { + return counts.computeIfAbsent(label, k -> new long[3]); + } + + double total(final Measure measure) { + double num = 0, den = 0; + for (final var c : counts.values()) { + num += measure.numerator(c[0], c[1], c[2]); + den += measure.denominator(c[0], c[1], c[2]); + } + return num / den; + } + + Map byLabel(final Measure measure) { + final var result = new LinkedHashMap(); + for (final var entry : counts.entrySet()) { + final var c = entry.getValue(); + result.put(entry.getKey(), measure.numerator(c[0], c[1], c[2]) / + measure.denominator(c[0], c[1], c[2])); + } + return result; + } + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/labeling/shapeBasedAverage/ShapeBasedAverage.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/labeling/shapeBasedAverage/ShapeBasedAverage.java new file mode 100644 index 000000000..f410213ad --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/labeling/shapeBasedAverage/ShapeBasedAverage.java @@ -0,0 +1,164 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.labeling.shapeBasedAverage; + +import java.util.Arrays; +import java.util.List; +import java.util.TreeSet; +import java.util.function.BiFunction; + +import net.imglib2.Dimensions; +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.converter.Converters; +import net.imglib2.type.logic.BitType; +import net.imglib2.type.numeric.IntegerType; +import net.imglib2.type.numeric.real.FloatType; +import net.imglib2.util.Intervals; +import net.imglib2.view.Views; + +import org.scijava.ops.spi.Nullable; +import org.scijava.ops.spi.OpDependency; + +/** + * Combines several label images of the same scene into one by shape-based + * averaging (Rohlfing & Maurer 2007), e.g. for atlas-based label fusion. + *

+ * For each input {@code k} and label {@code l}, the signed Euclidean + * distance map {@code d_kl} to the boundary of the region of {@code l} is + * computed, positive outside the region and negative inside it: this is the + * negated {@code image.signedDistanceTransform} of the region. At a pixel + * outside the region, it is the distance to the nearest pixel of {@code l} + * minus half a pixel; at a pixel inside it, minus the distance to the nearest + * pixel with another label, plus half a pixel. The boundary thus lies halfway + * between pixel centers, and pixels next to it get {@code ±0.5}. + * Every output pixel is assigned the label with the smallest mean signed + * distance {@code (1/K) Σ_k d_kl}, i.e. the label whose averaged shape it + * lies deepest inside. Unlike voting, this yields smooth, averaged shapes: + * averaging two shifted copies of a disk gives a disk at the midpoint. + *

+ *

+ * Every pixel value is a label, including 0 (the background). Where a label + * is absent from an input (or covers all of it), its distance in that input + * is taken to be (minus) the length of the image diagonal, less half a pixel. + * Ties are resolved in favor of the smallest label value. Distances are + * measured in calibrated units (pixels by default). + *

+ *

+ * Note: Before the half-pixel offset was introduced (to share + * {@code image.signedDistanceTransform}), distances were measured between + * pixel centers, i.e. {@code d_kl} was {@code ±0.5} larger in magnitude. The + * two conventions can disagree only where the mean signed distances of two + * labels differ by less than a pixel. + *

+ *

+ * Implemented from the paper. + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @param label pixel type + * @see T. Rohlfing and C. R. + * Maurer, Jr., "Shape-based averaging", IEEE Trans. Image Process. + * 16(1):153-161, 2007 + * @implNote op names='labeling.shapeBasedAverage' + */ +public class ShapeBasedAverage> implements + BiFunction>, double[], RandomAccessibleInterval> +{ + + @OpDependency(name = "create.img") + private BiFunction> createOp; + + @OpDependency(name = "image.signedDistanceTransform") + private BiFunction, double[], RandomAccessibleInterval> sdtOp; + + /** + * Computes the shape-based average. + * + * @param inputs the label images, all with the same dimensions + * @param calibration the pixel spacing along each dimension (default 1) + * @return the averaged label image + */ + @Override + public RandomAccessibleInterval apply( + final List> inputs, + @Nullable final double[] calibration) + { + if (inputs.isEmpty()) { + throw new IllegalArgumentException("No label images given"); + } + final var first = inputs.get(0); + for (final var in : inputs) { + if (!Intervals.equalDimensions(first, in)) { + throw new IllegalArgumentException( + "All label images must have the same dimensions"); + } + } + final var size = Intervals.numElements(first); + if (size > Integer.MAX_VALUE) { + throw new IllegalArgumentException("Image is too large: " + size + + " pixels"); + } + final var count = (int) size; + final var all = new TreeSet(); + for (final var in : inputs) + for (final var t : Views.flatIterable(in)) + all.add(t.getIntegerLong()); + + final var best = new long[count]; + final var bestDist = new double[count]; + Arrays.fill(bestDist, Double.POSITIVE_INFINITY); + final var sum = new double[count]; + for (final long label : all) { + Arrays.fill(sum, 0); + for (final var in : inputs) { + final RandomAccessibleInterval mask = Converters.convert(in, ( + a, b) -> b.set(a.getIntegerLong() == label), new BitType()); + var p = 0; + for (final var t : Views.flatIterable(sdtOp.apply(mask, calibration))) + sum[p++] -= t.getRealDouble(); + } + // Note: The sum orders labels like the mean, as K is constant. + for (var p = 0; p < count; p++) { + if (sum[p] < bestDist[p]) { + bestDist[p] = sum[p]; + best[p] = label; + } + } + } + + final var output = createOp.apply(first, first.getType() + .createVariable()); + var p = 0; + for (final var t : Views.flatIterable(output)) + t.setInteger(best[p++]); + return output; + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/labeling/utils/LabelEditing.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/labeling/utils/LabelEditing.java new file mode 100644 index 000000000..4d0916160 --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/labeling/utils/LabelEditing.java @@ -0,0 +1,363 @@ +/* #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.labeling.utils; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +import net.imglib2.FinalInterval; +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.algorithm.neighborhood.Shape; +import net.imglib2.img.array.ArrayImgs; +import net.imglib2.loops.LoopBuilder; +import net.imglib2.roi.labeling.ImgLabeling; +import net.imglib2.type.logic.BitType; +import net.imglib2.type.numeric.IntegerType; +import net.imglib2.type.numeric.integer.IntType; +import net.imglib2.util.Intervals; +import net.imglib2.view.Views; + +import org.scijava.ops.spi.Nullable; + +/** + * Ops rewriting the labels or label regions of an {@link ImgLabeling}. + *

+ * Each op returns a new labeling (or image); the input is not modified. + * Labelings may have overlapping labels, i.e. pixels with several labels; how + * each op treats them is described in its documentation. + *

+ *

+ * These are clean-room implementations of common label-map utilities, as + * offered by the {@code LabelImages} class and label plugins of MorphoLibJ + * (D. Legland, I. Arganda-Carreras and P. Andrey, "MorphoLibJ: integrated + * library and plugins for mathematical morphology with ImageJ", + * Bioinformatics 32(22): 3532–3534, 2016), generalized to + * n-dimensional labelings with arbitrary, possibly overlapping labels. + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +public final class LabelEditing { + + private LabelEditing() { + // Prevent instantiation of static utility class. + } + + /** + * Replaces the labels by consecutive integers. If the labels are mutually + * comparable (such as numbers or strings), they are numbered in their + * natural order; otherwise they are numbered in order of their first + * occurrence in a raster scan of the labeling. Overlaps are preserved. + * + * @param labeling the labeled objects + * @param start the first label (default 1) + * @return a new labeling with the labels {@code start, start + 1, ...} + * @param label type + * @param index type of the labeling + * @implNote op names='labeling.relabel', type=Function + */ + public static > ImgLabeling relabel( + final ImgLabeling labeling, @Nullable Integer start) + { + final int first = start == null ? 1 : start; + final var mapping = labeling.getMapping(); + final var seen = new boolean[mapping.numSets()]; + final var present = new LinkedHashSet(); + for (final var index : Views.flatIterable(labeling.getIndexImg())) { + final var i = index.getInteger(); + if (seen[i]) continue; + seen[i] = true; + present.addAll(mapping.labelsAtIndex(i)); + } + final var newLabels = new HashMap(); + var next = first; + for (final var label : Labelings.order(present)) + newLabels.put(label, next++); + return Labelings.mapLabelSets(labeling, set -> { + final var result = new HashSet(); + for (final var label : set) { + final var newLabel = newLabels.get(label); + if (newLabel != null) result.add(newLabel); + } + return result; + }); + } + + /** + * Merges several labels into one: every pixel carrying any of the given + * labels carries the target label instead. Other labels of those pixels + * are kept. + * + * @param labeling the labeled objects + * @param labels the labels to merge + * @param target the label of the merged region (default: the smallest of + * the given labels if they are mutually comparable, otherwise the + * first one) + * @return a new labeling with the labels merged + * @param label type + * @param index type of the labeling + * @implNote op names='labeling.mergeLabels', type=Function + */ + public static > ImgLabeling mergeLabels( + final ImgLabeling labeling, final Collection labels, + @Nullable L target) + { + if (labels.isEmpty()) return Labelings.mapLabelSets(labeling, s -> s); + final L merged = target == null ? Labelings.order(labels).get(0) : target; + final var toMerge = new HashSet<>(labels); + return Labelings.mapLabelSets(labeling, set -> { + final var result = new HashSet(); + var hit = false; + for (final var label : set) { + if (toMerge.contains(label)) hit = true; + else result.add(label); + } + if (hit) result.add(merged); + return result; + }); + } + + /** + * Fills the holes of the labeled regions. A hole is a connected component + * of unlabeled pixels that does not touch the border of the labeling + * interval. It is filled with the labels shared by all labeled pixels + * adjacent to it, i.e. the labels enclosing it; a hole between several + * regions that do not share a label is left unlabeled. + *

+ * The connectivity applies to the background: it defines the connected + * components of unlabeled pixels and their adjacency to labeled pixels, as + * the connectivity of {@code morphology.fillHoles} does. With the default + * full connectivity, a hole leaks out through a diagonal gap in its + * enclosing region and is not filled. For regions that are only connected + * diagonally (such as a thin outline drawn with diagonal steps), use face + * connectivity, the dual of their full connectivity. + *

+ * + * @param labeling the labeled objects + * @param connectivity the connectivity of the unlabeled components and of + * their adjacency to labeled pixels, such as a + * {@code DiamondShape} of radius 1 for face connectivity + * (4-connectivity in 2D, 6-connectivity in 3D) or a + * {@code RectangleShape} of span 1 for full connectivity + * (8-connectivity in 2D, 26-connectivity in 3D). Defaults to full + * connectivity. + * @return a new labeling with the holes filled + * @param label type + * @param index type of the labeling + * @implNote op names='labeling.fillHoles', type=Function + */ + public static > ImgLabeling fillHoles( + final ImgLabeling labeling, @Nullable Shape connectivity) + { + final var mapping = labeling.getMapping(); + final var indexImg = labeling.getIndexImg(); + final var n = labeling.numDimensions(); + final var empty = new boolean[mapping.numSets()]; + for (var i = 0; i < empty.length; i++) + empty[i] = mapping.labelsAtIndex(i).isEmpty(); + + final var offsets = Labelings.offsets(n, connectivity, false); + final var dims = indexImg.dimensionsAsLongArray(); + final var size = Intervals.numElements(indexImg); + if (size > Integer.MAX_VALUE) { + throw new IllegalArgumentException("Labeling is too large: " + size + + " pixels"); + } + final var background = new boolean[(int) size]; + var p = 0; + for (final var t : Views.flatIterable(indexImg)) + background[p++] = empty[t.getInteger()]; + final var ids = new int[(int) size]; + final var count = labelComponents(background, dims, offsets, ids); + final RandomAccessibleInterval components = Views.translate( + ArrayImgs.ints(ids, dims), indexImg.minAsLongArray()); + + // The labels enclosing each component, or null if none are known yet. + final var enclosing = new ArrayList>(); + final var touchesBorder = new boolean[count + 1]; + for (var c = 0; c <= count; c++) + enclosing.add(null); + final var ra = indexImg.randomAccess(); + final var position = new long[n]; + final var neighbor = new long[n]; + final var cursor = Views.flatIterable(components).localizingCursor(); + while (cursor.hasNext()) { + final var c = cursor.next().get(); + if (c == 0 || touchesBorder[c]) continue; + cursor.localize(position); + if (Labelings.onBorder(position, indexImg)) { + touchesBorder[c] = true; + continue; + } + for (final var offset : offsets) { + if (!Labelings.shift(position, offset, indexImg, neighbor)) continue; + final var labels = mapping.labelsAtIndex(ra.setPositionAndGet(neighbor) + .getInteger()); + if (labels.isEmpty()) continue; + final var known = enclosing.get(c); + if (known == null) enclosing.set(c, new HashSet<>(labels)); + else known.retainAll(labels); + } + } + + final var table = new Labelings.SetTable(); + final var lut = new int[mapping.numSets()]; + for (var i = 0; i < lut.length; i++) + lut[i] = table.indexOf(mapping.labelsAtIndex(i)); + final var fill = new int[count + 1]; + for (var c = 1; c <= count; c++) { + final var labels = enclosing.get(c); + if (!touchesBorder[c] && labels != null) fill[c] = table.indexOf(labels); + } + final var output = Labelings.createIndexImg(indexImg); + LoopBuilder.setImages(indexImg, components, output).forEachPixel((in, c, + out) -> { + final var f = fill[c.get()]; + out.setInteger(f != 0 ? f : lut[in.getInteger()]); + }); + return ImgLabeling.fromImageAndLabelSets(output, table.sets()); + } + + /** + * Labels the connected components of the true pixels of a flat mask, with + * dimension 0 varying fastest, numbering them from 1 in raster order. + * + * @return the number of components + */ + private static int labelComponents(final boolean[] mask, final long[] dims, + final List offsets, final int[] labels) + { + final var n = dims.length; + final var strides = new long[n]; + var stride = 1L; + for (var d = 0; d < n; d++) { + strides[d] = stride; + stride *= dims[d]; + } + final var position = new long[n]; + final var queue = new int[mask.length]; + var count = 0; + for (var start = 0; start < mask.length; start++) { + if (!mask[start] || labels[start] != 0) continue; + labels[start] = ++count; + var head = 0; + var tail = 0; + queue[tail++] = start; + while (head < tail) { + final var p = queue[head++]; + var rest = (long) p; + for (var d = 0; d < n; d++) { + position[d] = rest % dims[d]; + rest /= dims[d]; + } + for (final var offset : offsets) { + var q = 0L; + var inside = true; + for (var d = 0; d < n; d++) { + final var c = position[d] + offset[d]; + if (c < 0 || c >= dims[d]) { + inside = false; + break; + } + q += c * strides[d]; + } + if (!inside || !mask[(int) q] || labels[(int) q] != 0) continue; + labels[(int) q] = count; + queue[tail++] = (int) q; + } + } + } + return count; + } + + /** + * Crops a binary mask of one label to its bounding box, optionally padded + * by a border. The mask keeps the coordinates of the labeling, i.e. its + * minimum is the minimum of the bounding box minus the border; use + * {@code Views.zeroMin} for a zero-based image. Pixels of the padding that + * lie outside the labeling are false. + * + * @param labeling the labeled objects + * @param label the label to crop + * @param border the number of pixels to pad on each side (default 0) + * @return the mask of the label within its (padded) bounding box + * @param label type + * @param index type of the labeling + * @implNote op names='labeling.cropLabel', type=Function + */ + public static > RandomAccessibleInterval + cropLabel(final ImgLabeling labeling, final L label, + @Nullable Long border) + { + final long pad = border == null ? 0 : border; + if (pad < 0) throw new IllegalArgumentException("Negative border: " + + pad); + final var mapping = labeling.getMapping(); + final var hasLabel = new boolean[mapping.numSets()]; + for (var i = 0; i < hasLabel.length; i++) + hasLabel[i] = mapping.labelsAtIndex(i).contains(label); + + final var n = labeling.numDimensions(); + final var min = new long[n]; + final var max = new long[n]; + Arrays.fill(min, Long.MAX_VALUE); + Arrays.fill(max, Long.MIN_VALUE); + final var cursor = labeling.getIndexImg().localizingCursor(); + var found = false; + while (cursor.hasNext()) { + if (!hasLabel[cursor.next().getInteger()]) continue; + found = true; + for (var d = 0; d < n; d++) { + final var p = cursor.getLongPosition(d); + min[d] = Math.min(min[d], p); + max[d] = Math.max(max[d], p); + } + } + if (!found) throw new IllegalArgumentException("No such label: " + label); + for (var d = 0; d < n; d++) { + min[d] -= pad; + max[d] += pad; + } + final var box = new FinalInterval(min, max); + final RandomAccessibleInterval mask = Views.translate(ArrayImgs + .bits(box.dimensionsAsLongArray()), min); + final var overlap = Intervals.intersect(box, labeling); + LoopBuilder.setImages(Views.interval(labeling.getIndexImg(), overlap), + Views.interval(mask, overlap)).forEachPixel((index, m) -> m.set( + hasLabel[index.getInteger()])); + return mask; + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/labeling/utils/LabelFiltering.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/labeling/utils/LabelFiltering.java new file mode 100644 index 000000000..61b7238df --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/labeling/utils/LabelFiltering.java @@ -0,0 +1,311 @@ +/* #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.labeling.utils; + +import java.util.Arrays; +import java.util.Collection; +import java.util.HashSet; +import java.util.Map; +import java.util.function.Predicate; + +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.loops.LoopBuilder; +import net.imglib2.roi.labeling.ImgLabeling; +import net.imglib2.type.BooleanType; +import net.imglib2.type.numeric.IntegerType; +import net.imglib2.view.Views; + +import org.scijava.ops.spi.Nullable; + +/** + * Ops selecting a subset of the labels of an {@link ImgLabeling}. + *

+ * Each op returns a new labeling over a new index image of the same type and + * interval, containing only the selected labels; the input is not modified. + * Labelings may have overlapping labels: each label is selected or rejected + * on its own, and a pixel keeps exactly those of its labels that are + * selected. Sizes are pixel counts, and a pixel with several labels counts + * towards each of them. + *

+ *

+ * These are clean-room implementations of common label-map utilities, as + * offered by the {@code LabelImages} class of MorphoLibJ (D. Legland, I. + * Arganda-Carreras and P. Andrey, "MorphoLibJ: integrated library and plugins + * for mathematical morphology with ImageJ", Bioinformatics 32(22): + * 3532–3534, 2016), the edge exclusion and size filter of the 3D + * Objects Counter (S. Bolte and F. P. Cordelières, "A guided tour into + * subcellular colocalization analysis in light microscopy", Journal of + * Microscopy 224(3): 213–232, 2006), and the Binary Feature + * Extractor of the BioVoxxel Toolbox (J. Brocher), generalized to + * n-dimensional labelings with arbitrary, possibly overlapping labels. + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +public final class LabelFiltering { + + private LabelFiltering() { + // Prevent instantiation of static utility class. + } + + /** + * Keeps the labels accepted by a predicate. + * + * @param labeling the labeled objects + * @param predicate the test deciding which labels to keep + * @return a new labeling with only the accepted labels + * @param label type + * @param index type of the labeling + * @implNote op names='labeling.filter', type=Function + */ + public static > ImgLabeling filter( + final ImgLabeling labeling, final Predicate predicate) + { + return Labelings.retain(labeling, predicate); + } + + /** + * Keeps the labels whose value (such as a shape descriptor, size or mean + * intensity) lies within a range. Labels without a value, or with a NaN + * value, are removed. + * + * @param labeling the labeled objects + * @param values the value of each label + * @param min the minimum value (inclusive) to keep (default: no minimum) + * @param max the maximum value (inclusive) to keep (default: no maximum) + * @return a new labeling with only the labels in range + * @param label type + * @param index type of the labeling + * @param value type + * @implNote op names='labeling.filterByValue', type=Function + */ + public static , N extends Number> + ImgLabeling filterByValue(final ImgLabeling labeling, + final Map values, @Nullable Double min, @Nullable Double max) + { + final double lo = min == null ? Double.NEGATIVE_INFINITY : min; + final double hi = max == null ? Double.POSITIVE_INFINITY : max; + return Labelings.retain(labeling, label -> { + final var value = values.get(label); + if (value == null) return false; + final var v = value.doubleValue(); + return v >= lo && v <= hi; + }); + } + + /** + * Keeps the labels whose pixel count lies within a range. Use a minimum + * only for a size (area or volume) opening. + * + * @param labeling the labeled objects + * @param minSize the minimum pixel count (inclusive) to keep (default: no + * minimum) + * @param maxSize the maximum pixel count (inclusive) to keep (default: no + * maximum) + * @return a new labeling with only the labels in the size range + * @param label type + * @param index type of the labeling + * @implNote op names='labeling.filterBySize', type=Function + */ + public static > ImgLabeling filterBySize( + final ImgLabeling labeling, @Nullable Long minSize, + @Nullable Long maxSize) + { + final long lo = minSize == null ? 0 : minSize; + final long hi = maxSize == null ? Long.MAX_VALUE : maxSize; + final var sizes = Labelings.sizes(labeling); + return Labelings.retain(labeling, label -> { + final var size = sizes.get(label); + return size != null && size >= lo && size <= hi; + }); + } + + /** + * Keeps only the label with the largest pixel count. If several labels + * share the largest pixel count, all of them are kept. + * + * @param labeling the labeled objects + * @return a new labeling with only the largest label + * @param label type + * @param index type of the labeling + * @implNote op names='labeling.keepLargest', type=Function + */ + public static > ImgLabeling keepLargest( + final ImgLabeling labeling) + { + final var sizes = Labelings.sizes(labeling); + final var largest = largestSize(sizes); + return Labelings.retain(labeling, label -> sizes.getOrDefault(label, + -1L) == largest); + } + + /** + * Removes the label with the largest pixel count, such as a background + * region. If several labels share the largest pixel count, all of them are + * removed. + * + * @param labeling the labeled objects + * @return a new labeling without the largest label + * @param label type + * @param index type of the labeling + * @implNote op names='labeling.removeLargest', type=Function + */ + public static > ImgLabeling removeLargest( + final ImgLabeling labeling) + { + final var sizes = Labelings.sizes(labeling); + final var largest = largestSize(sizes); + return Labelings.retain(labeling, label -> sizes.getOrDefault(label, + -1L) != largest); + } + + /** + * Keeps the given labels and removes all others. + * + * @param labeling the labeled objects + * @param labels the labels to keep + * @return a new labeling with only the given labels + * @param label type + * @param index type of the labeling + * @implNote op names='labeling.keepLabels', type=Function + */ + public static > ImgLabeling keepLabels( + final ImgLabeling labeling, final Collection labels) + { + final var set = new HashSet<>(labels); + return Labelings.retain(labeling, set::contains); + } + + /** + * Removes the given labels and keeps all others. + * + * @param labeling the labeled objects + * @param labels the labels to remove + * @return a new labeling without the given labels + * @param label type + * @param index type of the labeling + * @implNote op names='labeling.removeLabels', type=Function + */ + public static > ImgLabeling removeLabels( + final ImgLabeling labeling, final Collection labels) + { + final var set = new HashSet<>(labels); + return Labelings.retain(labeling, label -> !set.contains(label)); + } + + /** + * Removes the labels touching the border of the labeling interval, such as + * objects only partially inside the field of view. + * + * @param labeling the labeled objects + * @param borders which borders to consider: either one flag per dimension + * (both the minimum and maximum border of that dimension), or two + * flags per dimension in the order {@code min0, max0, min1, max1, + * ...} (default: all borders) + * @return a new labeling without the labels touching the chosen borders + * @param label type + * @param index type of the labeling + * @implNote op names='labeling.removeBorderLabels', type=Function + */ + public static > ImgLabeling + removeBorderLabels(final ImgLabeling labeling, + @Nullable boolean[] borders) + { + final var n = labeling.numDimensions(); + final var sides = new boolean[2 * n]; + if (borders == null) Arrays.fill(sides, true); + else if (borders.length == n) { + for (var d = 0; d < n; d++) + sides[2 * d] = sides[2 * d + 1] = borders[d]; + } + else if (borders.length == 2 * n) System.arraycopy(borders, 0, sides, 0, + 2 * n); + else throw new IllegalArgumentException("Expected " + n + " or " + 2 * n + + " border flags, but got " + borders.length); + + final var indexImg = labeling.getIndexImg(); + final var touching = new long[labeling.getMapping().numSets()]; + for (var d = 0; d < n; d++) { + for (var side = 0; side < 2; side++) { + if (!sides[2 * d + side]) continue; + final var pos = side == 0 ? indexImg.min(d) : indexImg.max(d); + final var face = n == 1 ? Views.interval(indexImg, new long[] { pos }, + new long[] { pos }) : Views.hyperSlice(indexImg, d, pos); + for (final var index : Views.iterable(face)) + touching[index.getInteger()] = 1; + } + } + final var border = Labelings.sumPerLabel(labeling, touching).keySet(); + return Labelings.retain(labeling, label -> !border.contains(label)); + } + + /** + * Keeps the labels that overlap a mask, such as objects containing a + * marker. The overlap of a label is the fraction of its pixels inside the + * mask. + * + * @param labeling the labeled objects + * @param mask the selecting mask, with the same interval as the labeling + * @param minOverlap the minimum overlap fraction (between 0 and 1, + * inclusive) to keep a label (default: keep each label with at + * least one pixel inside the mask) + * @return a new labeling with only the overlapping labels + * @param label type + * @param index type of the labeling + * @param mask type + * @implNote op names='labeling.selectByOverlap', type=Function + */ + public static , B extends BooleanType> + ImgLabeling selectByOverlap(final ImgLabeling labeling, + final RandomAccessibleInterval mask, @Nullable Double minOverlap) + { + final var numSets = labeling.getMapping().numSets(); + final var inside = new long[numSets]; + LoopBuilder.setImages(labeling.getIndexImg(), mask).forEachPixel((index, + m) -> { + if (m.get()) inside[index.getInteger()]++; + }); + final var insideSizes = Labelings.sumPerLabel(labeling, inside); + final var sizes = Labelings.sizes(labeling); + return Labelings.retain(labeling, label -> { + final long in = insideSizes.getOrDefault(label, 0L); + if (in == 0) return false; + return minOverlap == null || (double) in / sizes.get( + label) >= minOverlap; + }); + } + + private static long largestSize(final Map sizes) { + var largest = -1L; + for (final var size : sizes.values()) + largest = Math.max(largest, size); + return largest; + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/labeling/utils/LabelTopology.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/labeling/utils/LabelTopology.java new file mode 100644 index 000000000..f260607ea --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/labeling/utils/LabelTopology.java @@ -0,0 +1,210 @@ +/* #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.labeling.utils; + +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Set; + +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.algorithm.neighborhood.Shape; +import net.imglib2.img.array.ArrayImgs; +import net.imglib2.roi.labeling.ImgLabeling; +import net.imglib2.type.logic.BitType; +import net.imglib2.type.numeric.IntegerType; +import net.imglib2.view.Views; + +import org.scijava.ops.spi.Nullable; + +/** + * Ops analyzing the spatial relations between the labels of an + * {@link ImgLabeling}: their boundaries and their adjacency. + *

+ * Neighborhoods are given as a connectivity {@link Shape}: a + * {@code DiamondShape} of radius 1 for face neighbors only (4 in 2D, 6 in 3D) + * or a {@code RectangleShape} of span 1 for all neighbors sharing a face, edge + * or corner (8 in 2D, 26 in 3D), which is the default. Neighbors outside the + * labeling interval are ignored. + *

+ *

+ * These are clean-room implementations of the label boundaries and region + * adjacency graph of MorphoLibJ (D. Legland, I. Arganda-Carreras and P. + * Andrey, "MorphoLibJ: integrated library and plugins for mathematical + * morphology with ImageJ", Bioinformatics 32(22): 3532–3534, + * 2016), generalized to n-dimensional labelings with arbitrary, possibly + * overlapping labels. Results differ from MorphoLibJ: its label boundaries + * mark only the pixel before each change along each axis (which may be a + * background pixel), whereas {@code labeling.boundaries} marks the labeled + * pixels on both sides; and its region adjacency graph pairs labels two + * pixels apart along an axis (i.e. separated by a one-pixel watershed line), + * whereas {@code labeling.adjacency} pairs labels that touch, so dilate a + * labeling with watershed lines first (e.g. with {@code labeling.dilate}). + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +public final class LabelTopology { + + private LabelTopology() { + // Prevent instantiation of static utility class. + } + + /** + * Computes the boundaries of the labeled regions as a binary image. A pixel + * is a boundary pixel if it carries at least one label and has a neighbor + * with a different set of labels (another label, no label, or a different + * overlap). Between two touching regions, the boundary pixels of both + * regions are marked. + * + * @param labeling the labeled objects + * @param connectivity the neighborhood defining which pixels are adjacent, + * such as a {@code DiamondShape} of radius 1 for face connectivity + * (4-connectivity in 2D, 6-connectivity in 3D) or a + * {@code RectangleShape} of span 1 for full connectivity + * (8-connectivity in 2D, 26-connectivity in 3D). Defaults to full + * connectivity. + * @return a binary image of the boundary pixels, with the interval of the + * labeling + * @param label type + * @param index type of the labeling + * @implNote op names='labeling.boundaries', type=Function + */ + public static > RandomAccessibleInterval + boundaries(final ImgLabeling labeling, + @Nullable Shape connectivity) + { + final var indexImg = labeling.getIndexImg(); + final var mapping = labeling.getMapping(); + final var n = labeling.numDimensions(); + final var empty = new boolean[mapping.numSets()]; + for (var i = 0; i < empty.length; i++) + empty[i] = mapping.labelsAtIndex(i).isEmpty(); + + final RandomAccessibleInterval output = Views.translate(ArrayImgs + .bits(indexImg.dimensionsAsLongArray()), indexImg.minAsLongArray()); + final var out = output.randomAccess(); + final var ra = indexImg.randomAccess(); + final var offsets = Labelings.offsets(n, connectivity, false); + final var position = new long[n]; + final var neighbor = new long[n]; + final var cursor = Views.flatIterable(indexImg).localizingCursor(); + while (cursor.hasNext()) { + final var index = cursor.next().getInteger(); + if (empty[index]) continue; + cursor.localize(position); + for (final var offset : offsets) { + if (!Labelings.shift(position, offset, indexImg, neighbor)) continue; + if (ra.setPositionAndGet(neighbor).getInteger() != index) { + out.setPositionAndGet(position).set(true); + break; + } + } + } + return output; + } + + /** + * Computes the region adjacency graph of the labeling. Two labels are + * adjacent if a pixel of one is a neighbor of a pixel of the other, or if + * they overlap (share a pixel). + * + * @param labeling the labeled objects + * @param connectivity the neighborhood defining which pixels are adjacent, + * such as a {@code DiamondShape} of radius 1 for face connectivity + * (4-connectivity in 2D, 6-connectivity in 3D) or a + * {@code RectangleShape} of span 1 for full connectivity + * (8-connectivity in 2D, 26-connectivity in 3D). Defaults to full + * connectivity. + * @return a map from each label present in the labeling to the set of its + * adjacent labels (empty if it has none); the relation is + * symmetric and never contains a label itself + * @param label type + * @param index type of the labeling + * @implNote op names='labeling.adjacency', type=Function + */ + public static > Map> adjacency( + final ImgLabeling labeling, + @Nullable Shape connectivity) + { + final var indexImg = labeling.getIndexImg(); + final var mapping = labeling.getMapping(); + final var n = labeling.numDimensions(); + final long numSets = mapping.numSets(); + + final var used = new boolean[(int) numSets]; + final var pairs = new HashSet(); + final var ra = indexImg.randomAccess(); + final var offsets = Labelings.offsets(n, connectivity, true); + final var position = new long[n]; + final var neighbor = new long[n]; + final var cursor = Views.flatIterable(indexImg).localizingCursor(); + while (cursor.hasNext()) { + final var index = cursor.next().getInteger(); + used[index] = true; + cursor.localize(position); + for (final var offset : offsets) { + if (!Labelings.shift(position, offset, indexImg, neighbor)) continue; + final var other = ra.setPositionAndGet(neighbor).getInteger(); + if (other == index) continue; + final long lo = Math.min(index, other); + final long hi = Math.max(index, other); + pairs.add(lo * numSets + hi); + } + } + + final var graph = new LinkedHashMap>(); + for (var i = 0; i < used.length; i++) { + if (!used[i]) continue; + final var labels = mapping.labelsAtIndex(i); + for (final var label : labels) + graph.computeIfAbsent(label, k -> new LinkedHashSet<>()); + connect(graph, labels, labels); + } + for (final long pair : pairs) { + connect(graph, mapping.labelsAtIndex((int) (pair / numSets)), mapping + .labelsAtIndex((int) (pair % numSets))); + } + return graph; + } + + private static void connect(final Map> graph, + final Set labels1, final Set labels2) + { + for (final var a : labels1) { + for (final var b : labels2) { + if (a.equals(b)) continue; + graph.get(a).add(b); + graph.get(b).add(a); + } + } + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/labeling/utils/Labelings.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/labeling/utils/Labelings.java new file mode 100644 index 000000000..0c13c5ac9 --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/labeling/utils/Labelings.java @@ -0,0 +1,242 @@ +/* #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.labeling.utils; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Function; +import java.util.function.Predicate; + +import net.imglib2.Interval; +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.algorithm.neighborhood.Shape; +import net.imglib2.img.Img; +import net.imglib2.loops.LoopBuilder; +import net.imglib2.roi.labeling.ImgLabeling; +import net.imglib2.type.numeric.IntegerType; +import net.imglib2.util.Util; +import net.imglib2.view.Views; + +import org.scijava.ops.image.util.Neighborhoods; + +/** + * Internal helpers shared by the label-map utility ops. + *

+ * Most of the ops only rewrite the label sets of an {@link ImgLabeling}: they + * compute the new label set of each index of the labeling's mapping once and + * then remap the index image, which is linear in the number of pixels. + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +final class Labelings { + + private Labelings() { + // Prevent instantiation of static utility class. + } + + /** + * Interns label sets, assigning each distinct set a consecutive index, with + * the empty set at index 0. + */ + static final class SetTable { + + private final List> sets = new ArrayList<>(); + private final Map, Integer> indices = new HashMap<>(); + + SetTable() { + indexOf(Collections.emptySet()); + } + + int indexOf(final Set set) { + final var existing = indices.get(set); + if (existing != null) return existing; + final Set copy = Collections.unmodifiableSet(new HashSet<>(set)); + sets.add(copy); + indices.put(copy, sets.size() - 1); + return sets.size() - 1; + } + + List> sets() { + return sets; + } + } + + /** Creates a zeroed image with the type and interval of the given one. */ + static > RandomAccessibleInterval createIndexImg( + final RandomAccessibleInterval like) + { + final var type = like.getType().createVariable(); + final Img img = Util.getSuitableImgFactory(like, type).create(like); + return Views.isZeroMin(like) ? img : Views.translate(img, like + .minAsLongArray()); + } + + /** + * Creates a new labeling by applying the given function to each label set + * of the given labeling. + */ + static > ImgLabeling mapLabelSets( + final ImgLabeling labeling, final Function, Set> f) + { + final var mapping = labeling.getMapping(); + final var table = new SetTable(); + final var lut = new int[mapping.numSets()]; + for (var i = 0; i < lut.length; i++) + lut[i] = table.indexOf(f.apply(mapping.labelsAtIndex(i))); + final var indexImg = createIndexImg(labeling.getIndexImg()); + LoopBuilder.setImages(labeling.getIndexImg(), indexImg).forEachPixel((in, + out) -> out.setInteger(lut[in.getInteger()])); + return ImgLabeling.fromImageAndLabelSets(indexImg, table.sets()); + } + + /** + * Creates a new labeling containing only the labels accepted by the given + * predicate, which is evaluated once per label. + */ + static > ImgLabeling retain( + final ImgLabeling labeling, final Predicate keep) + { + final var decisions = new HashMap(); + return mapLabelSets(labeling, set -> { + final var result = new HashSet(); + for (final var label : set) { + if (decisions.computeIfAbsent(label, keep::test)) result.add(label); + } + return result; + }); + } + + /** Counts the pixels of each index of the labeling's index image. */ + static long[] indexCounts(final ImgLabeling labeling) { + final var counts = new long[labeling.getMapping().numSets()]; + for (final var index : labeling.getIndexImg()) + counts[index.getInteger()]++; + return counts; + } + + /** + * Computes the pixel count of each label present in the labeling, in + * order of the labeling's mapping. A pixel with several labels counts + * towards each of them. + */ + static Map sizes(final ImgLabeling labeling) { + return sumPerLabel(labeling, indexCounts(labeling)); + } + + /** Sums per-index values into per-label values, skipping zero entries. */ + static Map sumPerLabel(final ImgLabeling labeling, + final long[] perIndex) + { + final var mapping = labeling.getMapping(); + final var result = new LinkedHashMap(); + for (var i = 0; i < perIndex.length; i++) { + if (perIndex[i] == 0) continue; + for (final var label : mapping.labelsAtIndex(i)) + result.merge(label, perIndex[i], Long::sum); + } + return result; + } + + /** + * Orders labels by their natural ordering if they are mutually comparable, + * otherwise keeps the given order. + */ + @SuppressWarnings({ "rawtypes", "unchecked" }) + static List order(final Collection labels) { + final var list = new ArrayList(new LinkedHashSet<>(labels)); + try { + Collections.sort((List) list); + } + catch (final ClassCastException exc) { + // Note: Labels without a natural ordering keep the given order. + return new ArrayList<>(new LinkedHashSet<>(labels)); + } + return list; + } + + /** + * Gets the neighbor offsets of a connectivity in {@code n} dimensions, + * without the origin (see {@link Neighborhoods#rasterOffsets}). If + * {@code forwardOnly} is set, only the offsets whose first nonzero + * coordinate is positive are returned, so that each pair of neighboring + * pixels is visited once. + * + * @param connectivity the connectivity, or null for full connectivity + */ + static List offsets(final int n, final Shape connectivity, + final boolean forwardOnly) + { + final var offsets = new ArrayList(); + for (final var o : Neighborhoods.rasterOffsets(connectivity, n)) { + var first = 0; + for (var d = 0; d < n && first == 0; d++) + first = o[d]; + if (forwardOnly && first < 0) continue; + final var offset = new long[n]; + for (var d = 0; d < n; d++) + offset[d] = o[d]; + offsets.add(offset); + } + return offsets; + } + + /** + * Moves {@code target} to {@code position + offset}, returning false if the + * result lies outside the interval. + */ + static boolean shift(final long[] position, final long[] offset, + final Interval interval, final long[] target) + { + for (var d = 0; d < position.length; d++) { + target[d] = position[d] + offset[d]; + if (target[d] < interval.min(d) || target[d] > interval.max(d)) + return false; + } + return true; + } + + /** Whether the position lies on the border of the interval. */ + static boolean onBorder(final long[] position, final Interval interval) { + for (var d = 0; d < position.length; d++) { + if (position[d] == interval.min(d) || position[d] == interval.max(d)) + return true; + } + return false; + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/morphology/attribute/AreaClosing.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/morphology/attribute/AreaClosing.java new file mode 100644 index 000000000..af37a3367 --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/morphology/attribute/AreaClosing.java @@ -0,0 +1,101 @@ +/* #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.morphology.attribute; + +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.algorithm.neighborhood.Shape; +import net.imglib2.type.numeric.RealType; + +import org.scijava.function.Computers; +import org.scijava.ops.image.morphology.attribute.AttributeFilters.Attribute; +import org.scijava.ops.spi.Nullable; + +/** + * Computes the grayscale area closing of an image, in any number of dimensions: + * removes the dark structures made of fewer than a given number of pixels, + * without altering the shape of the others. + *

+ * Each connected component of each lower level set of the image is preserved + * if it has at least {@code minArea} pixels; the others are raised to the + * level of the smallest preserved component containing them. Equivalently, + * the value of a pixel is the minimum, over all connected sets of at least + * {@code minArea} pixels containing it, of the maximum of the image on that + * set. On a binary image, this removes the background components of fewer than + * {@code minArea} pixels (that is, it fills the small holes). + *

+ *

+ * Implemented by pruning the min-tree of the image (Salembier, Oliveras and + * Garrido, 1998), built with the union-find algorithm of Najman and Couprie + * (2006) as formulated by Berger et al. (2007), with the direct filtering + * rule. Any {@code RealType} and any number of dimensions are supported. + *

+ *

+ * See L. Vincent, "Morphological area openings and closings for grey-scale + * images", in Shape in Picture, NATO ASI Series F 126, Springer, + * 1994, pp. 197-208; and E. J. Breen and R. Jones, "Attribute openings, + * thinnings, and granulometries", Computer Vision and Image + * Understanding 64(3):377-389, 1996. + *

+ *

+ * NaN input values are not supported and cause an + * {@link IllegalArgumentException}. Images are limited to 231 + * pixels. + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @param image type + * @implNote op names='morphology.areaClosing' + */ +public class AreaClosing> implements + Computers.Arity3, Long, Shape, RandomAccessibleInterval> +{ + + /** + * Computes the area closing. + * + * @param input the image to filter + * @param minArea the minimal number of pixels of the preserved components + * @param connectivity the neighborhood defining which pixels are adjacent, + * such as a {@code DiamondShape} of radius 1 for face connectivity + * (4-connectivity in 2D, 6-connectivity in 3D) or a + * {@code RectangleShape} of span 1 for full connectivity + * (8-connectivity in 2D, 26-connectivity in 3D). Defaults to full + * connectivity. + * @param output the filtered image, of the same dimensions as {@code input} + */ + @Override + public void compute(final RandomAccessibleInterval input, + final Long minArea, @Nullable final Shape connectivity, + final RandomAccessibleInterval output) + { + AttributeFilters.filter(input, Attribute.AREA, minArea, + connectivity, true, output); + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/morphology/attribute/AreaOpening.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/morphology/attribute/AreaOpening.java new file mode 100644 index 000000000..9d4adddb6 --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/morphology/attribute/AreaOpening.java @@ -0,0 +1,101 @@ +/* #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.morphology.attribute; + +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.algorithm.neighborhood.Shape; +import net.imglib2.type.numeric.RealType; + +import org.scijava.function.Computers; +import org.scijava.ops.image.morphology.attribute.AttributeFilters.Attribute; +import org.scijava.ops.spi.Nullable; + +/** + * Computes the grayscale area opening of an image, in any number of dimensions: + * removes the bright structures made of fewer than a given number of pixels, + * without altering the shape of the others. + *

+ * Each connected component of each upper level set of the image is preserved + * if it has at least {@code minArea} pixels; the others are lowered to the + * level of the smallest preserved component containing them. Equivalently, + * the value of a pixel is the maximum, over all connected sets of at least + * {@code minArea} pixels containing it, of the minimum of the image on that + * set. On a binary image, this removes the foreground components of fewer than + * {@code minArea} pixels. + *

+ *

+ * Implemented by pruning the max-tree of the image (Salembier, Oliveras and + * Garrido, 1998), built with the union-find algorithm of Najman and Couprie + * (2006) as formulated by Berger et al. (2007), with the direct filtering + * rule. Any {@code RealType} and any number of dimensions are supported. + *

+ *

+ * See L. Vincent, "Morphological area openings and closings for grey-scale + * images", in Shape in Picture, NATO ASI Series F 126, Springer, + * 1994, pp. 197-208; and E. J. Breen and R. Jones, "Attribute openings, + * thinnings, and granulometries", Computer Vision and Image + * Understanding 64(3):377-389, 1996. + *

+ *

+ * NaN input values are not supported and cause an + * {@link IllegalArgumentException}. Images are limited to 231 + * pixels. + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @param image type + * @implNote op names='morphology.areaOpening' + */ +public class AreaOpening> implements + Computers.Arity3, Long, Shape, RandomAccessibleInterval> +{ + + /** + * Computes the area opening. + * + * @param input the image to filter + * @param minArea the minimal number of pixels of the preserved components + * @param connectivity the neighborhood defining which pixels are adjacent, + * such as a {@code DiamondShape} of radius 1 for face connectivity + * (4-connectivity in 2D, 6-connectivity in 3D) or a + * {@code RectangleShape} of span 1 for full connectivity + * (8-connectivity in 2D, 26-connectivity in 3D). Defaults to full + * connectivity. + * @param output the filtered image, of the same dimensions as {@code input} + */ + @Override + public void compute(final RandomAccessibleInterval input, + final Long minArea, @Nullable final Shape connectivity, + final RandomAccessibleInterval output) + { + AttributeFilters.filter(input, Attribute.AREA, minArea, + connectivity, false, output); + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/morphology/attribute/AttributeClosing.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/morphology/attribute/AttributeClosing.java new file mode 100644 index 000000000..dbd288a00 --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/morphology/attribute/AttributeClosing.java @@ -0,0 +1,103 @@ +/* #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.morphology.attribute; + +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.algorithm.neighborhood.Shape; +import net.imglib2.type.numeric.RealType; + +import org.scijava.function.Computers; +import org.scijava.ops.image.morphology.attribute.AttributeFilters.Attribute; +import org.scijava.ops.spi.Nullable; + +/** + * Computes a grayscale attribute closing of an image, in any number of + * dimensions, with the attribute given by name. + *

+ * Each connected component of each lower level set of the image is preserved + * if its attribute is at least the given threshold; the others are raised to + * the level of the smallest preserved component containing them. The + * supported attributes are {@code area} (see {@code morphology.areaClosing}), + * {@code boxDiagonal} (see {@code morphology.boxDiagonalClosing}) and + * {@code grayVolume} (see {@code morphology.grayVolumeClosing}). The name + * {@code volume} is rejected as ambiguous: the literature uses it for the + * grayscale volume, while MorphoLibJ uses it for the number of voxels of a + * 3D component, which is {@code area} here. + *

+ *

+ * Implemented by pruning the min-tree of the image (Salembier, Oliveras and + * Garrido, 1998), built with the union-find algorithm of Najman and Couprie + * (2006) as formulated by Berger et al. (2007), with the direct filtering + * rule. Any {@code RealType} and any number of dimensions are supported. + *

+ *

+ * See E. J. Breen and R. Jones, "Attribute openings, thinnings, and + * granulometries", Computer Vision and Image Understanding + * 64(3):377-389, 1996. + *

+ *

+ * NaN input values are not supported and cause an + * {@link IllegalArgumentException}. Images are limited to 231 + * pixels. + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @param image type + * @implNote op names='morphology.attributeClosing' + */ +public class AttributeClosing> implements + Computers.Arity4, String, Double, Shape, RandomAccessibleInterval> +{ + + /** + * Computes the attribute closing. + * + * @param input the image to filter + * @param attribute the name of the attribute: area, boxDiagonal or + * grayVolume (case insensitive) + * @param threshold the minimal attribute value of the preserved components + * @param connectivity the neighborhood defining which pixels are adjacent, + * such as a {@code DiamondShape} of radius 1 for face connectivity + * (4-connectivity in 2D, 6-connectivity in 3D) or a + * {@code RectangleShape} of span 1 for full connectivity + * (8-connectivity in 2D, 26-connectivity in 3D). Defaults to full + * connectivity. + * @param output the filtered image, of the same dimensions as {@code input} + */ + @Override + public void compute(final RandomAccessibleInterval input, + final String attribute, final Double threshold, + @Nullable final Shape connectivity, + final RandomAccessibleInterval output) + { + AttributeFilters.filter(input, Attribute.of(attribute), threshold, + connectivity, true, output); + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/morphology/attribute/AttributeFilters.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/morphology/attribute/AttributeFilters.java new file mode 100644 index 000000000..e9b88a8e7 --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/morphology/attribute/AttributeFilters.java @@ -0,0 +1,131 @@ +/* #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.morphology.attribute; + +import java.util.Arrays; +import java.util.stream.Collectors; + +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.algorithm.neighborhood.Shape; +import net.imglib2.type.numeric.RealType; + +/** + * Shared implementation of the attribute openings and closings of this + * package, which prune the component tree of an image with the direct rule. + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +final class AttributeFilters { + + private AttributeFilters() { + // Prevent instantiation of static utility class. + } + + /** The attributes measured on the nodes of a component tree. */ + enum Attribute { + + /** The number of pixels of the component. */ + AREA("area"), + + /** + * The diagonal of the bounding box of the component, in pixels. + */ + BOX_DIAGONAL("boxDiagonal"), + + /** + * The sum, over the pixels of the component, of their height above the + * level at which the component merges with another one, called the + * volume in the literature (Vachier and Meyer). + */ + GRAY_VOLUME("grayVolume"); + + private final String label; + + Attribute(final String label) { + this.label = label; + } + + /** + * Looks up an attribute by name, ignoring case. + * + * @param name the name of the attribute + * @return the attribute + */ + static Attribute of(final String name) { + if ("volume".equalsIgnoreCase(name)) { + throw new IllegalArgumentException("Ambiguous attribute: volume " + + "(use \"area\" for the number of pixels or voxels of a component, " + + "as in the MorphoLibJ volume opening, or \"grayVolume\" for the " + + "grayscale volume of Vachier and Meyer)"); + } + for (final var a : values()) { + if (a.label.equalsIgnoreCase(name)) return a; + } + throw new IllegalArgumentException("Unknown attribute: " + name + + " (expected one of " + Arrays.stream(values()).map(a -> a.label) + .collect(Collectors.joining(", ")) + ")"); + } + + private double[] measure(final ComponentTree tree) { + switch (this) { + case AREA: + return tree.areas(); + case BOX_DIAGONAL: + return tree.boxDiagonals(); + default: + return tree.volumes(); + } + } + } + + /** + * Computes an attribute opening or closing. + * + * @param input the image to filter + * @param attribute the attribute measured on the connected components + * @param threshold the minimal attribute value of preserved components + * @param connectivity the neighborhood defining which pixels are adjacent, + * or null for full connectivity + * @param closing true for a closing (dark components), false for an opening + * (bright components) + * @param output the image to write the result into + */ + static void filter(final RandomAccessibleInterval> input, + final Attribute attribute, final double threshold, + final Shape connectivity, final boolean closing, + final RandomAccessibleInterval> output) + { + if (Double.isNaN(threshold)) { + throw new IllegalArgumentException("Threshold must not be NaN"); + } + final var tree = new ComponentTree(input, connectivity, closing); + tree.write(tree.filter(attribute.measure(tree), threshold), output); + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/morphology/attribute/AttributeOpening.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/morphology/attribute/AttributeOpening.java new file mode 100644 index 000000000..e1a4d0bfa --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/morphology/attribute/AttributeOpening.java @@ -0,0 +1,103 @@ +/* #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.morphology.attribute; + +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.algorithm.neighborhood.Shape; +import net.imglib2.type.numeric.RealType; + +import org.scijava.function.Computers; +import org.scijava.ops.image.morphology.attribute.AttributeFilters.Attribute; +import org.scijava.ops.spi.Nullable; + +/** + * Computes a grayscale attribute opening of an image, in any number of + * dimensions, with the attribute given by name. + *

+ * Each connected component of each upper level set of the image is preserved + * if its attribute is at least the given threshold; the others are lowered to + * the level of the smallest preserved component containing them. The + * supported attributes are {@code area} (see {@code morphology.areaOpening}), + * {@code boxDiagonal} (see {@code morphology.boxDiagonalOpening}) and + * {@code grayVolume} (see {@code morphology.grayVolumeOpening}). The name + * {@code volume} is rejected as ambiguous: the literature uses it for the + * grayscale volume, while MorphoLibJ uses it for the number of voxels of a + * 3D component, which is {@code area} here. + *

+ *

+ * Implemented by pruning the max-tree of the image (Salembier, Oliveras and + * Garrido, 1998), built with the union-find algorithm of Najman and Couprie + * (2006) as formulated by Berger et al. (2007), with the direct filtering + * rule. Any {@code RealType} and any number of dimensions are supported. + *

+ *

+ * See E. J. Breen and R. Jones, "Attribute openings, thinnings, and + * granulometries", Computer Vision and Image Understanding + * 64(3):377-389, 1996. + *

+ *

+ * NaN input values are not supported and cause an + * {@link IllegalArgumentException}. Images are limited to 231 + * pixels. + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @param image type + * @implNote op names='morphology.attributeOpening' + */ +public class AttributeOpening> implements + Computers.Arity4, String, Double, Shape, RandomAccessibleInterval> +{ + + /** + * Computes the attribute opening. + * + * @param input the image to filter + * @param attribute the name of the attribute: area, boxDiagonal or + * grayVolume (case insensitive) + * @param threshold the minimal attribute value of the preserved components + * @param connectivity the neighborhood defining which pixels are adjacent, + * such as a {@code DiamondShape} of radius 1 for face connectivity + * (4-connectivity in 2D, 6-connectivity in 3D) or a + * {@code RectangleShape} of span 1 for full connectivity + * (8-connectivity in 2D, 26-connectivity in 3D). Defaults to full + * connectivity. + * @param output the filtered image, of the same dimensions as {@code input} + */ + @Override + public void compute(final RandomAccessibleInterval input, + final String attribute, final Double threshold, + @Nullable final Shape connectivity, + final RandomAccessibleInterval output) + { + AttributeFilters.filter(input, Attribute.of(attribute), threshold, + connectivity, false, output); + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/morphology/attribute/BoxDiagonalClosing.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/morphology/attribute/BoxDiagonalClosing.java new file mode 100644 index 000000000..97c7c3253 --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/morphology/attribute/BoxDiagonalClosing.java @@ -0,0 +1,102 @@ +/* #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.morphology.attribute; + +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.algorithm.neighborhood.Shape; +import net.imglib2.type.numeric.RealType; + +import org.scijava.function.Computers; +import org.scijava.ops.image.morphology.attribute.AttributeFilters.Attribute; +import org.scijava.ops.spi.Nullable; + +/** + * Computes the grayscale bounding box diagonal closing of an image, in any + * number of dimensions: removes the dark structures whose bounding box is + * small, without altering the shape of the others. + *

+ * Each connected component of each lower level set of the image is preserved + * if the diagonal of its bounding box is at least {@code minDiagonal}; the + * others are raised to the level of the smallest preserved component + * containing them. The diagonal is the Euclidean distance between the + * centers of two opposite corner pixels of the box: a single pixel has a + * diagonal of 0, two adjacent pixels a diagonal of 1, and a square of 2x2 + * pixels a diagonal of the square root of 2. Unlike the area, this attribute + * preserves thin elongated structures such as lines and fibres. Computing + * the boxes takes two integers per pixel and dimension on top of the + * component tree, e.g. about 3 GB for a 5123 volume. + *

+ *

+ * Implemented by pruning the min-tree of the image (Salembier, Oliveras and + * Garrido, 1998), built with the union-find algorithm of Najman and Couprie + * (2006) as formulated by Berger et al. (2007), with the direct filtering + * rule. Any {@code RealType} and any number of dimensions are supported. + *

+ *

+ * See E. J. Breen and R. Jones, "Attribute openings, thinnings, and + * granulometries", Computer Vision and Image Understanding + * 64(3):377-389, 1996. + *

+ *

+ * NaN input values are not supported and cause an + * {@link IllegalArgumentException}. Images are limited to 231 + * pixels. + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @param image type + * @implNote op names='morphology.boxDiagonalClosing' + */ +public class BoxDiagonalClosing> implements + Computers.Arity3, Double, Shape, RandomAccessibleInterval> +{ + + /** + * Computes the bounding box diagonal closing. + * + * @param input the image to filter + * @param minDiagonal the minimal bounding box diagonal of the preserved components, in pixels + * @param connectivity the neighborhood defining which pixels are adjacent, + * such as a {@code DiamondShape} of radius 1 for face connectivity + * (4-connectivity in 2D, 6-connectivity in 3D) or a + * {@code RectangleShape} of span 1 for full connectivity + * (8-connectivity in 2D, 26-connectivity in 3D). Defaults to full + * connectivity. + * @param output the filtered image, of the same dimensions as {@code input} + */ + @Override + public void compute(final RandomAccessibleInterval input, + final Double minDiagonal, @Nullable final Shape connectivity, + final RandomAccessibleInterval output) + { + AttributeFilters.filter(input, Attribute.BOX_DIAGONAL, minDiagonal, + connectivity, true, output); + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/morphology/attribute/BoxDiagonalOpening.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/morphology/attribute/BoxDiagonalOpening.java new file mode 100644 index 000000000..cd0076cf1 --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/morphology/attribute/BoxDiagonalOpening.java @@ -0,0 +1,102 @@ +/* #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.morphology.attribute; + +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.algorithm.neighborhood.Shape; +import net.imglib2.type.numeric.RealType; + +import org.scijava.function.Computers; +import org.scijava.ops.image.morphology.attribute.AttributeFilters.Attribute; +import org.scijava.ops.spi.Nullable; + +/** + * Computes the grayscale bounding box diagonal opening of an image, in any + * number of dimensions: removes the bright structures whose bounding box is + * small, without altering the shape of the others. + *

+ * Each connected component of each upper level set of the image is preserved + * if the diagonal of its bounding box is at least {@code minDiagonal}; the + * others are lowered to the level of the smallest preserved component + * containing them. The diagonal is the Euclidean distance between the + * centers of two opposite corner pixels of the box: a single pixel has a + * diagonal of 0, two adjacent pixels a diagonal of 1, and a square of 2x2 + * pixels a diagonal of the square root of 2. Unlike the area, this attribute + * preserves thin elongated structures such as lines and fibres. Computing + * the boxes takes two integers per pixel and dimension on top of the + * component tree, e.g. about 3 GB for a 5123 volume. + *

+ *

+ * Implemented by pruning the max-tree of the image (Salembier, Oliveras and + * Garrido, 1998), built with the union-find algorithm of Najman and Couprie + * (2006) as formulated by Berger et al. (2007), with the direct filtering + * rule. Any {@code RealType} and any number of dimensions are supported. + *

+ *

+ * See E. J. Breen and R. Jones, "Attribute openings, thinnings, and + * granulometries", Computer Vision and Image Understanding + * 64(3):377-389, 1996. + *

+ *

+ * NaN input values are not supported and cause an + * {@link IllegalArgumentException}. Images are limited to 231 + * pixels. + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @param image type + * @implNote op names='morphology.boxDiagonalOpening' + */ +public class BoxDiagonalOpening> implements + Computers.Arity3, Double, Shape, RandomAccessibleInterval> +{ + + /** + * Computes the bounding box diagonal opening. + * + * @param input the image to filter + * @param minDiagonal the minimal bounding box diagonal of the preserved components, in pixels + * @param connectivity the neighborhood defining which pixels are adjacent, + * such as a {@code DiamondShape} of radius 1 for face connectivity + * (4-connectivity in 2D, 6-connectivity in 3D) or a + * {@code RectangleShape} of span 1 for full connectivity + * (8-connectivity in 2D, 26-connectivity in 3D). Defaults to full + * connectivity. + * @param output the filtered image, of the same dimensions as {@code input} + */ + @Override + public void compute(final RandomAccessibleInterval input, + final Double minDiagonal, @Nullable final Shape connectivity, + final RandomAccessibleInterval output) + { + AttributeFilters.filter(input, Attribute.BOX_DIAGONAL, minDiagonal, + connectivity, false, output); + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/morphology/attribute/ComponentTree.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/morphology/attribute/ComponentTree.java new file mode 100644 index 000000000..45fee17af --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/morphology/attribute/ComponentTree.java @@ -0,0 +1,389 @@ +/* #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.morphology.attribute; + +import java.util.Arrays; + +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.algorithm.neighborhood.RectangleShape; +import net.imglib2.algorithm.neighborhood.Shape; +import net.imglib2.type.numeric.RealType; +import net.imglib2.util.Intervals; +import net.imglib2.view.Views; + +import org.scijava.ops.image.util.Neighborhoods; + +/** + * The max-tree (component tree of the upper level sets) of an n-dimensional + * image, built with the union-find algorithm of Berger et al. + *

+ * Each node of the tree is a connected component of an upper level set + * {@code {f >= t}} at a level {@code t} taken by the image, and its parent is + * the smallest strictly larger such component. The tree is stored in a single + * {@code parent} array over the pixels (flattened, first dimension varying + * fastest): each node is represented by one canonical pixel at its level, the + * other pixels of the node at that level point to it, and a canonical pixel + * points to the canonical pixel of the parent node. The root (the whole image, + * at the minimum value) points to itself; a connectivity that does not link + * all pixels yields several roots, one per connected component of the image. + * A min-tree is the max-tree of the + * negated image. + *

+ *

+ * References: + *

+ *
    + *
  • P. Salembier, A. Oliveras and L. Garrido, "Antiextensive connected + * operators for image and sequence processing", IEEE Transactions on Image + * Processing 7(4):555-570, 1998.
  • + *
  • L. Najman and M. Couprie, "Building the component tree in quasi-linear + * time", IEEE Transactions on Image Processing 15(11):3531-3539, + * 2006.
  • + *
  • C. Berger, T. Geraud, R. Levillain, N. Widynski, A. Baillard and E. + * Bertin, "Effective component tree computation with application to pattern + * recognition in astronomical imaging", IEEE International Conference on + * Image Processing, 2007.
  • + *
+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +final class ComponentTree { + + /** Number of dimensions. */ + final int n; + + /** Size of each dimension. */ + final int[] dims; + + /** Total number of pixels. */ + final int size; + + /** Whether this is a min-tree rather than a max-tree. */ + final boolean minTree; + + /** Pixel values (negated for a min-tree). */ + final double[] values; + + /** Rank of each pixel value among the distinct values. */ + final int[] ranks; + + /** Pixels sorted by decreasing value (leaves first, root last). */ + final int[] order; + + /** Parent of each pixel, as described in the class documentation. */ + final int[] parent; + + /** + * Builds the max-tree, or the min-tree, of an image. + * + * @param image the image + * @param connectivity the neighborhood defining which pixels are adjacent, + * or null for full connectivity (a {@link RectangleShape} of span + * 1, i.e. 8-connectivity in 2D and 26-connectivity in 3D) + * @param minTree whether to build the min-tree instead of the max-tree + */ + ComponentTree(final RandomAccessibleInterval> image, + final Shape connectivity, final boolean minTree) + { + if (Intervals.numElements(image) > Integer.MAX_VALUE - 8) { + throw new IllegalArgumentException( + "Images with more than 2^31 pixels are not supported"); + } + this.minTree = minTree; + n = image.numDimensions(); + dims = new int[n]; + final var strides = new int[n]; + var s = 1; + for (var d = 0; d < n; d++) { + dims[d] = (int) image.dimension(d); + strides[d] = s; + s *= dims[d]; + } + size = s; + + values = new double[size]; + var i = 0; + for (final var t : Views.flatIterable(image)) { + final var v = t.getRealDouble(); + if (Double.isNaN(v)) { + throw new IllegalArgumentException("NaN values are not supported"); + } + // Note: adding 0.0 turns -0.0 into 0.0, so they share one rank. + values[i++] = (minTree ? -v : v) + 0.0; + } + ranks = new int[size]; + order = sortDecreasing(values, ranks); + parent = new int[size]; + build(Neighborhoods.neighborOffsets(connectivity, n), strides); + } + + /** + * Tests whether a pixel is the canonical pixel of its node. + * + * @param p the pixel index + * @return true if the pixel represents a node + */ + boolean isCanonical(final int p) { + final var q = parent[p]; + return q == p || ranks[q] != ranks[p]; + } + + /** + * Computes the position of the pixel with the given linear index. + * + * @param index the linear index + * @param pos the array to fill with the position + */ + void position(int index, final int[] pos) { + for (var d = 0; d < n; d++) { + pos[d] = index % dims[d]; + index /= dims[d]; + } + } + + /** + * Computes the pixel count of every node, accumulated at its canonical + * pixel. + * + * @return the area of the node of each canonical pixel + */ + double[] areas() { + final var area = new double[size]; + Arrays.fill(area, 1); + for (final var p : order) { + if (parent[p] != p) area[parent[p]] += area[p]; + } + return area; + } + + /** + * Computes the bounding box diagonal of every node, at its canonical pixel: + * the Euclidean distance between the centers of two opposite corner pixels + * of the bounding box (0 for a single pixel). + * + * @return the bounding box diagonal of the node of each canonical pixel + */ + double[] boxDiagonals() { + final var min = new int[size * n]; + final var max = new int[size * n]; + final var pos = new int[n]; + for (var p = 0; p < size; p++) { + position(p, pos); + System.arraycopy(pos, 0, min, p * n, n); + System.arraycopy(pos, 0, max, p * n, n); + } + for (final var o : order) { + if (parent[o] == o) continue; + final var p = o * n; + final var q = parent[o] * n; + for (var d = 0; d < n; d++) { + if (min[p + d] < min[q + d]) min[q + d] = min[p + d]; + if (max[p + d] > max[q + d]) max[q + d] = max[p + d]; + } + } + final var diagonal = new double[size]; + for (var p = 0; p < size; p++) { + var sum = 0.0; + for (var d = 0; d < n; d++) { + final double extent = max[p * n + d] - min[p * n + d]; + sum += extent * extent; + } + diagonal[p] = Math.sqrt(sum); + } + return diagonal; + } + + /** + * Computes the volume of every node, at its canonical pixel: the sum, over + * the pixels of the node, of their height above the level of the parent + * node. A root has an infinite volume. + * + * @return the volume of the node of each canonical pixel + */ + double[] volumes() { + final var area = areas(); + final var sum = values.clone(); + for (final var p : order) { + if (parent[p] != p) sum[parent[p]] += sum[p]; + } + for (var p = 0; p < size; p++) { + final var q = parent[p]; + sum[p] = q == p ? Double.POSITIVE_INFINITY : sum[p] - area[p] * + values[q]; + } + return sum; + } + + /** + * Filters the image with the direct rule: each node whose attribute is + * below the threshold is removed, its pixels taking the level of the + * nearest preserved ancestor node, while all other nodes keep their level. + * The roots are always preserved. + * + * @param attributes the attribute of each node, at its canonical pixel + * @param threshold the minimal attribute value of preserved nodes + * @return the filtered values (negated back for a min-tree) + */ + double[] filter(final double[] attributes, final double threshold) { + final var out = new double[size]; + for (var i = size - 1; i >= 0; i--) { + final var p = order[i]; + final var q = parent[p]; + if (q == p) out[p] = values[p]; + else if (ranks[q] != ranks[p]) { + out[p] = attributes[p] >= threshold ? values[p] : out[q]; + } + else out[p] = out[q]; + } + if (minTree) { + for (var p = 0; p < size; p++) + out[p] = -out[p]; + } + return out; + } + + /** + * Writes values into an image of the dimensions of the tree, in flat + * iteration order. + * + * @param result the values to write + * @param image the image to write into + */ + void write(final double[] result, + final RandomAccessibleInterval> image) + { + if (image.numDimensions() != n) { + throw new IllegalArgumentException("Expected " + n + + " dimensions but got " + image.numDimensions()); + } + for (var d = 0; d < n; d++) { + if (image.dimension(d) != dims[d]) { + throw new IllegalArgumentException("Output dimensions " + Arrays + .toString(image.dimensionsAsLongArray()) + " do not match " + Arrays + .toString(dims)); + } + } + var i = 0; + for (final var t : Views.flatIterable(image)) + t.setReal(result[i++]); + } + + /** + * Links the pixels into the tree: union-find over the pixels in decreasing + * order of value, followed by canonicalization. + */ + private void build(final int[][] offsets, final int[] strides) { + final var steps = new int[offsets.length]; + var reach = 0; + for (var k = 0; k < offsets.length; k++) { + for (var d = 0; d < n; d++) { + steps[k] += offsets[k][d] * strides[d]; + reach = Math.max(reach, Math.abs(offsets[k][d])); + } + } + + final var zpar = new int[size]; + Arrays.fill(zpar, -1); + final var pos = new int[n]; + for (final var p : order) { + parent[p] = p; + zpar[p] = p; + position(p, pos); + final var interior = isInterior(pos, reach); + for (var k = 0; k < offsets.length; k++) { + if (!interior && !contains(pos, offsets[k])) continue; + final var q = p + steps[k]; + if (zpar[q] < 0) continue; + final var r = find(zpar, q); + if (r != p) { + parent[r] = p; + zpar[r] = p; + } + } + } + + for (var i = size - 1; i >= 0; i--) { + final var p = order[i]; + final var q = parent[p]; + if (ranks[parent[q]] == ranks[q]) parent[p] = parent[q]; + } + } + + private boolean isInterior(final int[] pos, final int reach) { + for (var d = 0; d < n; d++) { + if (pos[d] < reach || pos[d] >= dims[d] - reach) return false; + } + return true; + } + + private boolean contains(final int[] pos, final int[] offset) { + for (var d = 0; d < n; d++) { + final var c = pos[d] + offset[d]; + if (c < 0 || c >= dims[d]) return false; + } + return true; + } + + /** Finds the root of a union-find set, halving the path on the way. */ + private static int find(final int[] zpar, int p) { + while (zpar[p] != p) { + zpar[p] = zpar[zpar[p]]; + p = zpar[p]; + } + return p; + } + + /** + * Sorts the pixels by decreasing value (ties in increasing index order), + * using a counting sort over the ranks of the distinct values. + */ + private static int[] sortDecreasing(final double[] values, + final int[] ranks) + { + final var sorted = values.clone(); + Arrays.sort(sorted); + var levels = 0; + for (var i = 0; i < sorted.length; i++) { + if (i == 0 || sorted[i] != sorted[levels - 1]) sorted[levels++] = + sorted[i]; + } + final var count = new int[levels + 1]; + for (var p = 0; p < values.length; p++) { + ranks[p] = Arrays.binarySearch(sorted, 0, levels, values[p]); + count[levels - 1 - ranks[p] + 1]++; + } + for (var r = 1; r <= levels; r++) + count[r] += count[r - 1]; + final var order = new int[values.length]; + for (var p = 0; p < values.length; p++) + order[count[levels - 1 - ranks[p]]++] = p; + return order; + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/morphology/attribute/GrayVolumeClosing.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/morphology/attribute/GrayVolumeClosing.java new file mode 100644 index 000000000..f5d391520 --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/morphology/attribute/GrayVolumeClosing.java @@ -0,0 +1,112 @@ +/* #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.morphology.attribute; + +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.algorithm.neighborhood.Shape; +import net.imglib2.type.numeric.RealType; + +import org.scijava.function.Computers; +import org.scijava.ops.image.morphology.attribute.AttributeFilters.Attribute; +import org.scijava.ops.spi.Nullable; + +/** + * Computes the grayscale volume closing of an image, in any number of + * dimensions: removes the dark structures of small volume, that is with a + * small product of size and contrast. + *

+ * Each connected component of each lower level set of the image is preserved + * if its volume is at least {@code minVolume}; the others are raised to the + * level of the smallest preserved component containing them. The volume of a + * component is the sum, over its pixels, of their height below the level at + * which the component merges with a larger one, that is the minimum value of + * the image on the pixels adjacent to the component. The component covering + * the whole image is always preserved. + *

+ *

+ * The literature calls this attribute the volume (Vachier, 1995; + * Meyer, 1998), since it is the volume under (or above) the image surface + * restricted to the component. This op is named {@code grayVolumeClosing} + * rather than {@code volumeClosing} because MorphoLibJ uses the latter name + * for a different filter: its 3D "volume closing" is a size closing that + * counts the voxels of each component, which here is + * {@code morphology.areaClosing} applied to a 3D image. Since the grayscale + * volume of a component decreases when its sub-components are removed, + * this filter is not idempotent. + *

+ *

+ * Implemented by pruning the min-tree of the image (Salembier, Oliveras and + * Garrido, 1998), built with the union-find algorithm of Najman and Couprie + * (2006) as formulated by Berger et al. (2007), with the direct filtering + * rule. Any {@code RealType} and any number of dimensions are supported. + *

+ *

+ * See C. Vachier, "Extraction de caracteristiques, segmentation d'image et + * morphologie mathematique", PhD thesis, Ecole des Mines de Paris, 1995; and + * F. Meyer, "From connected operators to levelings", in Mathematical + * Morphology and its Applications to Image and Signal Processing, Kluwer, + * 1998, pp. 191-198. + *

+ *

+ * NaN input values are not supported and cause an + * {@link IllegalArgumentException}. Images are limited to 231 + * pixels. + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @param image type + * @implNote op names='morphology.grayVolumeClosing' + */ +public class GrayVolumeClosing> implements + Computers.Arity3, Double, Shape, RandomAccessibleInterval> +{ + + /** + * Computes the grayscale volume closing. + * + * @param input the image to filter + * @param minVolume the minimal volume of the preserved components, in pixels times intensity units + * @param connectivity the neighborhood defining which pixels are adjacent, + * such as a {@code DiamondShape} of radius 1 for face connectivity + * (4-connectivity in 2D, 6-connectivity in 3D) or a + * {@code RectangleShape} of span 1 for full connectivity + * (8-connectivity in 2D, 26-connectivity in 3D). Defaults to full + * connectivity. + * @param output the filtered image, of the same dimensions as {@code input} + */ + @Override + public void compute(final RandomAccessibleInterval input, + final Double minVolume, @Nullable final Shape connectivity, + final RandomAccessibleInterval output) + { + AttributeFilters.filter(input, Attribute.GRAY_VOLUME, minVolume, + connectivity, true, output); + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/morphology/attribute/GrayVolumeOpening.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/morphology/attribute/GrayVolumeOpening.java new file mode 100644 index 000000000..3e4b32bbc --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/morphology/attribute/GrayVolumeOpening.java @@ -0,0 +1,112 @@ +/* #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.morphology.attribute; + +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.algorithm.neighborhood.Shape; +import net.imglib2.type.numeric.RealType; + +import org.scijava.function.Computers; +import org.scijava.ops.image.morphology.attribute.AttributeFilters.Attribute; +import org.scijava.ops.spi.Nullable; + +/** + * Computes the grayscale volume opening of an image, in any number of + * dimensions: removes the bright structures of small volume, that is with a + * small product of size and contrast. + *

+ * Each connected component of each upper level set of the image is preserved + * if its volume is at least {@code minVolume}; the others are lowered to the + * level of the smallest preserved component containing them. The volume of a + * component is the sum, over its pixels, of their height above the level at + * which the component merges with a larger one, that is the maximum value of + * the image on the pixels adjacent to the component. The component covering + * the whole image is always preserved. + *

+ *

+ * The literature calls this attribute the volume (Vachier, 1995; + * Meyer, 1998), since it is the volume under (or above) the image surface + * restricted to the component. This op is named {@code grayVolumeOpening} + * rather than {@code volumeOpening} because MorphoLibJ uses the latter name + * for a different filter: its 3D "volume opening" is a size opening that + * counts the voxels of each component, which here is + * {@code morphology.areaOpening} applied to a 3D image. Since the grayscale + * volume of a component decreases when its sub-components are removed, + * this filter is not idempotent. + *

+ *

+ * Implemented by pruning the max-tree of the image (Salembier, Oliveras and + * Garrido, 1998), built with the union-find algorithm of Najman and Couprie + * (2006) as formulated by Berger et al. (2007), with the direct filtering + * rule. Any {@code RealType} and any number of dimensions are supported. + *

+ *

+ * See C. Vachier, "Extraction de caracteristiques, segmentation d'image et + * morphologie mathematique", PhD thesis, Ecole des Mines de Paris, 1995; and + * F. Meyer, "From connected operators to levelings", in Mathematical + * Morphology and its Applications to Image and Signal Processing, Kluwer, + * 1998, pp. 191-198. + *

+ *

+ * NaN input values are not supported and cause an + * {@link IllegalArgumentException}. Images are limited to 231 + * pixels. + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @param image type + * @implNote op names='morphology.grayVolumeOpening' + */ +public class GrayVolumeOpening> implements + Computers.Arity3, Double, Shape, RandomAccessibleInterval> +{ + + /** + * Computes the grayscale volume opening. + * + * @param input the image to filter + * @param minVolume the minimal volume of the preserved components, in pixels times intensity units + * @param connectivity the neighborhood defining which pixels are adjacent, + * such as a {@code DiamondShape} of radius 1 for face connectivity + * (4-connectivity in 2D, 6-connectivity in 3D) or a + * {@code RectangleShape} of span 1 for full connectivity + * (8-connectivity in 2D, 26-connectivity in 3D). Defaults to full + * connectivity. + * @param output the filtered image, of the same dimensions as {@code input} + */ + @Override + public void compute(final RandomAccessibleInterval input, + final Double minVolume, @Nullable final Shape connectivity, + final RandomAccessibleInterval output) + { + AttributeFilters.filter(input, Attribute.GRAY_VOLUME, minVolume, + connectivity, false, output); + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/morphology/directional/DirectionalFilter.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/morphology/directional/DirectionalFilter.java new file mode 100644 index 000000000..bbc1d92f2 --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/morphology/directional/DirectionalFilter.java @@ -0,0 +1,212 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.morphology.directional; + +import java.util.Locale; +import java.util.function.BiFunction; + +import net.imglib2.Dimensions; +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.algorithm.neighborhood.Shape; +import net.imglib2.loops.LoopBuilder; +import net.imglib2.outofbounds.OutOfBoundsBorderFactory; +import net.imglib2.outofbounds.OutOfBoundsFactory; +import net.imglib2.type.numeric.RealType; +import net.imglib2.type.numeric.real.DoubleType; +import net.imglib2.util.Intervals; + +import org.scijava.function.Computers; +import org.scijava.ops.spi.Nullable; +import org.scijava.ops.spi.OpDependency; + +/** + * Directional filtering with oriented line segments (Soille & Talbot + * 2001). + *

+ * The image is filtered with a line-segment structuring element (see + * {@link OrientedLineShape}) at {@code n} orientations {@code θ_i = i·π/n}, + * {@code i = 0, ..., n-1}, and the {@code n} results are combined pixel-wise + * by their maximum or minimum. The filter applied per orientation is one of: + *

+ *
    + *
  • {@code "erosion"} (minimum along the line), {@code "dilation"} + * (maximum), {@code "opening"} (erosion followed by dilation) or + * {@code "closing"} (dilation followed by erosion);
  • + *
  • {@code "mean"} or {@code "median"} along the line.
  • + *
+ *

+ * The maximum of directional openings (the default) keeps bright thin + * structures, such as fibers or vessels, that contain a line segment of the + * given length in some orientation, while removing bright noise and blobs + * smaller than the segment; dually, the minimum of directional closings keeps + * dark curvilinear structures. The maximum of directional means or medians + * smooths along the local structure orientation, which enhances thin bright + * lines without blurring them across. + *

+ *

+ * The lines lie in the plane of the first two dimensions, so images with more + * dimensions are filtered plane by plane. Out-of-bounds values are taken from + * the nearest border pixel. + *

+ *

+ * Implemented from the papers. This is the method of the "Directional + * Filtering" plugin of MorphoLibJ, but the line segments are discretized + * differently (see {@link OrientedLineShape}), so results are not identical + * to those of the plugin. + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @param input pixel type + * @param output pixel type + * @see P. Soille and H. Talbot, + * "Directional morphological filtering", IEEE Trans. Pattern Anal. Mach. + * Intell. 23(11):1313-1329, 2001 + * @implNote op names='morphology.directionalFilter' + */ +public class DirectionalFilter, V extends RealType> + implements + Computers.Arity5, String, Double, Integer, String, RandomAccessibleInterval> +{ + + @OpDependency(name = "create.img") + private BiFunction> createOp; + + @OpDependency(name = "filter.min") + private Computers.Arity3, Shape, OutOfBoundsFactory>, RandomAccessibleInterval> minOp; + + @OpDependency(name = "filter.max") + private Computers.Arity3, Shape, OutOfBoundsFactory>, RandomAccessibleInterval> maxOp; + + @OpDependency(name = "filter.mean") + private Computers.Arity3, Shape, OutOfBoundsFactory>, RandomAccessibleInterval> meanOp; + + @OpDependency(name = "filter.median") + private Computers.Arity3, Shape, OutOfBoundsFactory>, RandomAccessibleInterval> medianOp; + + /** + * Applies the directional filter. + * + * @param input the image to filter, with at least 2 dimensions + * @param operation the filter applied along each line: one of "opening", + * "closing", "erosion", "dilation", "mean" or "median" (default: + * "opening") + * @param lineLength the approximate Euclidean length of the line segments, + * in pixels (default: 20) + * @param directions the number of orientations, evenly spaced over 180 + * degrees (default: 32) + * @param combination how the results of the orientations are combined: + * "max" or "min" (default: "max") + * @param output the filtered image + */ + @Override + public void compute(final RandomAccessibleInterval input, + @Nullable final String operation, @Nullable final Double lineLength, + @Nullable final Integer directions, @Nullable final String combination, + final RandomAccessibleInterval output) + { + if (input.numDimensions() < 2) { + throw new IllegalArgumentException( + "Directional filtering needs at least 2 dimensions"); + } + if (!Intervals.equalDimensions(input, output)) { + throw new IllegalArgumentException( + "Input and output must have the same dimensions"); + } + final var op = operation == null ? "opening" : operation.toLowerCase( + Locale.ROOT); + if (!op.matches("opening|closing|erosion|dilation|mean|median")) { + throw new IllegalArgumentException("Unknown operation: " + operation + + " (expected opening, closing, erosion, dilation, mean or median)"); + } + final double length = lineLength == null ? 20 : lineLength; + final int n = directions == null ? 32 : directions; + if (n < 1) { + throw new IllegalArgumentException( + "Number of directions must be positive: " + n); + } + final var comb = combination == null ? "max" : combination.toLowerCase( + Locale.ROOT); + final boolean useMax; + if (comb.equals("max")) useMax = true; + else if (comb.equals("min")) useMax = false; + else throw new IllegalArgumentException("Combination must be max or min: " + + combination); + + final var source = createOp.apply(input, new DoubleType()); + LoopBuilder.setImages(input, source).forEachPixel((i, s) -> s.set(i + .getRealDouble())); + final var result = createOp.apply(input, new DoubleType()); + final var tmp = createOp.apply(input, new DoubleType()); + final var filtered = createOp.apply(input, new DoubleType()); + final OutOfBoundsFactory> oob = + new OutOfBoundsBorderFactory<>(); + for (var i = 0; i < n; i++) { + final var shape = new OrientedLineShape(length, i * Math.PI / n); + switch (op) { + case "erosion": + minOp.compute(source, shape, oob, filtered); + break; + case "dilation": + maxOp.compute(source, shape, oob, filtered); + break; + case "opening": + minOp.compute(source, shape, oob, tmp); + maxOp.compute(tmp, shape, oob, filtered); + break; + case "closing": + maxOp.compute(source, shape, oob, tmp); + minOp.compute(tmp, shape, oob, filtered); + break; + case "mean": + meanOp.compute(source, shape, oob, filtered); + break; + default: + medianOp.compute(source, shape, oob, filtered); + } + if (i == 0) { + LoopBuilder.setImages(filtered, result).forEachPixel((f, r) -> r.set( + f)); + } + else if (useMax) { + LoopBuilder.setImages(filtered, result).forEachPixel((f, r) -> { + if (f.get() > r.get()) r.set(f); + }); + } + else { + LoopBuilder.setImages(filtered, result).forEachPixel((f, r) -> { + if (f.get() < r.get()) r.set(f); + }); + } + } + LoopBuilder.setImages(result, output).forEachPixel((r, o) -> o.setReal(r + .get())); + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/morphology/directional/OrientedLineShape.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/morphology/directional/OrientedLineShape.java new file mode 100644 index 000000000..767f773e2 --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/morphology/directional/OrientedLineShape.java @@ -0,0 +1,374 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.morphology.directional; + +import net.imglib2.AbstractCursor; +import net.imglib2.Cursor; +import net.imglib2.FinalInterval; +import net.imglib2.Interval; +import net.imglib2.Localizable; +import net.imglib2.Point; +import net.imglib2.RandomAccess; +import net.imglib2.RandomAccessible; +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.algorithm.neighborhood.Neighborhood; +import net.imglib2.algorithm.neighborhood.Shape; +import net.imglib2.view.Views; + +/** + * A {@link Shape} consisting of a digital straight line segment through the + * center pixel, at an arbitrary orientation in the plane of the first two + * dimensions. + *

+ * The segment is symmetric about the center, so it equals its own reflection + * (which makes an erosion followed by a dilation with it a morphological + * opening). For an orientation {@code θ}, the dominant axis is the one of + * {@code (cos θ, sin θ)} with the larger magnitude. The segment contains one + * pixel for each integer step {@code k} in {@code [-K, K]} along the dominant + * axis, the other coordinate being {@code k} times the slope, rounded half + * away from zero, as in a Bresenham line (Hendriks & van Vliet 2005). + * {@code K} is chosen so that the Euclidean length of the segment is close to + * the requested length: {@code K = round(((length - 1) / 2) · max(|cos θ|, + * |sin θ|))}. The angle is measured from the positive x axis (dimension 0) + * towards the positive y axis (dimension 1). Offsets in further dimensions + * are zero, so on an n-D image the line acts plane by plane. + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @see C. L. L. Hendriks + * and L. J. van Vliet, "Using line segments as structuring elements for + * sampling-invariant measurements", IEEE Trans. Pattern Anal. Mach. + * Intell. 27(11):1826-1831, 2005 + */ +public class OrientedLineShape implements Shape { + + private final double length; + private final double angle; + + /** The offsets of the line pixels, as {@code [x, y]} pairs. */ + private final long[][] offsets; + + /** + * Creates an oriented line shape. + * + * @param length the approximate Euclidean length of the segment, in pixels + * @param angle the orientation of the segment, in radians + */ + public OrientedLineShape(final double length, final double angle) { + if (!(length >= 1)) { + throw new IllegalArgumentException("Length must be at least 1: " + + length); + } + this.length = length; + this.angle = angle; + final var cos = Math.cos(angle); + final var sin = Math.sin(angle); + final var horizontal = Math.abs(cos) >= Math.abs(sin); + final var major = horizontal ? cos : sin; + final var slope = (horizontal ? sin : cos) / major; + final var k = Math.round((length - 1) / 2 * Math.abs(major)); + offsets = new long[(int) (2 * k + 1)][]; + for (var i = -k; i <= k; i++) { + final var minor = Math.signum(i * slope) * Math.round(Math.abs(i * + slope)); + final var step = (long) Math.signum(major) * i; + offsets[(int) (i + k)] = horizontal ? new long[] { step, (long) minor } + : new long[] { (long) minor, step }; + } + } + + /** @return the approximate Euclidean length of the segment */ + public double getLength() { + return length; + } + + /** @return the orientation of the segment, in radians */ + public double getAngle() { + return angle; + } + + /** + * @return a copy of the offsets of the line pixels relative to the center, + * as {@code [x, y]} pairs + */ + public long[][] getOffsets() { + final var copy = new long[offsets.length][]; + for (var i = 0; i < offsets.length; i++) + copy[i] = offsets[i].clone(); + return copy; + } + + @Override + public RandomAccessibleInterval> neighborhoods( + final RandomAccessibleInterval source) + { + return Views.interval(neighborhoodsRandomAccessible(source), source); + } + + @Override + public RandomAccessible> neighborhoodsRandomAccessible( + final RandomAccessible source) + { + checkDimensions(source.numDimensions()); + return new LineNeighborhoods<>(source); + } + + @Override + public RandomAccessibleInterval> neighborhoodsSafe( + final RandomAccessibleInterval source) + { + return neighborhoods(source); + } + + @Override + public RandomAccessible> + neighborhoodsRandomAccessibleSafe(final RandomAccessible source) + { + return neighborhoodsRandomAccessible(source); + } + + @Override + public Interval getStructuringElementBoundingBox(final int numDimensions) { + checkDimensions(numDimensions); + final var min = new long[numDimensions]; + final var max = new long[numDimensions]; + for (final var o : offsets) { + for (var d = 0; d < 2; d++) { + min[d] = Math.min(min[d], o[d]); + max[d] = Math.max(max[d], o[d]); + } + } + return new FinalInterval(min, max); + } + + @Override + public String toString() { + return "OrientedLineShape, length = " + length + ", angle = " + angle; + } + + private static void checkDimensions(final int numDimensions) { + if (numDimensions < 2) { + throw new IllegalArgumentException( + "An oriented line needs at least 2 dimensions"); + } + } + + /** The random-accessible field of line neighborhoods over a source. */ + private final class LineNeighborhoods implements + RandomAccessible> + { + + private final RandomAccessible source; + + private LineNeighborhoods(final RandomAccessible source) { + this.source = source; + } + + @Override + public int numDimensions() { + return source.numDimensions(); + } + + @Override + public RandomAccess> randomAccess() { + return new LineNeighborhoodAccess<>(source); + } + + @Override + public RandomAccess> randomAccess(final Interval interval) { + return randomAccess(); + } + + @Override + public Neighborhood getType() { + return randomAccess().get(); + } + } + + /** A random access whose samples are line neighborhoods. */ + private final class LineNeighborhoodAccess extends Point implements + RandomAccess> + { + + private final RandomAccessible source; + + private LineNeighborhoodAccess(final RandomAccessible source) { + super(source.numDimensions()); + this.source = source; + } + + private LineNeighborhoodAccess(final LineNeighborhoodAccess other) { + super(other); + this.source = other.source; + } + + @Override + public Neighborhood get() { + return new LineNeighborhood<>(this, source); + } + + @Override + public LineNeighborhoodAccess copy() { + return new LineNeighborhoodAccess<>(this); + } + + @Override + public Neighborhood getType() { + return get(); + } + } + + /** The line neighborhood of one center pixel. */ + private final class LineNeighborhood extends Point implements + Neighborhood + { + + private final RandomAccessible source; + private final Interval bounds; + + private LineNeighborhood(final Localizable center, + final RandomAccessible source) + { + super(center); + this.source = source; + final var box = getStructuringElementBoundingBox(); + final var min = box.minAsLongArray(); + final var max = box.maxAsLongArray(); + for (var d = 0; d < min.length; d++) { + min[d] += position[d]; + max[d] += position[d]; + } + bounds = new FinalInterval(min, max); + } + + @Override + public Interval getStructuringElementBoundingBox() { + return OrientedLineShape.this.getStructuringElementBoundingBox( + numDimensions()); + } + + @Override + public long min(final int d) { + return bounds.min(d); + } + + @Override + public long max(final int d) { + return bounds.max(d); + } + + @Override + public long size() { + return offsets.length; + } + + @Override + public Object iterationOrder() { + return this; + } + + @Override + public Cursor cursor() { + return new LineCursor<>(position, source.randomAccess()); + } + + @Override + public Cursor localizingCursor() { + return cursor(); + } + + @Override + public T getType() { + return source.getType(); + } + } + + /** A cursor over the pixels of one line neighborhood. */ + private final class LineCursor extends AbstractCursor { + + private final long[] center; + private final RandomAccess access; + private int index = -1; + + private LineCursor(final long[] center, final RandomAccess access) { + super(center.length); + this.center = center.clone(); + this.access = access; + } + + @Override + public T get() { + return access.get(); + } + + @Override + public void fwd() { + index++; + final var o = offsets[index]; + access.setPosition(center); + access.move(o[0], 0); + access.move(o[1], 1); + } + + @Override + public void reset() { + index = -1; + } + + @Override + public boolean hasNext() { + return index < offsets.length - 1; + } + + @Override + public void localize(final long[] pos) { + access.localize(pos); + } + + @Override + public long getLongPosition(final int d) { + return access.getLongPosition(d); + } + + @Override + public LineCursor copy() { + final var c = new LineCursor<>(center, access.copy()); + c.index = index; + return c; + } + + @Override + public void jumpFwd(final long steps) { + for (var i = 0; i < steps; i++) + fwd(); + } + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/morphology/geodesic/GeodesicDiameters.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/morphology/geodesic/GeodesicDiameters.java new file mode 100644 index 000000000..208f0d44e --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/morphology/geodesic/GeodesicDiameters.java @@ -0,0 +1,231 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.morphology.geodesic; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import net.imglib2.Point; +import net.imglib2.roi.labeling.ImgLabeling; +import net.imglib2.roi.labeling.LabelRegion; +import net.imglib2.roi.labeling.LabelRegions; +import net.imglib2.type.numeric.IntegerType; + +import org.scijava.ops.image.util.Calibrations; +import org.scijava.ops.spi.Nullable; + +/** + * Geodesic diameters of labeled regions: the length of the longest shortest + * path between two pixels of a region that stays inside the region. + *

+ * The diameter is found with the two-pass method of C. Lantuéjoul and + * S. Beucher ("On the use of the geodesic metric in image analysis", + * Journal of Microscopy 121(1): 39–49, 1981) as used by D. + * Legland and F. Beaugrand ("Automated clustering of lignocellulosic fibres + * based on morphometric features and using clustering of variables", + * Industrial Crops and Products 45: 253–261, 2013): starting + * from a central pixel of the region (one farthest from its boundary), the + * geodesically farthest pixel is the first extremity; the pixel farthest from + * the first extremity is the second one, and their geodesic distance is the + * diameter. This is exact for regions without holes in the continuous + * setting, and a close lower bound otherwise. Geodesic distances are + * computed as by {@code morphology.geodesicDistanceMap}, along the steps of a + * neighborhood of radius 1 or 2, in any number of dimensions. + *

+ *

+ * All ops measure each label on its own (a pixel carrying the labels {A, B} + * belongs to both A and B), and report results in the units of the pixel + * spacing. + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +public final class GeodesicDiameters { + + private GeodesicDiameters() { + // Prevent instantiation of static utility class. + } + + /** + * Computes the geodesic diameter of each label. The diameter is the + * geodesic distance between the centers of the two extremity pixels plus + * the length of the diagonal of one pixel, which accounts for the extent + * of the extremity pixels themselves (as in MorphoLibJ, whose chamfer + * distances otherwise differ slightly from these exact step lengths): a + * single pixel has diameter + * √2 in 2D, and a straight bar of {@code N} pixels has diameter + * {@code N - 1 + √2}. A label whose region is not connected (with respect + * to the steps of the neighborhood) has an infinite diameter. + * + * @param labeling the labeled objects + * @param calibration the pixel spacing along each dimension (default 1) + * @param radius the radius of the neighborhood of path steps, 1 (adjacent + * pixels) or 2 (also knight moves and their n-D analogs; default) + * @return a map from each label to its geodesic diameter + * @param label type + * @param index type of the labeling + * @implNote op names='labeling.geodesicDiameter', type=Function + */ + public static > Map diameters( + final ImgLabeling labeling, @Nullable double[] calibration, + @Nullable Integer radius) + { + final var s = Calibrations.spacing(calibration, labeling + .numDimensions(), "calibration"); + double diagonal = 0; + for (final var si : s) + diagonal += si * si; + diagonal = Math.sqrt(diagonal); + final var result = new LinkedHashMap(); + for (final var region : new LabelRegions<>(labeling)) { + if (region.inside().size() == 0) continue; + final var diameter = Diameter.of(region, s, radius); + result.put(region.getLabel(), diameter.connected ? diameter.length + + diagonal : Double.POSITIVE_INFINITY); + } + return result; + } + + /** + * Computes a geodesic diameter path of each label: a shortest path inside + * the region between the two extremities of its geodesic diameter (see + * {@code labeling.geodesicDiameter}). The first point of each path is the + * first extremity and the last point is the second one; consecutive + * points are steps of the neighborhood. For a region that is not + * connected, the path lies in the part containing its most central pixel. + * + * @param labeling the labeled objects + * @param calibration the pixel spacing along each dimension (default 1) + * @param radius the radius of the neighborhood of path steps, 1 (adjacent + * pixels) or 2 (also knight moves and their n-D analogs; default) + * @return a map from each label to its path, as pixel positions + * @param label type + * @param index type of the labeling + * @implNote op names='labeling.geodesicDiameterPath', type=Function + */ + public static > Map> paths( + final ImgLabeling labeling, @Nullable double[] calibration, + @Nullable Integer radius) + { + final var s = Calibrations.spacing(calibration, labeling + .numDimensions(), "calibration"); + final var result = new LinkedHashMap>(); + for (final var region : new LabelRegions<>(labeling)) { + if (region.inside().size() == 0) continue; + final var diameter = Diameter.of(region, s, radius); + final var graph = diameter.graph; + final var min = region.minAsLongArray(); + final var path = new ArrayList(); + for (var p = diameter.end; p >= 0; p = graph.pred[p]) { + final var pos = graph.position(p); + for (var d = 0; d < pos.length; d++) + pos[d] += min[d]; + path.add(new Point(pos)); + } + // Note: The predecessors lead from the second extremity to the first. + Collections.reverse(path); + result.put(region.getLabel(), path); + } + return result; + } + + /** The geodesic diameter of one region, in its bounding box. */ + private static final class Diameter { + + GeodesicGraph graph; + /** Index of the second extremity; predecessors lead to the first. */ + int end; + double length; + boolean connected; + + static Diameter of(final LabelRegion region, + final double[] calibration, final Integer radius) + { + final var dims = region.dimensionsAsLongArray(); + final var min = region.minAsLongArray(); + long total = 1; + for (final var d : dims) + total *= d; + if (total > Integer.MAX_VALUE - 8) { + throw new IllegalArgumentException("Region too large: " + total + + " pixels"); + } + final var ids = new int[(int) total]; + Arrays.fill(ids, -1); + final var cursor = region.inside().localizingCursor(); + var count = 0; + while (cursor.hasNext()) { + cursor.fwd(); + var index = 0L; + var stride = 1L; + for (var d = 0; d < dims.length; d++) { + index += (cursor.getLongPosition(d) - min[d]) * stride; + stride *= dims[d]; + } + ids[(int) index] = 0; + count++; + } + final var graph = new GeodesicGraph(dims, ids, calibration, + radius == null ? 2 : radius); + + // Find a central pixel: one farthest from the region boundary. + final var seeds = new int[count]; + var seedCount = 0; + for (var i = 0; i < ids.length; i++) + if (ids[i] == 0 && graph.isBorder(i)) seeds[seedCount++] = i; + graph.propagate(seeds, seedCount); + final var center = graph.farthest(); + + final var single = new int[1]; + single[0] = center; + graph.propagate(single, 1); + single[0] = graph.farthest(); + graph.propagate(single, 1); + + final var diameter = new Diameter(); + diameter.graph = graph; + diameter.end = graph.farthest(); + diameter.length = graph.dist[diameter.end]; + diameter.connected = true; + for (var i = 0; i < ids.length; i++) { + if (graph.dist[i] == Double.POSITIVE_INFINITY) { + diameter.connected = false; + break; + } + } + return diameter; + } + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/morphology/geodesic/GeodesicDistanceMaps.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/morphology/geodesic/GeodesicDistanceMaps.java new file mode 100644 index 000000000..6fd965cd9 --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/morphology/geodesic/GeodesicDistanceMaps.java @@ -0,0 +1,189 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.morphology.geodesic; + +import java.util.Arrays; + +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.img.array.ArrayImgs; +import net.imglib2.roi.labeling.ImgLabeling; +import net.imglib2.type.BooleanType; +import net.imglib2.type.numeric.IntegerType; +import net.imglib2.type.numeric.real.DoubleType; +import net.imglib2.util.Intervals; +import net.imglib2.view.Views; + +import org.scijava.ops.image.util.Calibrations; +import org.scijava.ops.spi.Nullable; + +/** + * Geodesic distance maps: the length of the shortest path from a marker to + * each pixel that stays inside a mask. + *

+ * Paths run between pixel centers along the steps of a discrete + * neighborhood, each weighted by its Euclidean length in the units of the + * pixel spacing, and the map is computed exactly for this weighted grid graph + * with the algorithm of E. W. Dijkstra ("A note on two problems in + * connexion with graphs", Numerische Mathematik 1: 269–271, + * 1959), in a single pass and in any number of dimensions. With neighborhood + * radius 1, the steps are those to the {@code 3^n - 1} adjacent pixels (in 2D + * a chamfer metric with weights 1 and √2); with radius 2, the steps + * also include the primitive offsets reaching two pixels away, such as the + * knight moves (1, 2) in 2D (weight √5), which reduces the anisotropy + * of the metric from about 8 % to about 3 % in 2D. Such a long step is only + * taken if one of the two pixels nearest to its midpoint is also inside the + * mask. Geodesic distances were introduced to image analysis by C. + * Lantuéjoul and S. Beucher ("On the use of the geodesic metric in + * image analysis", Journal of Microscopy 121(1): 39–49, 1981); + * the weighted neighborhoods follow the chamfer distances of G. Borgefors + * ("Distance transformations in digital images", Computer Vision, + * Graphics, and Image Processing 34(3): 344–371, 1986), with exact + * Euclidean step lengths instead of integer weights. + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +public final class GeodesicDistanceMaps { + + private GeodesicDistanceMaps() { + // Prevent instantiation of static utility class. + } + + /** + * Computes the geodesic distance from the marker to each pixel of the + * mask: the length of the shortest path from a marker pixel that stays + * inside the mask. Marker pixels have distance 0, mask pixels that no path + * from the marker reaches have positive infinity, and pixels outside the + * mask are {@code NaN}. Marker pixels outside the mask are ignored. + * + * @param marker the pixels from which distances are measured, with the + * same dimensions as the mask + * @param mask the pixels within which paths must stay + * @param calibration the pixel spacing along each dimension (default 1) + * @param radius the radius of the neighborhood of path steps, 1 (adjacent + * pixels) or 2 (also knight moves and their n-D analogs; default) + * @return the geodesic distance map, with the interval of the mask + * @param marker type + * @param mask type + * @implNote op names='morphology.geodesicDistanceMap', type=Function + */ + public static , M extends BooleanType> + RandomAccessibleInterval binary( + final RandomAccessibleInterval marker, + final RandomAccessibleInterval mask, + @Nullable double[] calibration, @Nullable Integer radius) + { + final var region = new int[checkedSize(mask)]; + var i = 0; + for (final var m : Views.flatIterable(mask)) + region[i++] = m.get() ? 0 : -1; + return compute(marker, mask, region, calibration, radius); + } + + /** + * Computes the geodesic distance from the marker to each labeled pixel, + * with paths that never leave the region of the pixel. Each distinct label + * set of the labeling is one region: a pixel carrying the labels {A, B} + * belongs to a region distinct from the pixels carrying only A. Labeled + * pixels that no path from the marker reaches (such as all pixels of a + * region without marker pixels) have positive infinity, and unlabeled + * pixels are {@code NaN}. Marker pixels outside the labels are ignored. + * + * @param marker the pixels from which distances are measured, with the + * same dimensions as the labeling + * @param labeling the regions within which paths must stay + * @param calibration the pixel spacing along each dimension (default 1) + * @param radius the radius of the neighborhood of path steps, 1 (adjacent + * pixels) or 2 (also knight moves and their n-D analogs; default) + * @return the geodesic distance map, with the interval of the labeling + * @param marker type + * @param label type + * @param index type of the labeling + * @implNote op names='morphology.geodesicDistanceMap', type=Function + */ + public static , L, I extends IntegerType> + RandomAccessibleInterval labeling( + final RandomAccessibleInterval marker, + final ImgLabeling labeling, @Nullable double[] calibration, + @Nullable Integer radius) + { + final var region = new int[checkedSize(labeling)]; + final var mapping = labeling.getMapping(); + final var empty = new boolean[mapping.numSets()]; + for (var s = 0; s < empty.length; s++) + empty[s] = mapping.labelsAtIndex(s).isEmpty(); + var i = 0; + for (final var index : Views.flatIterable(labeling.getIndexImg())) { + final var s = index.getInteger(); + region[i++] = empty[s] ? -1 : s; + } + return compute(marker, labeling, region, calibration, radius); + } + + private static > + RandomAccessibleInterval compute( + final RandomAccessibleInterval marker, + final RandomAccessibleInterval domain, final int[] region, + final double[] calibration, final Integer radius) + { + if (!Arrays.equals(marker.dimensionsAsLongArray(), domain + .dimensionsAsLongArray())) + { + throw new IllegalArgumentException( + "Marker and mask dimensions differ: " + Arrays.toString(marker + .dimensionsAsLongArray()) + " vs " + Arrays.toString(domain + .dimensionsAsLongArray())); + } + final var dims = domain.dimensionsAsLongArray(); + final var graph = new GeodesicGraph(dims, region, Calibrations + .spacing(calibration, dims.length, "calibration"), radius == null + ? 2 : radius); + final var seeds = new int[region.length]; + var count = 0; + var i = 0; + for (final var m : Views.flatIterable(marker)) { + if (m.get()) seeds[count++] = i; + i++; + } + graph.propagate(seeds, count); + return Views.translate(ArrayImgs.doubles(graph.dist, dims), domain + .minAsLongArray()); + } + + private static int checkedSize(final RandomAccessibleInterval image) { + final var size = Intervals.numElements(image); + if (size > Integer.MAX_VALUE - 8) { + throw new IllegalArgumentException("Image too large: " + size + + " pixels"); + } + return (int) size; + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/morphology/geodesic/GeodesicGraph.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/morphology/geodesic/GeodesicGraph.java new file mode 100644 index 000000000..c53eae23f --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/morphology/geodesic/GeodesicGraph.java @@ -0,0 +1,290 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.morphology.geodesic; + +import java.util.ArrayList; +import java.util.Arrays; + +import net.imglib2.algorithm.neighborhood.RectangleShape; + +import org.scijava.ops.image.util.Neighborhoods; + +/** + * A pixel grid graph for shortest-path (geodesic) distance propagation within + * regions. Each pixel belongs to a region (a non-negative id) or to none + * (id -1); edges connect pixels of the same region along the primitive + * offsets of the {@code (2r+1)^n} neighborhood, weighted by their Euclidean + * length in the units of the pixel spacing. Distances are computed with + * the algorithm of Dijkstra. + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +final class GeodesicGraph { + + final int size; + final int[] region; + final double[] dist; + final int[] pred; + + private final int n; + private final int[] dims; + private final int[] strides; + private final int[][] offsets; + private final int[] deltas; + private final double[] weights; + private final int[] via1; + private final int[] via2; + + GeodesicGraph(final long[] dimensions, final int[] region, + final double[] spacing, final int radius) + { + if (radius != 1 && radius != 2) { + throw new IllegalArgumentException( + "Neighborhood radius must be 1 or 2, but was " + radius); + } + n = dimensions.length; + dims = new int[n]; + strides = new int[n]; + long total = 1; + for (var d = 0; d < n; d++) { + dims[d] = (int) dimensions[d]; + strides[d] = (int) total; + total *= dimensions[d]; + } + if (total > Integer.MAX_VALUE - 8) { + throw new IllegalArgumentException("Image too large: " + total + + " pixels"); + } + size = (int) total; + this.region = region; + dist = new double[size]; + pred = new int[size]; + + final var offs = new ArrayList(); + for (final var v : Neighborhoods.rasterOffsets(new RectangleShape(radius, + false), n)) + { + if (isPrimitive(v)) offs.add(v); + } + final var count = offs.size(); + offsets = offs.toArray(new int[count][]); + deltas = new int[count]; + weights = new double[count]; + via1 = new int[count]; + via2 = new int[count]; + for (var k = 0; k < count; k++) { + final var o = offsets[k]; + double w2 = 0; + var max = 0; + for (var d = 0; d < n; d++) { + deltas[k] += o[d] * strides[d]; + w2 += o[d] * spacing[d] * o[d] * spacing[d]; + max = Math.max(max, Math.abs(o[d])); + } + weights[k] = Math.sqrt(w2); + if (max > 1) { + // Note: a step longer than one pixel must pass through one of the two + // pixels nearest to its midpoint, so it cannot jump across a gap. + for (var d = 0; d < n; d++) { + via1[k] += Math.floorDiv(o[d], 2) * strides[d]; + via2[k] += -Math.floorDiv(-o[d], 2) * strides[d]; + } + } + } + } + + /** Checks that the offset is non-zero and its components are coprime. */ + private static boolean isPrimitive(final int[] v) { + var g = 0; + for (final var c : v) + g = gcd(g, Math.abs(c)); + return g == 1; + } + + private static int gcd(final int a, final int b) { + return b == 0 ? a : gcd(b, a % b); + } + + /** + * Computes the geodesic distance from the nearest seed to every pixel of + * the seeds' regions. Pixels outside any region get {@code NaN}; region + * pixels not reachable from any seed get positive infinity. Seeds outside + * any region are ignored. Afterwards, {@link #pred} holds the predecessor + * of each reached pixel on a shortest path (-1 for seeds). + */ + void propagate(final int[] seeds, final int seedCount) { + for (var i = 0; i < size; i++) { + dist[i] = region[i] < 0 ? Double.NaN : Double.POSITIVE_INFINITY; + pred[i] = -1; + } + final var heap = new Heap(Math.max(16, seedCount)); + for (var s = 0; s < seedCount; s++) { + final var seed = seeds[s]; + if (region[seed] < 0 || dist[seed] == 0) continue; + dist[seed] = 0; + heap.push(0, seed); + } + final var pos = new int[n]; + while (!heap.isEmpty()) { + final var key = heap.minKey(); + final var p = heap.pop(); + if (key > dist[p]) continue; + var rem = p; + for (var d = 0; d < n; d++) { + pos[d] = rem % dims[d]; + rem /= dims[d]; + } + final var id = region[p]; + for (var k = 0; k < offsets.length; k++) { + if (!inBounds(pos, offsets[k])) continue; + final var q = p + deltas[k]; + if (region[q] != id) continue; + if (via1[k] != via2[k] && region[p + via1[k]] != id && + region[p + via2[k]] != id) continue; + final var dq = key + weights[k]; + if (dq < dist[q]) { + dist[q] = dq; + pred[q] = p; + heap.push(dq, q); + } + } + } + } + + private boolean inBounds(final int[] pos, final int[] offset) { + for (var d = 0; d < n; d++) { + final var c = pos[d] + offset[d]; + if (c < 0 || c >= dims[d]) return false; + } + return true; + } + + /** + * Checks whether the pixel lies on the edge of the grid or has a face + * neighbor in another region. + */ + boolean isBorder(final int p) { + var rem = p; + for (var d = 0; d < n; d++) { + final var c = rem % dims[d]; + rem /= dims[d]; + if (c == 0 || c == dims[d] - 1) return true; + if (region[p - strides[d]] != region[p] || // + region[p + strides[d]] != region[p]) return true; + } + return false; + } + + /** Decodes the grid position of a pixel index. */ + long[] position(final int p) { + final var pos = new long[n]; + var rem = p; + for (var d = 0; d < n; d++) { + pos[d] = rem % dims[d]; + rem /= dims[d]; + } + return pos; + } + + /** + * Returns the reached pixel with the largest finite distance (the first one + * on ties), or -1 if no pixel was reached. + */ + int farthest() { + var best = -1; + var max = Double.NEGATIVE_INFINITY; + for (var i = 0; i < size; i++) { + final var d = dist[i]; + if (d > max && d < Double.POSITIVE_INFINITY) { + max = d; + best = i; + } + } + return best; + } + + /** A binary min-heap of pixel indices keyed by distance. */ + private static final class Heap { + + private double[] keys; + private int[] values; + private int count; + + Heap(final int capacity) { + keys = new double[capacity]; + values = new int[capacity]; + } + + boolean isEmpty() { + return count == 0; + } + + double minKey() { + return keys[0]; + } + + void push(final double key, final int value) { + if (count == keys.length) { + keys = Arrays.copyOf(keys, 2 * count); + values = Arrays.copyOf(values, 2 * count); + } + var i = count++; + while (i > 0) { + final var parent = (i - 1) / 2; + if (keys[parent] <= key) break; + keys[i] = keys[parent]; + values[i] = values[parent]; + i = parent; + } + keys[i] = key; + values[i] = value; + } + + int pop() { + final var top = values[0]; + final var key = keys[--count]; + final var value = values[count]; + var i = 0; + while (true) { + var child = 2 * i + 1; + if (child >= count) break; + if (child + 1 < count && keys[child + 1] < keys[child]) child++; + if (keys[child] >= key) break; + keys[i] = keys[child]; + values[i] = values[child]; + i = child; + } + keys[i] = key; + values[i] = value; + return top; + } + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/morphology/gradient/MorphologicalGradients.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/morphology/gradient/MorphologicalGradients.java new file mode 100644 index 000000000..0a4ee68c2 --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/morphology/gradient/MorphologicalGradients.java @@ -0,0 +1,259 @@ +/* #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.morphology.gradient; + +import java.util.function.BiFunction; + +import net.imglib2.Dimensions; +import net.imglib2.IterableInterval; +import net.imglib2.RandomAccessible; +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.algorithm.neighborhood.Shape; +import net.imglib2.img.Img; +import net.imglib2.loops.LoopBuilder; +import net.imglib2.type.numeric.RealType; +import net.imglib2.util.Intervals; +import net.imglib2.view.Views; + +import org.scijava.function.Computers; +import org.scijava.ops.spi.OpDependency; + +/** + * Morphological gradients and Laplacian of grayscale images, in any number of + * dimensions, combining the dilation and erosion by a structuring element. + *

+ * With {@code dil} and {@code ero} the dilation and erosion of the input + * {@code f} by the given {@link Shape}, the ops compute: + *

+ *
    + *
  • {@code morphology.gradient}: {@code dil - ero} (the Beucher + * gradient),
  • + *
  • {@code morphology.internalGradient}: {@code f - ero},
  • + *
  • {@code morphology.externalGradient}: {@code dil - f},
  • + *
  • {@code morphology.laplacian}: {@code dil + ero - 2 f}, i.e. the + * external minus the internal gradient. It is negative on the bright side of + * edges, so use a signed or floating-point output type.
  • + *
+ *

+ * Pixels outside the image are ignored by the dilation and erosion. The + * arithmetic is done in double precision and written to the output with + * {@code setReal}. + *

+ *

+ * See P. Soille, Morphological Image Analysis, 2nd ed., Springer, + * 2003, and L. J. van Vliet, I. T. Young and G. L. Beckers, "A nonlinear + * Laplace operator as edge detector in noisy images", Computer Vision, + * Graphics, and Image Processing 45(2): 167–195, 1989. These ops correspond to the + * gradient and Laplacian filters of MorphoLibJ, whose Laplacian is half of + * the one computed here. + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +public final class MorphologicalGradients { + + private MorphologicalGradients() { + // Prevent instantiation of static utility class. + } + + /** + * Computes the morphological (Beucher) gradient: the dilation minus the + * erosion. + * + * @param createOp an op to create temporary images + * @param dilateOp the dilation op + * @param erodeOp the erosion op + * @param input the image to process + * @param shape the structuring element, such as a {@code RectangleShape} or + * {@code HyperSphereShape} + * @param output the gradient, of the same dimensions as {@code input} + * @param input type + * @param output type + * @implNote op names='morphology.gradient', type=Computer + */ + public static , R extends RealType> void gradient( + @OpDependency( + name = "create.img") BiFunction> createOp, + @OpDependency( + name = "morphology.dilate") Computers.Arity3, Shape, Integer, IterableInterval> dilateOp, + @OpDependency( + name = "morphology.erode") Computers.Arity3, Shape, Integer, IterableInterval> erodeOp, + final RandomAccessibleInterval input, final Shape shape, + final RandomAccessibleInterval output) + { + check(input, output); + final var dil = dilate(createOp, dilateOp, input, shape); + final var ero = erode(createOp, erodeOp, input, shape); + LoopBuilder.setImages(dil, ero, output).multiThreaded().forEachPixel((d, + e, o) -> o.setReal(d.getRealDouble() - e.getRealDouble())); + } + + /** + * Computes the internal morphological gradient: the image minus its + * erosion. + * + * @param createOp an op to create temporary images + * @param erodeOp the erosion op + * @param input the image to process + * @param shape the structuring element, such as a {@code RectangleShape} or + * {@code HyperSphereShape} + * @param output the gradient, of the same dimensions as {@code input} + * @param input type + * @param output type + * @implNote op names='morphology.internalGradient', type=Computer + */ + public static , R extends RealType> void + internalGradient( + @OpDependency( + name = "create.img") BiFunction> createOp, + @OpDependency( + name = "morphology.erode") Computers.Arity3, Shape, Integer, IterableInterval> erodeOp, + final RandomAccessibleInterval input, final Shape shape, + final RandomAccessibleInterval output) + { + check(input, output); + final var ero = erode(createOp, erodeOp, input, shape); + LoopBuilder.setImages(input, ero, output).multiThreaded().forEachPixel((f, + e, o) -> o.setReal(f.getRealDouble() - e.getRealDouble())); + } + + /** + * Computes the external morphological gradient: the dilation minus the + * image. + * + * @param createOp an op to create temporary images + * @param dilateOp the dilation op + * @param input the image to process + * @param shape the structuring element, such as a {@code RectangleShape} or + * {@code HyperSphereShape} + * @param output the gradient, of the same dimensions as {@code input} + * @param input type + * @param output type + * @implNote op names='morphology.externalGradient', type=Computer + */ + public static , R extends RealType> void + externalGradient( + @OpDependency( + name = "create.img") BiFunction> createOp, + @OpDependency( + name = "morphology.dilate") Computers.Arity3, Shape, Integer, IterableInterval> dilateOp, + final RandomAccessibleInterval input, final Shape shape, + final RandomAccessibleInterval output) + { + check(input, output); + final var dil = dilate(createOp, dilateOp, input, shape); + LoopBuilder.setImages(input, dil, output).multiThreaded().forEachPixel((f, + d, o) -> o.setReal(d.getRealDouble() - f.getRealDouble())); + } + + /** + * Computes the morphological Laplacian: the dilation plus the erosion minus + * twice the image, i.e. the external minus the internal gradient. + * + * @param createOp an op to create temporary images + * @param dilateOp the dilation op + * @param erodeOp the erosion op + * @param input the image to process + * @param shape the structuring element, such as a {@code RectangleShape} or + * {@code HyperSphereShape} + * @param output the Laplacian, of the same dimensions as {@code input}; + * should be of a signed type since the Laplacian can be negative + * @param input type + * @param output type + * @implNote op names='morphology.laplacian', type=Computer + */ + public static , R extends RealType> void laplacian( + @OpDependency( + name = "create.img") BiFunction> createOp, + @OpDependency( + name = "morphology.dilate") Computers.Arity3, Shape, Integer, IterableInterval> dilateOp, + @OpDependency( + name = "morphology.erode") Computers.Arity3, Shape, Integer, IterableInterval> erodeOp, + final RandomAccessibleInterval input, final Shape shape, + final RandomAccessibleInterval output) + { + check(input, output); + final var dil = dilate(createOp, dilateOp, input, shape); + final var ero = erode(createOp, erodeOp, input, shape); + LoopBuilder.setImages(input, dil, ero, output).multiThreaded() + .forEachPixel((f, d, e, o) -> o.setReal(d.getRealDouble() + e + .getRealDouble() - 2 * f.getRealDouble())); + } + + // -- Helper methods -- + + private static void check(final RandomAccessibleInterval input, + final RandomAccessibleInterval output) + { + if (!Intervals.equalDimensions(input, output)) { + throw new IllegalArgumentException( + "Input and output dimensions differ"); + } + } + + private static > RandomAccessibleInterval dilate( + final BiFunction> createOp, + final Computers.Arity3, Shape, Integer, IterableInterval> dilateOp, + final RandomAccessibleInterval input, final Shape shape) + { + final var type = input.getType().createVariable(); + type.setReal(type.getMinValue()); + final var result = temp(createOp, input); + dilateOp.compute(Views.extendValue(input, type), shape, threads(), Views + .iterable(result)); + return result; + } + + private static > RandomAccessibleInterval erode( + final BiFunction> createOp, + final Computers.Arity3, Shape, Integer, IterableInterval> erodeOp, + final RandomAccessibleInterval input, final Shape shape) + { + final var type = input.getType().createVariable(); + type.setReal(type.getMaxValue()); + final var result = temp(createOp, input); + erodeOp.compute(Views.extendValue(input, type), shape, threads(), Views + .iterable(result)); + return result; + } + + private static > RandomAccessibleInterval temp( + final BiFunction> createOp, + final RandomAccessibleInterval input) + { + final Img img = createOp.apply(input, input.getType().createVariable()); + return Views.isZeroMin(input) ? img : Views.translate(img, input + .minAsLongArray()); + } + + private static int threads() { + return Runtime.getRuntime().availableProcessors(); + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/morphology/interpolate/InterpolateSlices.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/morphology/interpolate/InterpolateSlices.java new file mode 100644 index 000000000..e72b84318 --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/morphology/interpolate/InterpolateSlices.java @@ -0,0 +1,159 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.morphology.interpolate; + +import java.util.ArrayList; +import java.util.function.BiFunction; + +import net.imglib2.Dimensions; +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.loops.LoopBuilder; +import net.imglib2.type.BooleanType; +import net.imglib2.type.logic.BitType; +import net.imglib2.type.numeric.real.FloatType; +import net.imglib2.view.Views; + +import org.scijava.function.Functions; +import org.scijava.ops.image.util.Calibrations; +import org.scijava.ops.spi.Nullable; +import org.scijava.ops.spi.OpDependency; + +/** + * Shape-based interpolation of a binary mask between sparsely annotated + * slices. + *

+ * Slices along {@code dim} that contain at least one foreground pixel are + * annotated; all other slices are empty. For each pair of consecutive + * annotated slices {@code a < b}, every empty slice {@code z} between them is + * filled where the linear interpolation of their signed distance maps (from + * {@code image.signedDistanceTransform}, positive inside), + * {@code ((b - z) sdt_a + (z - a) sdt_b) / (b - a)}, is non-negative. + * Annotated slices are copied unchanged, and empty slices before the first or + * after the last annotated slice stay empty. This is the usual way to + * complete a manual 3D segmentation drawn on a few slices. + *

+ *

+ * Implemented from S. P. Raya and J. K. Udupa, "Shape-based interpolation of + * multidimensional objects", IEEE Trans. Med. Imaging 9(1):32-42, 1990, and + * G. T. Herman, J. Zheng and C. A. Bucholtz, "Shape-based interpolation", IEEE + * Comput. Graph. Appl. 12(3):69-79, 1992, with exact Euclidean instead of + * chamfer distances. + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @param mask type + * @implNote op names='morphology.interpolateSlices' + */ +public class InterpolateSlices> implements + Functions.Arity3, Integer, double[], RandomAccessibleInterval> +{ + + @OpDependency(name = "image.signedDistanceTransform") + private BiFunction, double[], RandomAccessibleInterval> sdtOp; + + @OpDependency(name = "create.img") + private BiFunction> createOp; + + /** + * Interpolates the empty slices of a mask. + * + * @param mask the mask, at least 2D, with foreground drawn on some slices + * @param dim the dimension along which slices are taken (default: the last + * dimension) + * @param calibration the pixel spacing along each dimension of the mask, + * including {@code dim} (default: 1 in every dimension); only the + * spacing within a slice matters, since the interpolation between + * slices is linear in the slice index + * @return the interpolated mask + */ + @Override + public RandomAccessibleInterval apply( + final RandomAccessibleInterval mask, @Nullable final Integer dim, + @Nullable final double[] calibration) + { + final var n = mask.numDimensions(); + if (n < 2) throw new IllegalArgumentException("Mask must be at least 2D"); + final int d = dim == null ? n - 1 : dim; + if (d < 0 || d >= n) { + throw new IllegalArgumentException("No such dimension: " + d); + } + final var sliceCalibration = sliceCalibration(calibration, n, d); + final var output = createOp.apply(mask, new BitType()); + final long mMin = mask.min(d), oMin = output.min(d); + + final var annotated = new ArrayList(); + for (long z = 0; z < mask.dimension(d); z++) { + final var slice = Views.hyperSlice(mask, d, mMin + z); + var any = false; + for (final var b : Views.iterable(slice)) { + if (b.get()) { + any = true; + break; + } + } + if (!any) continue; + annotated.add(z); + LoopBuilder.setImages(slice, Views.hyperSlice(output, d, oMin + z)) + .forEachPixel((i, o) -> o.set(i.get())); + } + + RandomAccessibleInterval sdtB = null; + for (var i = 0; i + 1 < annotated.size(); i++) { + final long a = annotated.get(i), b = annotated.get(i + 1); + final var sdtA = sdtB != null ? sdtB : sdtOp.apply(Views.hyperSlice(mask, + d, mMin + a), sliceCalibration); + sdtB = sdtOp.apply(Views.hyperSlice(mask, d, mMin + b), + sliceCalibration); + final var span = (double) (b - a); + for (var z = a + 1; z < b; z++) { + final var wa = (b - z) / span; + final var wb = (z - a) / span; + LoopBuilder.setImages(sdtA, sdtB, Views.hyperSlice(output, d, oMin + + z)).forEachPixel((sa, sb, o) -> o.set(wa * sa.get() + wb * sb + .get() >= 0)); + } + } + return output; + } + + /** Drops the spacing along {@code d} from a validated calibration. */ + private static double[] sliceCalibration(final double[] calibration, + final int n, final int d) + { + if (calibration == null) return null; + final var c = Calibrations.spacing(calibration, n, "calibration"); + final var slice = new double[n - 1]; + var j = 0; + for (var i = 0; i < n; i++) + if (i != d) slice[j++] = c[i]; + return slice; + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/morphology/reconstruction/ExtendedMaxima.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/morphology/reconstruction/ExtendedMaxima.java new file mode 100644 index 000000000..77e7d246b --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/morphology/reconstruction/ExtendedMaxima.java @@ -0,0 +1,95 @@ +/* #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.morphology.reconstruction; + +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.algorithm.neighborhood.Shape; +import net.imglib2.type.BooleanType; +import net.imglib2.type.numeric.RealType; + +import org.scijava.function.Computers; +import org.scijava.ops.spi.Nullable; + +/** + * Marks the extended maxima of an image in a binary mask, in any number of + * dimensions: the regional maxima of its h-maxima transform (see + * {@code morphology.hMaxima}). + *

+ * These are the maxima whose dynamic (height above the highest saddle leading + * to a higher maximum) exceeds {@code h}, each marked together with the part + * of its peak lying above its value minus {@code h}. They are typical markers + * for a watershed segmentation of bright objects. + *

+ *

+ * See P. Soille, Morphological Image Analysis, 2nd ed., Springer, + * 2003, section 6.3.5. + *

+ *

+ * NaN input values are not supported and cause an + * {@link IllegalArgumentException}. Images are limited to 231 + * pixels. + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @param image type + * @param mask type + * @implNote op names='morphology.extendedMaxima' + */ +public class ExtendedMaxima, B extends BooleanType> + implements + Computers.Arity3, Double, Shape, RandomAccessibleInterval> +{ + + /** + * Finds the extended maxima. + * + * @param input the image in which to search for maxima + * @param h the (non-negative) minimal dynamic of the maxima to keep + * @param connectivity the neighborhood defining which pixels are adjacent, + * such as a {@code DiamondShape} of radius 1 for face connectivity + * (4-connectivity in 2D, 6-connectivity in 3D) or a + * {@code RectangleShape} of span 1 for full connectivity + * (8-connectivity in 2D, 26-connectivity in 3D). Defaults to full + * connectivity. + * @param output the mask, of the same dimensions as {@code input}; set to + * true on extended maxima and false elsewhere + */ + @Override + public void compute(final RandomAccessibleInterval input, final Double h, + @Nullable final Shape connectivity, + final RandomAccessibleInterval output) + { + final var grid = new PixelGrid(input, connectivity); + final var values = grid.values(input); + Reconstructions.hMaxima(values, h, Reconstructions.lowest(input + .getType()), grid); + grid.write(Reconstructions.regionalMaxima(values, grid), output); + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/morphology/reconstruction/ExtendedMinima.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/morphology/reconstruction/ExtendedMinima.java new file mode 100644 index 000000000..b3b6672cd --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/morphology/reconstruction/ExtendedMinima.java @@ -0,0 +1,95 @@ +/* #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.morphology.reconstruction; + +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.algorithm.neighborhood.Shape; +import net.imglib2.type.BooleanType; +import net.imglib2.type.numeric.RealType; + +import org.scijava.function.Computers; +import org.scijava.ops.spi.Nullable; + +/** + * Marks the extended minima of an image in a binary mask, in any number of + * dimensions: the regional minima of its h-minima transform (see + * {@code morphology.hMinima}). + *

+ * These are the minima whose dynamic (depth below the lowest saddle leading to + * a lower minimum) exceeds {@code h}, each marked together with the part of + * its basin lying below its value plus {@code h}. They are typical markers for + * a watershed segmentation (see also {@code morphology.imposeMinima}). + *

+ *

+ * See P. Soille, Morphological Image Analysis, 2nd ed., Springer, + * 2003, section 6.3.5. + *

+ *

+ * NaN input values are not supported and cause an + * {@link IllegalArgumentException}. Images are limited to 231 + * pixels. + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @param image type + * @param mask type + * @implNote op names='morphology.extendedMinima' + */ +public class ExtendedMinima, B extends BooleanType> + implements + Computers.Arity3, Double, Shape, RandomAccessibleInterval> +{ + + /** + * Finds the extended minima. + * + * @param input the image in which to search for minima + * @param h the (non-negative) minimal dynamic of the minima to keep + * @param connectivity the neighborhood defining which pixels are adjacent, + * such as a {@code DiamondShape} of radius 1 for face connectivity + * (4-connectivity in 2D, 6-connectivity in 3D) or a + * {@code RectangleShape} of span 1 for full connectivity + * (8-connectivity in 2D, 26-connectivity in 3D). Defaults to full + * connectivity. + * @param output the mask, of the same dimensions as {@code input}; set to + * true on extended minima and false elsewhere + */ + @Override + public void compute(final RandomAccessibleInterval input, final Double h, + @Nullable final Shape connectivity, + final RandomAccessibleInterval output) + { + final var grid = new PixelGrid(input, connectivity); + final var values = grid.values(input); + Reconstructions.hMinima(values, h, Reconstructions.highest(input + .getType()), grid); + grid.write(Reconstructions.regionalMinima(values, grid), output); + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/morphology/reconstruction/GrayscaleFillHoles.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/morphology/reconstruction/GrayscaleFillHoles.java new file mode 100644 index 000000000..f0a03ccff --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/morphology/reconstruction/GrayscaleFillHoles.java @@ -0,0 +1,93 @@ +/* #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.morphology.reconstruction; + +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.algorithm.neighborhood.Shape; +import net.imglib2.type.numeric.RealType; + +import org.scijava.function.Computers; +import org.scijava.ops.spi.Nullable; + +/** + * Fills the holes of a grayscale image, in any number of dimensions. + *

+ * A hole is a regional minimum not connected to the image border; it is + * filled up to the lowest level at which it can be reached from the border. + * The result is the reconstruction by erosion, over the input, of a marker + * equal to the input on the border and to the input maximum elsewhere. On a + * binary image this is equivalent to the {@code BooleanType} variant of + * {@code morphology.fillHoles}, which takes precedence for such images when + * a connectivity is given; since that variant requires one, a binary image + * passed without a connectivity takes this grayscale path, with the same + * result. + *

+ *

+ * See P. Soille, Morphological Image Analysis, 2nd ed., Springer, + * 2003, section 6.3.7. + *

+ *

+ * NaN input values are not supported and cause an + * {@link IllegalArgumentException}. Images are limited to 231 + * pixels. + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @param image type + * @implNote op names='morphology.fillHoles', priority='-100.' + */ +public class GrayscaleFillHoles> implements + Computers.Arity2, Shape, RandomAccessibleInterval> +{ + + /** + * Fills the holes. + * + * @param input the image to process + * @param connectivity the neighborhood defining which pixels are adjacent, + * such as a {@code DiamondShape} of radius 1 for face connectivity + * (4-connectivity in 2D, 6-connectivity in 3D) or a + * {@code RectangleShape} of span 1 for full connectivity + * (8-connectivity in 2D, 26-connectivity in 3D). Defaults to full + * connectivity. + * @param output the filled image, of the same dimensions as {@code input} + */ + @Override + public void compute(final RandomAccessibleInterval input, + @Nullable final Shape connectivity, + final RandomAccessibleInterval output) + { + final var grid = new PixelGrid(input, connectivity); + final var values = grid.values(input); + final var marker = Reconstructions.borderMarker(values, grid, true); + Reconstructions.erode(marker, values, grid); + grid.write(marker, output); + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/morphology/reconstruction/HMaxima.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/morphology/reconstruction/HMaxima.java new file mode 100644 index 000000000..7fa6600eb --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/morphology/reconstruction/HMaxima.java @@ -0,0 +1,91 @@ +/* #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.morphology.reconstruction; + +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.algorithm.neighborhood.Shape; +import net.imglib2.type.numeric.RealType; + +import org.scijava.function.Computers; +import org.scijava.ops.spi.Nullable; + +/** + * The h-maxima transform of an image, in any number of dimensions: the + * reconstruction by dilation of {@code input - h} under {@code input}. + *

+ * Every regional maximum whose dynamic (height above the highest saddle + * leading to a higher maximum) is at most {@code h} is suppressed, and the + * remaining maxima are lowered by {@code h}. For integer types, + * {@code input - h} saturates at the minimum value of the type. + *

+ *

+ * See P. Soille, Morphological Image Analysis, 2nd ed., Springer, + * 2003, section 6.3.4. + *

+ *

+ * NaN input values are not supported and cause an + * {@link IllegalArgumentException}. Images are limited to 231 + * pixels. + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @param image type + * @implNote op names='morphology.hMaxima' + */ +public class HMaxima> implements + Computers.Arity3, Double, Shape, RandomAccessibleInterval> +{ + + /** + * Computes the h-maxima transform. + * + * @param input the image to transform + * @param h the (non-negative) height of the maxima to suppress + * @param connectivity the neighborhood defining which pixels are adjacent, + * such as a {@code DiamondShape} of radius 1 for face connectivity + * (4-connectivity in 2D, 6-connectivity in 3D) or a + * {@code RectangleShape} of span 1 for full connectivity + * (8-connectivity in 2D, 26-connectivity in 3D). Defaults to full + * connectivity. + * @param output the transformed image, of the same dimensions as + * {@code input} + */ + @Override + public void compute(final RandomAccessibleInterval input, final Double h, + @Nullable final Shape connectivity, + final RandomAccessibleInterval output) + { + final var grid = new PixelGrid(input, connectivity); + final var values = grid.values(input); + Reconstructions.hMaxima(values, h, Reconstructions.lowest(input + .getType()), grid); + grid.write(values, output); + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/morphology/reconstruction/HMinima.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/morphology/reconstruction/HMinima.java new file mode 100644 index 000000000..3d083fe3a --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/morphology/reconstruction/HMinima.java @@ -0,0 +1,91 @@ +/* #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.morphology.reconstruction; + +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.algorithm.neighborhood.Shape; +import net.imglib2.type.numeric.RealType; + +import org.scijava.function.Computers; +import org.scijava.ops.spi.Nullable; + +/** + * The h-minima transform of an image, in any number of dimensions: the + * reconstruction by erosion of {@code input + h} over {@code input}. + *

+ * Every regional minimum whose dynamic (depth below the lowest saddle leading + * to a lower minimum) is at most {@code h} is filled, and the remaining minima + * are raised by {@code h}. For integer types, {@code input + h} saturates at + * the maximum value of the type. + *

+ *

+ * See P. Soille, Morphological Image Analysis, 2nd ed., Springer, + * 2003, section 6.3.4. + *

+ *

+ * NaN input values are not supported and cause an + * {@link IllegalArgumentException}. Images are limited to 231 + * pixels. + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @param image type + * @implNote op names='morphology.hMinima' + */ +public class HMinima> implements + Computers.Arity3, Double, Shape, RandomAccessibleInterval> +{ + + /** + * Computes the h-minima transform. + * + * @param input the image to transform + * @param h the (non-negative) depth of the minima to suppress + * @param connectivity the neighborhood defining which pixels are adjacent, + * such as a {@code DiamondShape} of radius 1 for face connectivity + * (4-connectivity in 2D, 6-connectivity in 3D) or a + * {@code RectangleShape} of span 1 for full connectivity + * (8-connectivity in 2D, 26-connectivity in 3D). Defaults to full + * connectivity. + * @param output the transformed image, of the same dimensions as + * {@code input} + */ + @Override + public void compute(final RandomAccessibleInterval input, final Double h, + @Nullable final Shape connectivity, + final RandomAccessibleInterval output) + { + final var grid = new PixelGrid(input, connectivity); + final var values = grid.values(input); + Reconstructions.hMinima(values, h, Reconstructions.highest(input + .getType()), grid); + grid.write(values, output); + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/morphology/reconstruction/ImposeMaxima.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/morphology/reconstruction/ImposeMaxima.java new file mode 100644 index 000000000..bed65c3c5 --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/morphology/reconstruction/ImposeMaxima.java @@ -0,0 +1,110 @@ +/* #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.morphology.reconstruction; + +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.algorithm.neighborhood.Shape; +import net.imglib2.type.BooleanType; +import net.imglib2.type.numeric.RealType; + +import org.scijava.function.Computers; +import org.scijava.ops.spi.Nullable; + +/** + * Imposes maxima on an image, in any number of dimensions: modifies the image + * so that its only regional maxima are the given markers. + *

+ * This is the dual of {@code morphology.imposeMinima}. Marker pixels are set to + * the highest value of the type, and every other pixel is lowered to the + * highest level at which it can be reached from a marker. This is the + * reconstruction by dilation of the marker image under + * {@code max(input - e, marker image)}, where the marker image is highest on + * markers and lowest elsewhere. For integer types, {@code e} is 1 (with + * saturation at the minimum value) and the highest value is the maximum value + * of the type; for floating point types, {@code e} is 0 and the highest value + * is positive infinity. + *

+ *

+ * See P. Soille, Morphological Image Analysis, 2nd ed., Springer, + * 2003, section 6.3.6. + *

+ *

+ * NaN input values are not supported and cause an + * {@link IllegalArgumentException}. Images are limited to 231 + * pixels. + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @param image type + * @param marker type + * @implNote op names='morphology.imposeMaxima' + */ +public class ImposeMaxima, B extends BooleanType> + implements + Computers.Arity3, RandomAccessibleInterval, Shape, RandomAccessibleInterval> +{ + + /** + * Imposes the maxima. + * + * @param input the image to modify + * @param markers the binary marker image, of the same dimensions as + * {@code input}, true where maxima are to be imposed + * @param connectivity the neighborhood defining which pixels are adjacent, + * such as a {@code DiamondShape} of radius 1 for face connectivity + * (4-connectivity in 2D, 6-connectivity in 3D) or a + * {@code RectangleShape} of span 1 for full connectivity + * (8-connectivity in 2D, 26-connectivity in 3D). Defaults to full + * connectivity. + * @param output the modified image, of the same dimensions as + * {@code input} + */ + @Override + public void compute(final RandomAccessibleInterval input, + final RandomAccessibleInterval markers, + @Nullable final Shape connectivity, + final RandomAccessibleInterval output) + { + final var grid = new PixelGrid(input, connectivity); + final var type = input.getType(); + final var lowest = Reconstructions.lowest(type); + final var highest = Reconstructions.highest(type); + final var e = Reconstructions.increment(type); + final var mask = grid.values(input); + final var marked = grid.flags(markers); + final var values = new double[grid.size]; + for (var i = 0; i < grid.size; i++) { + values[i] = marked[i] ? highest : lowest; + mask[i] = marked[i] ? highest : Math.max(mask[i] - e, lowest); + } + Reconstructions.dilate(values, mask, grid); + grid.write(values, output); + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/morphology/reconstruction/ImposeMinima.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/morphology/reconstruction/ImposeMinima.java new file mode 100644 index 000000000..e23763aa1 --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/morphology/reconstruction/ImposeMinima.java @@ -0,0 +1,111 @@ +/* #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.morphology.reconstruction; + +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.algorithm.neighborhood.Shape; +import net.imglib2.type.BooleanType; +import net.imglib2.type.numeric.RealType; + +import org.scijava.function.Computers; +import org.scijava.ops.spi.Nullable; + +/** + * Imposes minima on an image, in any number of dimensions: modifies the image + * so that its only regional minima are the given markers. + *

+ * Marker pixels are set to the lowest value of the type, and every other pixel + * is raised to the lowest level at which it can be reached from a marker, + * which fills every basin not containing a marker. This is the reconstruction + * by erosion of the marker image over {@code min(input + e, marker image)}, + * where the marker image is lowest on markers and highest elsewhere. For + * integer types, {@code e} is 1 (so that the markers are strictly lower than + * all other pixels, at the price of increasing the other pixels by 1, with + * saturation at the maximum value), and the lowest value is the minimum value + * of the type; for floating point types, {@code e} is 0 and the lowest value + * is negative infinity. + *

+ *

+ * See P. Soille, Morphological Image Analysis, 2nd ed., Springer, + * 2003, section 6.3.6. + *

+ *

+ * NaN input values are not supported and cause an + * {@link IllegalArgumentException}. Images are limited to 231 + * pixels. + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @param image type + * @param marker type + * @implNote op names='morphology.imposeMinima' + */ +public class ImposeMinima, B extends BooleanType> + implements + Computers.Arity3, RandomAccessibleInterval, Shape, RandomAccessibleInterval> +{ + + /** + * Imposes the minima. + * + * @param input the image to modify + * @param markers the binary marker image, of the same dimensions as + * {@code input}, true where minima are to be imposed + * @param connectivity the neighborhood defining which pixels are adjacent, + * such as a {@code DiamondShape} of radius 1 for face connectivity + * (4-connectivity in 2D, 6-connectivity in 3D) or a + * {@code RectangleShape} of span 1 for full connectivity + * (8-connectivity in 2D, 26-connectivity in 3D). Defaults to full + * connectivity. + * @param output the modified image, of the same dimensions as + * {@code input} + */ + @Override + public void compute(final RandomAccessibleInterval input, + final RandomAccessibleInterval markers, + @Nullable final Shape connectivity, + final RandomAccessibleInterval output) + { + final var grid = new PixelGrid(input, connectivity); + final var type = input.getType(); + final var lowest = Reconstructions.lowest(type); + final var highest = Reconstructions.highest(type); + final var e = Reconstructions.increment(type); + final var mask = grid.values(input); + final var marked = grid.flags(markers); + final var values = new double[grid.size]; + for (var i = 0; i < grid.size; i++) { + values[i] = marked[i] ? lowest : highest; + mask[i] = marked[i] ? lowest : Math.min(mask[i] + e, highest); + } + Reconstructions.erode(values, mask, grid); + grid.write(values, output); + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/morphology/reconstruction/KillBorders.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/morphology/reconstruction/KillBorders.java new file mode 100644 index 000000000..02cb9aa5b --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/morphology/reconstruction/KillBorders.java @@ -0,0 +1,94 @@ +/* #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.morphology.reconstruction; + +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.algorithm.neighborhood.Shape; +import net.imglib2.type.numeric.RealType; + +import org.scijava.function.Computers; +import org.scijava.ops.spi.Nullable; + +/** + * Removes the structures touching the image border, in any number of + * dimensions, for grayscale and binary ({@code BitType}) images alike. + *

+ * The result is {@code input - R}, where {@code R} is the reconstruction by + * dilation, under the input, of a marker equal to the input on the border and + * to the input minimum elsewhere. For binary images, this removes the + * foreground components touching the border; for grayscale images, each + * pixel is lowered by the highest level at which it is connected to the + * border. + *

+ *

+ * See P. Soille, Morphological Image Analysis, 2nd ed., Springer, + * 2003, section 6.3.8. + *

+ *

+ * NaN input values are not supported and cause an + * {@link IllegalArgumentException}. Images are limited to 231 + * pixels. + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @param image type + * @implNote op names='morphology.killBorders' + */ +public class KillBorders> implements + Computers.Arity2, Shape, RandomAccessibleInterval> +{ + + /** + * Removes the border structures. + * + * @param input the image to process + * @param connectivity the neighborhood defining which pixels are adjacent, + * such as a {@code DiamondShape} of radius 1 for face connectivity + * (4-connectivity in 2D, 6-connectivity in 3D) or a + * {@code RectangleShape} of span 1 for full connectivity + * (8-connectivity in 2D, 26-connectivity in 3D). Defaults to full + * connectivity. + * @param output the processed image, of the same dimensions as + * {@code input} + */ + @Override + public void compute(final RandomAccessibleInterval input, + @Nullable final Shape connectivity, + final RandomAccessibleInterval output) + { + final var grid = new PixelGrid(input, connectivity); + final var values = grid.values(input); + final var marker = Reconstructions.borderMarker(values, grid, false); + Reconstructions.dilate(marker, values, grid); + for (var i = 0; i < grid.size; i++) + values[i] -= marker[i]; + grid.write(values, output); + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/morphology/reconstruction/PixelGrid.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/morphology/reconstruction/PixelGrid.java new file mode 100644 index 000000000..07f932ebb --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/morphology/reconstruction/PixelGrid.java @@ -0,0 +1,288 @@ +/* #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.morphology.reconstruction; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.algorithm.neighborhood.RectangleShape; +import net.imglib2.algorithm.neighborhood.Shape; +import net.imglib2.type.BooleanType; +import net.imglib2.type.numeric.RealType; +import net.imglib2.util.Intervals; +import net.imglib2.view.Views; + +import org.scijava.ops.image.util.Neighborhoods; + +/** + * A flattened n-dimensional pixel grid (first dimension varying fastest) + * together with a symmetric neighborhood (the connectivity), as used by the + * reconstruction-based ops of this package. + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +final class PixelGrid { + + /** Number of dimensions. */ + final int n; + + /** Size of each dimension. */ + final int[] dims; + + /** Linear index step of each dimension. */ + final int[] strides; + + /** Total number of pixels. */ + final int size; + + /** Neighbor offsets (symmetric, excluding the origin). */ + final int[][] offsets; + + /** Linear index step of each neighbor offset. */ + final int[] steps; + + /** Indices of the offsets preceding the origin in raster order. */ + final int[] causal; + + /** Indices of the offsets following the origin in raster order. */ + final int[] anticausal; + + /** + * Creates a grid with the dimensions of the given interval. + * + * @param interval the image whose dimensions to use + * @param connectivity the neighborhood defining which pixels are adjacent, + * or null for full connectivity (a {@link RectangleShape} of span + * 1, i.e. 8-connectivity in 2D and 26-connectivity in 3D) + */ + PixelGrid(final RandomAccessibleInterval interval, + final Shape connectivity) + { + n = interval.numDimensions(); + if (Intervals.numElements(interval) > Integer.MAX_VALUE - 8) { + throw new IllegalArgumentException( + "Images with more than 2^31 pixels are not supported"); + } + dims = new int[n]; + strides = new int[n]; + var s = 1; + for (var d = 0; d < n; d++) { + dims[d] = (int) interval.dimension(d); + strides[d] = s; + s *= dims[d]; + } + size = s; + + offsets = Neighborhoods.neighborOffsets(connectivity, n); + steps = new int[offsets.length]; + final List before = new ArrayList<>(); + final List after = new ArrayList<>(); + for (var k = 0; k < offsets.length; k++) { + var step = 0; + for (var d = 0; d < n; d++) + step += offsets[k][d] * strides[d]; + steps[k] = step; + if (precedes(offsets[k])) before.add(k); + else after.add(k); + } + causal = before.stream().mapToInt(Integer::intValue).toArray(); + anticausal = after.stream().mapToInt(Integer::intValue).toArray(); + } + + /** + * Tests whether the neighbor at the given offset lies inside the grid. + * + * @param pos the position of the center pixel + * @param k the index of the neighbor offset + * @return true if the neighbor is inside the grid + */ + boolean contains(final int[] pos, final int k) { + final var o = offsets[k]; + for (var d = 0; d < n; d++) { + final var c = pos[d] + o[d]; + if (c < 0 || c >= dims[d]) return false; + } + return true; + } + + /** + * Computes the position of the pixel with the given linear index. + * + * @param index the linear index + * @param pos the array to fill with the position + */ + void position(int index, final int[] pos) { + for (var d = 0; d < n; d++) { + pos[d] = index % dims[d]; + index /= dims[d]; + } + } + + /** + * Advances a position by one pixel in raster order. + * + * @param pos the position to advance + */ + void forward(final int[] pos) { + for (var d = 0; d < n; d++) { + if (++pos[d] < dims[d]) return; + pos[d] = 0; + } + } + + /** + * Moves a position back by one pixel in raster order. + * + * @param pos the position to move + */ + void backward(final int[] pos) { + for (var d = 0; d < n; d++) { + if (--pos[d] >= 0) return; + pos[d] = dims[d] - 1; + } + } + + /** + * Tests whether a position lies on the border of the grid. + * + * @param pos the position to test + * @return true if any coordinate is at the minimum or maximum of its + * dimension + */ + boolean onBorder(final int[] pos) { + for (var d = 0; d < n; d++) { + if (pos[d] == 0 || pos[d] == dims[d] - 1) return true; + } + return false; + } + + /** + * Copies the values of an image into a new array, in flat iteration order. + * + * @param image the image to copy + * @return the values of the image + * @throws IllegalArgumentException if the image contains NaN, which the + * raster scans would otherwise spread over the whole output (as + * in {@code ComponentTree}) + */ + double[] values(final RandomAccessibleInterval> image) { + checkDimensions(image); + final var values = new double[size]; + var i = 0; + for (final var t : Views.flatIterable(image)) { + final var v = t.getRealDouble(); + if (Double.isNaN(v)) { + throw new IllegalArgumentException("NaN values are not supported"); + } + values[i++] = v; + } + return values; + } + + /** + * Writes values into an image, in flat iteration order. + * + * @param values the values to write + * @param image the image to write into + */ + void write(final double[] values, + final RandomAccessibleInterval> image) + { + checkDimensions(image); + var i = 0; + for (final var t : Views.flatIterable(image)) + t.setReal(values[i++]); + } + + /** + * Copies the values of a binary image into a new array, in flat iteration + * order. + * + * @param image the image to copy + * @return the flags of the image + */ + boolean[] flags( + final RandomAccessibleInterval> image) + { + checkDimensions(image); + final var flags = new boolean[size]; + var i = 0; + for (final var t : Views.flatIterable(image)) + flags[i++] = t.get(); + return flags; + } + + /** + * Writes flags into a binary image, in flat iteration order. + * + * @param flags the flags to write + * @param image the image to write into + */ + void write(final boolean[] flags, + final RandomAccessibleInterval> image) + { + checkDimensions(image); + var i = 0; + for (final var t : Views.flatIterable(image)) + t.set(flags[i++]); + } + + /** + * Checks that an image has the dimensions of this grid. + * + * @param image the image to check + */ + void checkDimensions(final RandomAccessibleInterval image) { + if (image.numDimensions() != n) { + throw new IllegalArgumentException("Expected " + n + + " dimensions but got " + image.numDimensions()); + } + for (var d = 0; d < n; d++) { + if (image.dimension(d) != dims[d]) { + throw new IllegalArgumentException("Image dimensions " + Arrays + .toString(image.dimensionsAsLongArray()) + " do not match " + Arrays + .toString(dims)); + } + } + } + + /** + * Tests whether an offset precedes the origin in raster order (the last + * dimension being the most significant). + */ + private static boolean precedes(final int[] offset) { + for (var d = offset.length - 1; d >= 0; d--) { + if (offset[d] != 0) return offset[d] < 0; + } + return false; + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/morphology/reconstruction/ReconstructByDilation.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/morphology/reconstruction/ReconstructByDilation.java new file mode 100644 index 000000000..e2619aa01 --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/morphology/reconstruction/ReconstructByDilation.java @@ -0,0 +1,93 @@ +/* #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.morphology.reconstruction; + +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.algorithm.neighborhood.Shape; +import net.imglib2.type.numeric.RealType; + +import org.scijava.function.Computers; +import org.scijava.ops.spi.Nullable; + +/** + * Grayscale morphological reconstruction by dilation of a marker image under a + * mask image, in any number of dimensions. + *

+ * The marker is repeatedly dilated (with the given connectivity) and clipped to + * the mask until stability. Each pixel of the result is the largest value + * {@code v} such that the pixel is connected to a marker pixel of value at + * least {@code v} by a path along which the mask is at least {@code v}. In + * particular, each regional maximum of the mask which is not marked is + * flattened down to the level at which it meets the marker. + *

+ *

+ * Implemented with the hybrid algorithm of L. Vincent, "Morphological grayscale + * reconstruction in image analysis: applications and efficient algorithms", + * IEEE Transactions on Image Processing 2(2):176-201, 1993. See also P. Soille, + * Morphological Image Analysis, 2nd ed., Springer, 2003, section 6.2. + *

+ *

+ * NaN input values are not supported and cause an + * {@link IllegalArgumentException}. Images are limited to 231 + * pixels. + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @param image type + * @implNote op names='morphology.reconstructByDilation' + */ +public class ReconstructByDilation> implements + Computers.Arity3, RandomAccessibleInterval, Shape, RandomAccessibleInterval> +{ + + /** + * Computes the reconstruction by dilation. + * + * @param marker the marker image; values above the mask are clipped to it + * @param mask the mask image, of the same dimensions as the marker + * @param connectivity the neighborhood defining which pixels are adjacent, + * such as a {@code DiamondShape} of radius 1 for face connectivity + * (4-connectivity in 2D, 6-connectivity in 3D) or a + * {@code RectangleShape} of span 1 for full connectivity + * (8-connectivity in 2D, 26-connectivity in 3D). Defaults to full + * connectivity. + * @param output the reconstruction, of the same dimensions as the marker + */ + @Override + public void compute(final RandomAccessibleInterval marker, + final RandomAccessibleInterval mask, @Nullable final Shape connectivity, + final RandomAccessibleInterval output) + { + final var grid = new PixelGrid(marker, connectivity); + final var values = grid.values(marker); + Reconstructions.dilate(values, grid.values(mask), grid); + grid.write(values, output); + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/morphology/reconstruction/ReconstructByErosion.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/morphology/reconstruction/ReconstructByErosion.java new file mode 100644 index 000000000..3cee01481 --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/morphology/reconstruction/ReconstructByErosion.java @@ -0,0 +1,91 @@ +/* #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.morphology.reconstruction; + +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.algorithm.neighborhood.Shape; +import net.imglib2.type.numeric.RealType; + +import org.scijava.function.Computers; +import org.scijava.ops.spi.Nullable; + +/** + * Grayscale morphological reconstruction by erosion of a marker image over a + * mask image, in any number of dimensions. + *

+ * The marker is repeatedly eroded (with the given connectivity) and clipped + * from below by the mask until stability. This is the dual of + * {@code morphology.reconstructByDilation}: each regional minimum of the mask + * which is not marked is filled up to the level at which it meets the marker. + *

+ *

+ * Implemented with the hybrid algorithm of L. Vincent, "Morphological grayscale + * reconstruction in image analysis: applications and efficient algorithms", + * IEEE Transactions on Image Processing 2(2):176-201, 1993. See also P. Soille, + * Morphological Image Analysis, 2nd ed., Springer, 2003, section 6.2. + *

+ *

+ * NaN input values are not supported and cause an + * {@link IllegalArgumentException}. Images are limited to 231 + * pixels. + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @param image type + * @implNote op names='morphology.reconstructByErosion' + */ +public class ReconstructByErosion> implements + Computers.Arity3, RandomAccessibleInterval, Shape, RandomAccessibleInterval> +{ + + /** + * Computes the reconstruction by erosion. + * + * @param marker the marker image; values below the mask are raised to it + * @param mask the mask image, of the same dimensions as the marker + * @param connectivity the neighborhood defining which pixels are adjacent, + * such as a {@code DiamondShape} of radius 1 for face connectivity + * (4-connectivity in 2D, 6-connectivity in 3D) or a + * {@code RectangleShape} of span 1 for full connectivity + * (8-connectivity in 2D, 26-connectivity in 3D). Defaults to full + * connectivity. + * @param output the reconstruction, of the same dimensions as the marker + */ + @Override + public void compute(final RandomAccessibleInterval marker, + final RandomAccessibleInterval mask, @Nullable final Shape connectivity, + final RandomAccessibleInterval output) + { + final var grid = new PixelGrid(marker, connectivity); + final var values = grid.values(marker); + Reconstructions.erode(values, grid.values(mask), grid); + grid.write(values, output); + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/morphology/reconstruction/Reconstructions.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/morphology/reconstruction/Reconstructions.java new file mode 100644 index 000000000..81c4dd82c --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/morphology/reconstruction/Reconstructions.java @@ -0,0 +1,362 @@ +/* #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.morphology.reconstruction; + +import java.util.Arrays; + +import net.imglib2.type.numeric.IntegerType; +import net.imglib2.type.numeric.RealType; + +/** + * Algorithms shared by the ops of this package, operating on flattened pixel + * arrays described by a {@link PixelGrid}. + *

+ * Grayscale reconstruction uses the hybrid algorithm of Vincent: one raster + * scan and one anti-raster scan propagate values through the causal and + * anti-causal halves of the neighborhood, after which the pixels that may + * still propagate are processed with a FIFO queue until stability. + *

+ *

+ * References: + *

+ *
    + *
  • L. Vincent, "Morphological grayscale reconstruction in image analysis: + * applications and efficient algorithms", IEEE Transactions on Image + * Processing 2(2):176-201, 1993.
  • + *
  • P. Soille, Morphological Image Analysis: Principles and + * Applications, 2nd ed., Springer, 2003, chapter 6.
  • + *
+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +final class Reconstructions { + + private Reconstructions() { + // Prevent instantiation of static utility class. + } + + /** + * Computes the reconstruction by dilation of a marker under a mask, in + * place. The marker is first clipped to be at most the mask. + * + * @param marker the marker values, replaced by the reconstruction + * @param mask the mask values + * @param grid the pixel grid and connectivity + */ + static void dilate(final double[] marker, final double[] mask, + final PixelGrid grid) + { + final var size = grid.size; + final var steps = grid.steps; + final var pos = new int[grid.n]; + + // Forward scan, with the causal half of the neighborhood. + for (var i = 0; i < size; i++, grid.forward(pos)) { + var v = marker[i]; + for (final var k : grid.causal) { + if (grid.contains(pos, k)) v = Math.max(v, marker[i + steps[k]]); + } + marker[i] = Math.min(v, mask[i]); + } + + // Backward scan, with the anti-causal half of the neighborhood. Pixels + // which could still raise an anti-causal neighbor are queued. + final var queue = new IntQueue(); + grid.backward(pos); + for (var i = size - 1; i >= 0; i--, grid.backward(pos)) { + var v = marker[i]; + for (final var k : grid.anticausal) { + if (grid.contains(pos, k)) v = Math.max(v, marker[i + steps[k]]); + } + v = Math.min(v, mask[i]); + marker[i] = v; + for (final var k : grid.anticausal) { + if (!grid.contains(pos, k)) continue; + final var q = i + steps[k]; + if (marker[q] < v && marker[q] < mask[q]) { + queue.add(i); + break; + } + } + } + + // Propagation until stability. + while (!queue.isEmpty()) { + final var p = queue.remove(); + grid.position(p, pos); + final var v = marker[p]; + for (var k = 0; k < steps.length; k++) { + if (!grid.contains(pos, k)) continue; + final var q = p + steps[k]; + if (marker[q] < v && marker[q] != mask[q]) { + marker[q] = Math.min(v, mask[q]); + queue.add(q); + } + } + } + } + + /** + * Computes the reconstruction by erosion of a marker over a mask, in place. + * The marker is first clipped to be at least the mask. + * + * @param marker the marker values, replaced by the reconstruction + * @param mask the mask values + * @param grid the pixel grid and connectivity + */ + static void erode(final double[] marker, final double[] mask, + final PixelGrid grid) + { + negate(marker); + final var negMask = mask.clone(); + negate(negMask); + dilate(marker, negMask, grid); + negate(marker); + } + + /** + * Finds the regional maxima of an image: the connected plateaus of constant + * value with no neighbor of higher value. + * + * @param values the image values + * @param grid the pixel grid and connectivity + * @return a flag per pixel, true for regional maxima + */ + static boolean[] regionalMaxima(final double[] values, + final PixelGrid grid) + { + final var size = grid.size; + final var steps = grid.steps; + final var pos = new int[grid.n]; + final var visited = new boolean[size]; + final var result = new boolean[size]; + final var plateau = new IntQueue(); + for (var start = 0; start < size; start++) { + if (visited[start]) continue; + final var v = values[start]; + var maximum = true; + plateau.clear(); + plateau.add(start); + visited[start] = true; + // Note: the queue keeps all its elements, so that after the flood + // fill they can be listed again to mark the plateau. + for (var head = 0; head < plateau.length(); head++) { + final var p = plateau.get(head); + grid.position(p, pos); + for (var k = 0; k < steps.length; k++) { + if (!grid.contains(pos, k)) continue; + final var q = p + steps[k]; + final var w = values[q]; + if (w > v) maximum = false; + else if (w == v && !visited[q]) { + visited[q] = true; + plateau.add(q); + } + } + } + if (maximum) { + for (var j = 0; j < plateau.length(); j++) + result[plateau.get(j)] = true; + } + } + return result; + } + + /** + * Finds the regional minima of an image: the connected plateaus of constant + * value with no neighbor of lower value. + * + * @param values the image values (not modified) + * @param grid the pixel grid and connectivity + * @return a flag per pixel, true for regional minima + */ + static boolean[] regionalMinima(final double[] values, + final PixelGrid grid) + { + final var negated = values.clone(); + negate(negated); + return regionalMaxima(negated, grid); + } + + /** + * Computes the h-maxima transform: the reconstruction by dilation of + * {@code f - h} under {@code f}, which suppresses the maxima whose dynamic + * is at most {@code h}. + * + * @param values the image values, replaced by the transform + * @param h the height + * @param lowest the lowest value representable by the image type + * @param grid the pixel grid and connectivity + */ + static void hMaxima(final double[] values, final double h, + final double lowest, final PixelGrid grid) + { + checkHeight(h); + final var mask = values.clone(); + for (var i = 0; i < values.length; i++) + values[i] = Math.max(values[i] - h, lowest); + dilate(values, mask, grid); + } + + /** + * Computes the h-minima transform: the reconstruction by erosion of + * {@code f + h} over {@code f}, which suppresses the minima whose dynamic + * is at most {@code h}. + * + * @param values the image values, replaced by the transform + * @param h the height + * @param highest the highest value representable by the image type + * @param grid the pixel grid and connectivity + */ + static void hMinima(final double[] values, final double h, + final double highest, final PixelGrid grid) + { + checkHeight(h); + final var mask = values.clone(); + for (var i = 0; i < values.length; i++) + values[i] = Math.min(values[i] + h, highest); + erode(values, mask, grid); + } + + /** + * Gets the smallest increment between two values of a type: 1 for integer + * types and 0 for floating point types. + * + * @param type an instance of the type + * @return the increment + */ + static double increment(final RealType type) { + return type instanceof IntegerType ? 1 : 0; + } + + /** + * Gets the lowest value of a type: its minimum value for integer types and + * negative infinity for floating point types. + * + * @param type an instance of the type + * @return the lowest value + */ + static double lowest(final RealType type) { + return type instanceof IntegerType ? type.getMinValue() + : Double.NEGATIVE_INFINITY; + } + + /** + * Gets the highest value of a type: its maximum value for integer types and + * positive infinity for floating point types. + * + * @param type an instance of the type + * @return the highest value + */ + static double highest(final RealType type) { + return type instanceof IntegerType ? type.getMaxValue() + : Double.POSITIVE_INFINITY; + } + + /** + * Creates a marker equal to the given values on the border of the grid, and + * to their minimum (or maximum) elsewhere. + * + * @param values the image values + * @param grid the pixel grid + * @param max true to fill the interior with the maximum, false for the + * minimum + * @return the marker values + */ + static double[] borderMarker(final double[] values, final PixelGrid grid, + final boolean max) + { + var fill = max ? Double.NEGATIVE_INFINITY : Double.POSITIVE_INFINITY; + for (final var v : values) + fill = max ? Math.max(fill, v) : Math.min(fill, v); + final var marker = new double[grid.size]; + final var pos = new int[grid.n]; + for (var i = 0; i < grid.size; i++, grid.forward(pos)) + marker[i] = grid.onBorder(pos) ? values[i] : fill; + return marker; + } + + private static void checkHeight(final double h) { + if (!(h >= 0)) { + throw new IllegalArgumentException("Height must be non-negative: " + h); + } + } + + private static void negate(final double[] values) { + for (var i = 0; i < values.length; i++) + values[i] = -values[i]; + } + + /** A growable FIFO queue of pixel indices. */ + static final class IntQueue { + + private int[] data = new int[256]; + private int head; + private int tail; + + void add(final int value) { + if (tail == data.length) { + if (head > data.length / 2) { + // Reclaim the space of removed elements. + System.arraycopy(data, head, data, 0, tail - head); + tail -= head; + head = 0; + } + else data = Arrays.copyOf(data, 2 * data.length); + } + data[tail++] = value; + } + + int remove() { + return data[head++]; + } + + boolean isEmpty() { + return head == tail; + } + + void clear() { + head = tail = 0; + } + + /** @return the number of elements added since the last clear */ + int length() { + return tail; + } + + /** + * @param index the index of the element, counted since the last clear + * @return the element + */ + int get(final int index) { + return data[index]; + } + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/morphology/reconstruction/RegionalMaxima.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/morphology/reconstruction/RegionalMaxima.java new file mode 100644 index 000000000..04e05e155 --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/morphology/reconstruction/RegionalMaxima.java @@ -0,0 +1,93 @@ +/* #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.morphology.reconstruction; + +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.algorithm.neighborhood.Shape; +import net.imglib2.type.BooleanType; +import net.imglib2.type.numeric.RealType; + +import org.scijava.function.Computers; +import org.scijava.ops.spi.Nullable; + +/** + * Marks the regional maxima of an image in a binary mask, in any number of + * dimensions. + *

+ * A regional maximum is a connected plateau of pixels of constant value, none + * of whose neighbors has a higher value. Unlike {@code image.localExtrema}, + * which tests each pixel against its neighborhood, whole plateaus are + * considered: a plateau adjacent to any higher pixel is not a maximum, and + * every pixel of a maximal plateau is marked. Pixels outside the image are + * ignored. + *

+ *

+ * See P. Soille, Morphological Image Analysis, 2nd ed., Springer, + * 2003, section 6.3. + *

+ *

+ * NaN input values are not supported and cause an + * {@link IllegalArgumentException}. Images are limited to 231 + * pixels. + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @param image type + * @param mask type + * @implNote op names='morphology.regionalMaxima' + */ +public class RegionalMaxima, B extends BooleanType> + implements + Computers.Arity2, Shape, RandomAccessibleInterval> +{ + + /** + * Finds the regional maxima. + * + * @param input the image in which to search for maxima + * @param connectivity the neighborhood defining which pixels are adjacent, + * such as a {@code DiamondShape} of radius 1 for face connectivity + * (4-connectivity in 2D, 6-connectivity in 3D) or a + * {@code RectangleShape} of span 1 for full connectivity + * (8-connectivity in 2D, 26-connectivity in 3D). Defaults to full + * connectivity. + * @param output the mask, of the same dimensions as {@code input}; set to + * true on regional maxima and false elsewhere + */ + @Override + public void compute(final RandomAccessibleInterval input, + @Nullable final Shape connectivity, + final RandomAccessibleInterval output) + { + final var grid = new PixelGrid(input, connectivity); + grid.write(Reconstructions.regionalMaxima(grid.values(input), grid), + output); + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/morphology/reconstruction/RegionalMinima.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/morphology/reconstruction/RegionalMinima.java new file mode 100644 index 000000000..97c10302f --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/morphology/reconstruction/RegionalMinima.java @@ -0,0 +1,90 @@ +/* #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.morphology.reconstruction; + +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.algorithm.neighborhood.Shape; +import net.imglib2.type.BooleanType; +import net.imglib2.type.numeric.RealType; + +import org.scijava.function.Computers; +import org.scijava.ops.spi.Nullable; + +/** + * Marks the regional minima of an image in a binary mask, in any number of + * dimensions. + *

+ * A regional minimum is a connected plateau of pixels of constant value, none + * of whose neighbors has a lower value. Every pixel of a minimal plateau is + * marked. Pixels outside the image are ignored. + *

+ *

+ * See P. Soille, Morphological Image Analysis, 2nd ed., Springer, + * 2003, section 6.3. + *

+ *

+ * NaN input values are not supported and cause an + * {@link IllegalArgumentException}. Images are limited to 231 + * pixels. + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @param image type + * @param mask type + * @implNote op names='morphology.regionalMinima' + */ +public class RegionalMinima, B extends BooleanType> + implements + Computers.Arity2, Shape, RandomAccessibleInterval> +{ + + /** + * Finds the regional minima. + * + * @param input the image in which to search for minima + * @param connectivity the neighborhood defining which pixels are adjacent, + * such as a {@code DiamondShape} of radius 1 for face connectivity + * (4-connectivity in 2D, 6-connectivity in 3D) or a + * {@code RectangleShape} of span 1 for full connectivity + * (8-connectivity in 2D, 26-connectivity in 3D). Defaults to full + * connectivity. + * @param output the mask, of the same dimensions as {@code input}; set to + * true on regional minima and false elsewhere + */ + @Override + public void compute(final RandomAccessibleInterval input, + @Nullable final Shape connectivity, + final RandomAccessibleInterval output) + { + final var grid = new PixelGrid(input, connectivity); + grid.write(Reconstructions.regionalMinima(grid.values(input), grid), + output); + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/morphology/thickness/DefaultLocalThickness.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/morphology/thickness/DefaultLocalThickness.java new file mode 100644 index 000000000..191a08fe0 --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/morphology/thickness/DefaultLocalThickness.java @@ -0,0 +1,489 @@ +/*- + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.morphology.thickness; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.scijava.concurrent.Parallelization; +import org.scijava.function.Functions; +import org.scijava.function.Inplaces; +import org.scijava.ops.image.util.Calibrations; +import org.scijava.ops.image.util.Neighborhoods; +import org.scijava.ops.spi.Nullable; +import org.scijava.ops.spi.OpDependency; + +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.algorithm.morphology.distance.DistanceTransform.DISTANCE_TYPE; +import net.imglib2.img.array.ArrayImg; +import net.imglib2.img.array.ArrayImgs; +import net.imglib2.img.basictypeaccess.array.DoubleArray; +import net.imglib2.img.basictypeaccess.array.FloatArray; +import net.imglib2.type.BooleanType; +import net.imglib2.type.numeric.real.DoubleType; +import net.imglib2.type.numeric.real.FloatType; +import net.imglib2.util.Intervals; +import net.imglib2.view.Views; + +/** + * Computes the local thickness of a binary image: the diameter of the largest + * ball that fits inside the foreground and contains the point, following + * Hildebrand & Rüegsegger (1997). + *

+ * The pipeline is: + *

+ *
    + *
  1. Squared Euclidean distance transform of the foreground (distance to the + * nearest background element; the image border is not treated as + * background), computed with {@code morphology.distanceTransform}.
  2. + *
  3. Distance ridge: foreground elements whose ball (radius = the + * distance value) is not contained in the ball of any element of the + * 3n-1 neighborhood. These are (approximately) the centres of + * maximal inscribed balls.
  4. + *
  5. Local thickness: every element receives the largest radius of + * all ridge balls covering it, reported as a diameter (2 × radius).
  6. + *
  7. Clean-up: elements of the thickness map that border its zero + * region get the average of their interior neighbors, reducing "jaggies".
  8. + *
  9. Optionally, elements outside the input foreground are set to 0.
  10. + *
+ *

+ * This is an n-dimensional port of the distance ridge, local thickness and + * clean-up plugins by Bob Dougherty (Copyright (c) 2006-2007, OptiNav, Inc., + * BSD-3-Clause), as distributed in the Fiji + * LocalThickness project. + * Unlike the original, which is 3D and isotropic only, this version supports + * any dimensionality and anisotropic element spacing: the ridge-search + * templates are computed for every subset of neighbor offset axes, and all + * distances are measured in calibrated units. With unit calibration, results + * match the original plugin. + *

+ *

+ * Reference: T. Hildebrand and P. Rüegsegger, "A new method for the + * model-independent assessment of thickness in three-dimensional images", + * Journal of Microscopy 185(1):67-75, 1997. + *

+ *

+ * Original license notice for the ported algorithm: + *

+ * + *
+ * Copyright (c) 2006, 2007 OptiNav, Inc.
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions
+ * are met:
+ *   Redistributions of source code must retain the above copyright
+ * notice, this list of conditions and the following disclaimer.
+ *   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.
+ *   Neither the name of OptiNav, Inc. nor the names of its contributors
+ * may be used to endorse or promote products derived from this software
+ * without specific prior written permission.
+ *
+ * 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 OWNER 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.
+ * 
+ *

+ * The input must contain at least one background element (an + * {@link IllegalArgumentException} is thrown otherwise), and must have fewer + * than 231 elements. An input without foreground yields 0 + * everywhere. + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @author Bob Dougherty + * @param type of the binary input + * @implNote op names='morphology.localThickness' + */ +public class DefaultLocalThickness> implements + Functions.Arity3, double[], Boolean, RandomAccessibleInterval> +{ + + @OpDependency(name = "morphology.distanceTransform") + private Inplaces.Arity3_1, DISTANCE_TYPE, double[]> edtOp; + + /** + * Computes the local thickness map of a binary image. + * + * @param input the binary image; {@code true} elements are foreground. It + * must contain at least one background element, otherwise an + * {@link IllegalArgumentException} is thrown. + * @param calibration the element spacing along each dimension (defaults to 1 + * for every dimension). Thickness values are reported in these + * units. + * @param maskToInput whether to set elements outside the input foreground to + * 0 (default {@code true}). When {@code false}, the cleaned-up map + * is returned as is, matching the Fiji "Local Thickness" driver; + * its support then extends slightly into the background. + * @return the local thickness (diameter) map, with the same interval as + * the input + */ + @Override + public RandomAccessibleInterval apply( // + final RandomAccessibleInterval input, // + @Nullable double[] calibration, // + @Nullable Boolean maskToInput // + ) { + final int n = input.numDimensions(); + calibration = Calibrations.spacing(calibration, n, "calibration"); + if (maskToInput == null) maskToInput = true; + + final long[] dims = Intervals.dimensionsAsLongArray(input); + if (Intervals.numElements(dims) > Integer.MAX_VALUE) { + throw new IllegalArgumentException("Image is too large: " + Intervals + .numElements(dims) + " elements"); + } + final int[] size = new int[n]; + for (int d = 0; d < n; d++) + size[d] = (int) dims[d]; + final double[] cal2 = new double[n]; + for (int d = 0; d < n; d++) + cal2[d] = calibration[d] * calibration[d]; + + // Compute the squared distance transform. Foreground is initialized with + // a value larger than any squared distance inside the image. + final ArrayImg sqImg = ArrayImgs.doubles(dims); + final double[] sq = sqImg.update(null).getCurrentStorageArray(); + final boolean[] fg = new boolean[sq.length]; + double big = 1; + for (int d = 0; d < n; d++) + big += cal2[d] * dims[d] * dims[d]; + int i = 0; + boolean hasBackground = false; + for (final B b : Views.flatIterable(Views.zeroMin(input))) { + fg[i] = b.get(); + sq[i] = fg[i] ? big : 0; + if (!fg[i]) hasBackground = true; + i++; + } + // Note: Without background, every element would be a ridge point whose + // ball covers the whole image, which takes quadratic time. + if (!hasBackground) { + throw new IllegalArgumentException( + "The input must contain at least one background element"); + } + edtOp.mutate(sqImg, DISTANCE_TYPE.EUCLIDIAN, cal2); + + final Neighbors nb = new Neighbors(size); + final int[][] ridge = distanceRidge(sq, size, cal2, nb); + final double[] lt = localThickness(sq.length, size, cal2, ridge, sq); + final float[] out = cleanUp(lt, size, nb); + if (maskToInput) { + for (int k = 0; k < out.length; k++) + if (!fg[k]) out[k] = 0; + } + + final ArrayImg result = ArrayImgs.floats(out, dims); + return Views.translate(result, input.minAsLongArray()); + } + + /** + * Finds the distance ridge: foreground elements whose ball is not contained + * in the ball of any neighbor. Returns the flat indices of the ridge points, + * in one array per parallel chunk. + */ + private static int[][] distanceRidge(final double[] sq, final int[] size, + final double[] cal2, final Neighbors nb) + { + // Build ridge templates for every distinct squared radius. + final double[] radii = Arrays.stream(sq).filter(v -> v > 0).distinct() + .sorted().toArray(); + final Map templates = new HashMap<>(radii.length * 2); + for (final double rSq : radii) + templates.put(rSq, template(rSq, cal2)); + + final int nChunks = Math.max(1, Parallelization.getTaskExecutor() + .suggestNumberOfTasks()); + final int[][] chunks = new int[nChunks][]; + final List tasks = new ArrayList<>(); + final int total = sq.length; + for (int c = 0; c < nChunks; c++) { + final int chunk = c; + final int start = (int) ((long) total * c / nChunks); + final int end = (int) ((long) total * (c + 1) / nChunks); + tasks.add(() -> { + final int[] pos = new int[size.length]; + int[] found = new int[16]; + int count = 0; + for (int idx = start; idx < end; idx++) { + final double rSq = sq[idx]; + if (rSq <= 0) continue; + final double[] t = templates.get(rSq); + nb.position(idx, pos); + boolean isRidge = true; + for (int o = 0; o < nb.count; o++) { + if (!nb.inBounds(pos, o)) continue; + final double needed = t[nb.mask[o]]; + if (sq[idx + nb.delta[o]] >= needed * (1 - 1e-12)) { + isRidge = false; + break; + } + } + if (isRidge) { + if (count == found.length) found = Arrays.copyOf(found, count * 2); + found[count++] = idx; + } + } + chunks[chunk] = Arrays.copyOf(found, count); + }); + } + Parallelization.getTaskExecutor().runAll(tasks); + return chunks; + } + + /** + * For each subset of axes (bit mask) along which a neighbor is offset by + * one element, computes the smallest squared radius a ball centered on that + * neighbor must have to contain the grid ball of squared radius + * {@code rSq} centered at the origin. + */ + private static double[] template(final double rSq, final double[] cal2) { + final int n = cal2.length; + final double[] t = new double[1 << n]; + final int[] p = new int[n]; + final int[] max = new int[n]; + for (int d = 1; d < n; d++) + max[d] = (int) Math.floor(Math.sqrt(rSq / cal2[d]) + 1e-9); + // Iterate over grid points p >= 0 in dims 1..n-1 inside the ball, taking + // the outermost grid point along dim 0. + while (true) { + double rest = 0; + for (int d = 1; d < n; d++) + rest += cal2[d] * p[d] * p[d]; + if (rest <= rSq * (1 + 1e-12)) { + p[0] = (int) Math.floor(Math.sqrt(Math.max(0, rSq - rest) / cal2[0]) + + 1e-9); + for (int mask = 1; mask < t.length; mask++) { + double dist = 0; + for (int d = 0; d < n; d++) { + final int q = p[d] + ((mask >> d) & 1); + dist += cal2[d] * q * q; + } + if (dist > t[mask]) t[mask] = dist; + } + } + // Advance the odometer over dims 1..n-1. + int d = 1; + while (d < n && p[d] == max[d]) { + p[d] = 0; + d++; + } + if (d >= n) break; + p[d]++; + } + return t; + } + + /** + * For each element, finds the largest squared radius of all ridge balls + * containing it, and returns the corresponding diameter (2 × radius). + * The work is split into slabs along the last dimension, so that no two + * tasks write to the same element. + */ + private static double[] localThickness(final int total, final int[] size, + final double[] cal2, final int[][] ridge, final double[] sq) + { + final int n = size.length; + final double[] lt = new double[total]; + final int[] strides = strides(size); + final int last = size[n - 1]; + final int nTasks = Math.max(1, Math.min(last, Parallelization + .getTaskExecutor().suggestNumberOfTasks())); + final List tasks = new ArrayList<>(); + for (int s = 0; s < nTasks; s++) { + final int slabStart = (int) ((long) last * s / nTasks); + final int slabEnd = (int) ((long) last * (s + 1) / nTasks) - 1; + tasks.add(() -> { + final int[] center = new int[n]; + final int[] lo = new int[n]; + final int[] hi = new int[n]; + for (final int[] chunk : ridge) { + for (final int idx : chunk) { + final double rSq = sq[idx]; + int rem = idx; + for (int d = 0; d < n; d++) { + center[d] = rem % size[d]; + rem /= size[d]; + } + boolean empty = false; + for (int d = 0; d < n; d++) { + final int ext = (int) Math.floor(Math.sqrt(rSq / cal2[d]) + 1e-9); + final int min = d == n - 1 ? slabStart : 0; + final int max = d == n - 1 ? slabEnd : size[d] - 1; + lo[d] = Math.max(min, center[d] - ext); + hi[d] = Math.min(max, center[d] + ext); + if (lo[d] > hi[d]) empty = true; + } + if (empty) continue; + paint(lt, rSq, center, lo, hi, strides, cal2, n - 1, 0, 0); + } + } + }); + } + Parallelization.getTaskExecutor().runAll(tasks); + for (int k = 0; k < total; k++) + lt[k] = 2 * Math.sqrt(lt[k]); + return lt; + } + + /** Recursively paints {@code rSq} into all elements of the ball. */ + private static void paint(final double[] lt, final double rSq, + final int[] center, final int[] lo, final int[] hi, final int[] strides, + final double[] cal2, final int d, final double partial, final int base) + { + for (int x = lo[d]; x <= hi[d]; x++) { + final int dx = x - center[d]; + final double dist = partial + cal2[d] * dx * dx; + if (dist > rSq) continue; + final int index = base + x * strides[d]; + if (d == 0) { + if (rSq > lt[index]) lt[index] = rSq; + } + else { + paint(lt, rSq, center, lo, hi, strides, cal2, d - 1, dist, index); + } + } + } + + /** + * Replaces the value of each nonzero element bordering a zero element with + * the average of its interior (non-bordering) nonzero neighbors. + */ + private static float[] cleanUp(final double[] lt, final int[] size, + final Neighbors nb) + { + final int total = lt.length; + // Each flag is 0 for background, -1 for a non-background element that + // borders background, and the input value for an interior element. + final double[] flags = new double[total]; + final int[] pos = new int[size.length]; + for (int idx = 0; idx < total; idx++) { + if (lt[idx] == 0) continue; + nb.position(idx, pos); + boolean border = false; + for (int o = 0; o < nb.count; o++) { + if (nb.inBounds(pos, o) && lt[idx + nb.delta[o]] == 0) { + border = true; + break; + } + } + flags[idx] = border ? -1 : lt[idx]; + } + // Border elements get the negated average of their interior neighbors, + // so that already-processed border elements are excluded from later + // averages. + for (int idx = 0; idx < total; idx++) { + if (flags[idx] != -1) continue; + nb.position(idx, pos); + int count = 0; + double sum = 0; + for (int o = 0; o < nb.count; o++) { + if (!nb.inBounds(pos, o)) continue; + final double v = flags[idx + nb.delta[o]]; + if (v > 0) { + count++; + sum += v; + } + } + flags[idx] = -(count > 0 ? sum / count : lt[idx]); + } + final float[] out = new float[total]; + for (int idx = 0; idx < total; idx++) + out[idx] = (float) Math.abs(flags[idx]); + return out; + } + + private static int[] strides(final int[] size) { + final int[] strides = new int[size.length]; + int s = 1; + for (int d = 0; d < size.length; d++) { + strides[d] = s; + s *= size[d]; + } + return strides; + } + + /** The 3n-1 neighborhood of an element in a flat array. */ + private static final class Neighbors { + + private final int[] size; + private final int count; + private final int[][] offsets; + private final int[] delta; + private final int[] mask; + + private Neighbors(final int[] size) { + this.size = size; + final int n = size.length; + final int[] strides = strides(size); + offsets = Neighborhoods.rasterOffsets(null, n); + count = offsets.length; + delta = new int[count]; + mask = new int[count]; + for (int o = 0; o < count; o++) { + for (int d = 0; d < n; d++) { + if (offsets[o][d] != 0) mask[o] |= 1 << d; + delta[o] += offsets[o][d] * strides[d]; + } + } + } + + private void position(int idx, final int[] pos) { + for (int d = 0; d < size.length; d++) { + pos[d] = idx % size[d]; + idx /= size[d]; + } + } + + private boolean inBounds(final int[] pos, final int o) { + final int[] off = offsets[o]; + for (int d = 0; d < size.length; d++) { + final int p = pos[d] + off[d]; + if (p < 0 || p >= size[d]) return false; + } + return true; + } + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/segment/dog/DefaultDetectSpotsDoG.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/segment/dog/DefaultDetectSpotsDoG.java new file mode 100644 index 000000000..d7cc888c8 --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/segment/dog/DefaultDetectSpotsDoG.java @@ -0,0 +1,196 @@ +/*- + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.segment.dog; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Comparator; +import java.util.List; + +import net.imglib2.Point; +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.RealLocalizable; +import net.imglib2.RealPoint; +import net.imglib2.algorithm.dog.DogDetection; +import net.imglib2.algorithm.localextrema.SubpixelLocalization; +import net.imglib2.type.NativeType; +import net.imglib2.type.numeric.RealType; +import net.imglib2.view.Views; + +import org.scijava.concurrent.Parallelization; +import org.scijava.function.Functions; +import org.scijava.ops.spi.Nullable; + +/** + * Detects blob-like spots using a Difference-of-Gaussian (DoG) detector, + * wrapping ImgLib2's {@link DogDetection}. + *

+ * The image is filtered with Gaussians of standard deviation {@code sigma} + * and {@code sigma × 2^(1/4)}; their difference approximates the + * scale-normalized Laplacian of Gaussian, which responds most strongly to + * spots of radius about {@code sigma × √n} in {@code n} dimensions (i.e., to + * Gaussian spots of standard deviation {@code sigma}). Local extrema of the + * DoG (over the 3^n neighborhood) whose normalized response exceeds the + * threshold are reported as spots, optionally refined to subpixel accuracy by + * fitting a quadratic to the DoG around each extremum (ImgLib2's + * {@link SubpixelLocalization}). + *

+ *

+ * The normalized response is the DoG value divided by {@code 2^(1/4) - 1}, so + * that it approximates the scale-normalized Laplacian of Gaussian and is + * roughly in units of image intensity. + *

+ *

+ * This op provides the core of the DoG detection in Stephan Preibisch's + * Descriptor + * based registration plugin and in TrackMate's DoG detector, both of + * which build on ImgLib2's {@link DogDetection} (BSD-2-Clause, by Tobias + * Pietzsch and Stephan Preibisch). See Lowe, D. G., "Distinctive image + * features from scale-invariant keypoints", IJCV 60(2):91-110 (2004). + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @param image type + * @implNote op names='segment.detectSpotsDoG' + */ +public class DefaultDetectSpotsDoG & NativeType> + implements + Functions.Arity6, Double, double[], Double, Boolean, Boolean, List> +{ + + /** Ratio between the two Gaussian scales of the DoG. */ + private static final double K = Math.pow(2, 0.25); + + /** + * Detects spots. + * + * @param input the image in which to detect spots, without NaN + * @param sigma the scale of the spots to detect: the standard deviation of + * the smaller Gaussian, in calibrated units + * @param calibration the physical size of a pixel along each dimension; + * defaults to 1 for every dimension + * @param threshold the minimum normalized DoG response of a spot (see the + * class description); defaults to 0, i.e. any extremum with a + * strictly positive response + * @param brightSpots {@code true} (default) to detect bright spots on a + * dark background, {@code false} to detect dark spots on a bright + * background + * @param subpixel {@code true} (default) to refine spot positions to + * subpixel accuracy, {@code false} to return pixel positions + * @return the spot positions, sorted in flat iteration order of the + * pixel each spot was detected at (first dimension fastest) + */ + @Override + public List apply(final RandomAccessibleInterval input, + final Double sigma, @Nullable double[] calibration, + @Nullable Double threshold, @Nullable Boolean brightSpots, + @Nullable Boolean subpixel) + { + for (final var t : Views.iterable(input)) { + if (Double.isNaN(t.getRealDouble())) { + throw new IllegalArgumentException("NaN values are not supported"); + } + } + final var n = input.numDimensions(); + if (sigma == null || !(sigma > 0)) throw new IllegalArgumentException( + "sigma must be positive"); + if (calibration == null) { + calibration = new double[n]; + Arrays.fill(calibration, 1); + } + if (calibration.length != n) throw new IllegalArgumentException( + "calibration must have one entry per dimension (" + n + ")"); + if (threshold == null) threshold = 0.0; + if (brightSpots == null) brightSpots = true; + if (subpixel == null) subpixel = true; + + // Note: Bright blobs are minima of the DoG computed by DogDetection. + final var extremaType = brightSpots ? DogDetection.ExtremaType.MINIMA + : DogDetection.ExtremaType.MAXIMA; + final var dog = new DogDetection<>(Views.extendMirrorSingle(input), input, + calibration, sigma, sigma * K, extremaType, threshold, true); + dog.setExecutorService(Parallelization.getExecutorService()); + dog.setKeepDoGImg(true); + final List peaks = new ArrayList<>(dog.getPeaks()); + final var dogImg = dogImage(dog); + + // Keep only peaks whose response strictly exceeds the threshold, which + // discards the spurious non-strict extrema of flat regions. + final var sign = brightSpots ? -1 : 1; + final var ra = dogImg.randomAccess(); + final var minResponse = threshold; + peaks.removeIf(p -> { + ra.setPosition(p); + return sign * ra.get().getRealDouble() / (K - 1) <= minResponse; + }); + peaks.sort(FLAT_ORDER); + + final List spots = new ArrayList<>(peaks.size()); + if (subpixel) { + final var refined = refine(peaks, dogImg); + for (final RealLocalizable r : refined) + spots.add(new RealPoint(r)); + } + else { + for (final var p : peaks) + spots.add(new RealPoint(p)); + } + return spots; + } + + @SuppressWarnings({ "rawtypes", "unchecked" }) + private static RandomAccessibleInterval> dogImage( + final DogDetection dog) + { + return (RandomAccessibleInterval) dog.getTypedDogDetection().dogImg; + } + + @SuppressWarnings({ "rawtypes", "unchecked" }) + private static List refine( + final List peaks, + final RandomAccessibleInterval> dogImg) + { + // Note: These are the settings used by DogDetection.getSubpixelPeaks(). + final var spl = new SubpixelLocalization(dogImg.numDimensions()); + spl.setAllowMaximaTolerance(true); + spl.setMaxNumMoves(10); + spl.setNumThreads(Runtime.getRuntime().availableProcessors()); + return spl.process(peaks, dogImg, dogImg); + } + + private static final Comparator FLAT_ORDER = (p1, p2) -> { + for (var d = p1.numDimensions() - 1; d >= 0; d--) { + final var c = Long.compare(p1.getLongPosition(d), p2.getLongPosition(d)); + if (c != 0) return c; + } + return 0; + }; +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/segment/fastmarching/FastMarching.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/segment/fastmarching/FastMarching.java new file mode 100644 index 000000000..2a08496dc --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/segment/fastmarching/FastMarching.java @@ -0,0 +1,325 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.segment.fastmarching; + +import java.util.Arrays; + +/** + * Fast marching solver of the Eikonal equation {@code |grad T| F = 1} on an + * n-dimensional regular grid, after J. A. Sethian ("A fast marching level set + * method for monotonically advancing fronts", PNAS 93(4): + * 1591–1595, 1996). + *

+ * Pixels are accepted in order of increasing arrival time, using a binary + * heap. The arrival time of a pixel is obtained from its accepted neighbors by + * solving the upwind discretization of the Eikonal equation, either with + * first-order one-sided differences or, where two accepted pixels are + * available in a row, with second-order ones (J. A. Sethian, Level Set + * Methods and Fast Marching Methods, 2nd ed., Cambridge University Press, + * 1999, section 8.6). Ties in the heap are broken by the flat pixel index, so + * the result is deterministic. + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +public final class FastMarching { + + private static final byte FAR = 0; + private static final byte TRIAL = 1; + private static final byte KNOWN = 2; + + private final long[] dims; + private final int[] strides; + private final double[] invH2; + private final double[] speed; + private final boolean secondOrder; + + private final double[] times; + private final byte[] state; + + // Binary min-heap of (time, index) entries, with lazy deletion. + private double[] heapKey = new double[64]; + private int[] heapIndex = new int[64]; + private int heapSize; + + // Scratch space for the local updates. + private final double[] coefs; + private final double[] centers; + private final int[] coord; + private final int[] pos; + + /** + * Creates a solver. + * + * @param dims the grid dimensions + * @param spacing the grid spacing along each dimension + * @param speed the propagation speed of each pixel, in flat order (first + * dimension fastest); pixels whose speed is not positive (or + * {@code NaN}) are never reached, unless they are seeds + * @param secondOrder whether to use second-order upwind differences where + * possible + */ + public FastMarching(final long[] dims, final double[] spacing, + final double[] speed, final boolean secondOrder) + { + final var n = dims.length; + if (spacing.length != n) { + throw new IllegalArgumentException("Expected " + n + + " spacing values but got " + spacing.length); + } + this.dims = dims; + this.speed = speed; + this.secondOrder = secondOrder; + strides = new int[n]; + invH2 = new double[n]; + var stride = 1L; + for (var d = 0; d < n; d++) { + if (!(spacing[d] > 0) || Double.isInfinite(spacing[d])) { + throw new IllegalArgumentException("Invalid spacing: " + Arrays + .toString(spacing)); + } + strides[d] = (int) stride; + invH2[d] = 1 / (spacing[d] * spacing[d]); + stride *= dims[d]; + } + if (stride != speed.length) { + throw new IllegalArgumentException("Speed array has " + speed.length + + " elements but the grid has " + stride); + } + times = new double[speed.length]; + Arrays.fill(times, Double.POSITIVE_INFINITY); + state = new byte[speed.length]; + coefs = new double[n]; + centers = new double[n]; + coord = new int[n]; + pos = new int[n]; + } + + /** + * Propagates the front from the given seed pixels, which get arrival time + * 0, until all reachable pixels are accepted or the arrival time exceeds + * {@code stopTime}. + * + * @param seeds the flat indices of the seed pixels + * @param stopTime the time after which propagation stops; pixels not + * accepted by then keep an infinite arrival time + * @return the arrival times, in flat order + */ + public double[] propagate(final int[] seeds, final double stopTime) { + for (final var s : seeds) { + times[s] = 0; + state[s] = KNOWN; + } + for (final var s : seeds) + updateNeighbors(s); + while (heapSize > 0) { + final var t = heapKey[0]; + final var i = heapIndex[0]; + pop(); + if (state[i] == KNOWN || t != times[i]) continue; + if (t > stopTime) break; + state[i] = KNOWN; + updateNeighbors(i); + } + for (var i = 0; i < times.length; i++) { + if (state[i] != KNOWN) times[i] = Double.POSITIVE_INFINITY; + } + return times; + } + + private void updateNeighbors(final int index) { + position(index, coord); + for (var d = 0; d < dims.length; d++) { + if (coord[d] > 0) update(index - strides[d]); + if (coord[d] < dims[d] - 1) update(index + strides[d]); + } + } + + private void position(final int index, final int[] p) { + var rem = index; + for (var d = dims.length - 1; d >= 0; d--) { + p[d] = rem / strides[d]; + rem -= p[d] * strides[d]; + } + } + + private void update(final int index) { + if (state[index] == KNOWN) return; + final var f = speed[index]; + if (!(f > 0)) return; + final var t = solve(index, 1 / (f * f)); + if (t < times[index]) { + times[index] = t; + if (state[index] == FAR) state[index] = TRIAL; + push(t, index); + } + } + + /** + * Computes the upwind arrival time of a pixel from its accepted neighbors. + */ + private double solve(final int index, final double rhs) { + position(index, pos); + var m = 0; + var anySecond = false; + for (var d = 0; d < dims.length; d++) { + final var s = strides[d]; + var best = Double.POSITIVE_INFINITY; + var side = 0; + if (pos[d] > 0 && state[index - s] == KNOWN) { + best = times[index - s]; + side = -1; + } + if (pos[d] < dims[d] - 1 && state[index + s] == KNOWN && times[index + + s] < best) + { + best = times[index + s]; + side = 1; + } + if (side == 0) continue; + var c = invH2[d]; + var b = best; + if (secondOrder) { + final var p2 = pos[d] + 2 * side; + if (p2 >= 0 && p2 < dims[d]) { + final var j = index + 2 * side * s; + if (state[j] == KNOWN && times[j] <= best) { + // One-sided difference (3 T - 4 T1 + T2) / (2 h). + c = 2.25 * invH2[d]; + b = (4 * best - times[j]) / 3; + anySecond = true; + } + } + } + coefs[m] = c; + centers[m] = b; + m++; + } + final var t = solveSorted(m, rhs); + if (!Double.isNaN(t) || !anySecond) return t; + // Note: With second-order terms, the quadratic can lack a real root; + // the first-order scheme always has one, so it is used instead. + return firstOrder(index, rhs); + } + + private double firstOrder(final int index, final double rhs) { + var m = 0; + for (var d = 0; d < dims.length; d++) { + final var s = strides[d]; + var best = Double.POSITIVE_INFINITY; + if (pos[d] > 0 && state[index - s] == KNOWN) best = times[index - s]; + if (pos[d] < dims[d] - 1 && state[index + s] == KNOWN) best = Math.min( + best, times[index + s]); + if (best == Double.POSITIVE_INFINITY) continue; + coefs[m] = invH2[d]; + centers[m] = best; + m++; + } + return solveSorted(m, rhs); + } + + /** + * Solves {@code sum_k c_k (T - b_k)^2 = rhs} for its largest root, adding + * the terms in increasing order of {@code b_k} for as long as they lie + * upwind of the solution. + */ + private double solveSorted(final int m, final double rhs) { + for (var i = 1; i < m; i++) { + final var b = centers[i]; + final var c = coefs[i]; + var j = i - 1; + while (j >= 0 && centers[j] > b) { + centers[j + 1] = centers[j]; + coefs[j + 1] = coefs[j]; + j--; + } + centers[j + 1] = b; + coefs[j + 1] = c; + } + double a = 0, bSum = 0, cSum = 0; + var t = Double.NaN; + for (var k = 0; k < m; k++) { + a += coefs[k]; + bSum += coefs[k] * centers[k]; + cSum += coefs[k] * centers[k] * centers[k]; + final var disc = bSum * bSum - a * (cSum - rhs); + if (disc < 0) return Double.NaN; + t = (bSum + Math.sqrt(disc)) / a; + if (k + 1 < m && t <= centers[k + 1]) break; + } + return t; + } + + // -- Heap -- + + private void push(final double key, final int index) { + if (heapSize == heapKey.length) { + heapKey = Arrays.copyOf(heapKey, 2 * heapSize); + heapIndex = Arrays.copyOf(heapIndex, 2 * heapSize); + } + var i = heapSize++; + while (i > 0) { + final var p = (i - 1) / 2; + if (!less(key, index, heapKey[p], heapIndex[p])) break; + heapKey[i] = heapKey[p]; + heapIndex[i] = heapIndex[p]; + i = p; + } + heapKey[i] = key; + heapIndex[i] = index; + } + + private void pop() { + heapSize--; + if (heapSize == 0) return; + final var key = heapKey[heapSize]; + final var index = heapIndex[heapSize]; + var i = 0; + while (true) { + var child = 2 * i + 1; + if (child >= heapSize) break; + if (child + 1 < heapSize && less(heapKey[child + 1], heapIndex[child + + 1], heapKey[child], heapIndex[child])) child++; + if (!less(heapKey[child], heapIndex[child], key, index)) break; + heapKey[i] = heapKey[child]; + heapIndex[i] = heapIndex[child]; + i = child; + } + heapKey[i] = key; + heapIndex[i] = index; + } + + private static boolean less(final double k1, final int i1, final double k2, + final int i2) + { + return k1 < k2 || (k1 == k2 && i1 < i2); + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/segment/fastmarching/FastMarchingOps.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/segment/fastmarching/FastMarchingOps.java new file mode 100644 index 000000000..4858bb5a9 --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/segment/fastmarching/FastMarchingOps.java @@ -0,0 +1,331 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.segment.fastmarching; + +import java.util.Arrays; +import java.util.List; + +import net.imglib2.Localizable; +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.img.array.ArrayImgs; +import net.imglib2.type.BooleanType; +import net.imglib2.type.logic.BitType; +import net.imglib2.type.numeric.RealType; +import net.imglib2.type.numeric.real.DoubleType; +import net.imglib2.util.Intervals; +import net.imglib2.view.Views; + +import org.scijava.ops.spi.Nullable; + +/** + * Fast marching ops: arrival times of a front that starts at seed pixels and + * advances outward with a pixel-dependent speed. + *

+ * The arrival time {@code T} solves the Eikonal equation + * {@code |grad T| F = 1} with {@code T = 0} at the seeds, where {@code F} is + * the speed image (or {@code 1 / cost} for a cost image). It is computed with + * the fast marching method of J. A. Sethian ("A fast marching level set method + * for monotonically advancing fronts", PNAS 93(4): 1591–1595, + * 1996), in any number of dimensions and with anisotropic pixel spacing, using + * first-order or second-order upwind differences. With unit speed, the arrival + * time approximates the Euclidean distance to the seeds; in general it is the + * minimal travel time {@code integral ds / F} along any path, so fronts refract + * at interfaces between media of different speeds. + *

+ *

+ * {@code morphology.geodesicDistanceMap} also measures path lengths from seeds, + * but differs in two ways. First, it computes exact shortest paths in a graph + * whose edges join a pixel to a fixed set of neighbors (Dijkstra), so its + * distances are polygonal (chamfer) approximations of the Euclidean ones, with + * a direction-dependent error that does not shrink with the distance; fast + * marching instead solves a discretization of the continuous Eikonal equation, + * whose relative error does decrease with the distance. Second, it is limited + * to a binary domain (unit speed inside the mask, impassable outside), whereas + * fast marching accepts any non-negative speed field. A binary mask can be + * used as a speed image here to get a continuous-metric geodesic distance. + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +public final class FastMarchingOps { + + private FastMarchingOps() { + // Prevent instantiation of static utility class. + } + + /** + * Computes the arrival time map of a front starting at the pixels of a + * seed mask. + * + * @param seeds the seed pixels, where the arrival time is 0; must have the + * dimensions of the speed image + * @param speed the speed of the front at each pixel, in calibrated units per + * unit of time; pixels whose speed is zero, negative or NaN are + * impassable + * @param calibration the pixel spacing along each dimension (default: 1 in + * every dimension) + * @param stopTime the arrival time after which propagation stops; later + * pixels are left at positive infinity (default: no limit) + * @param secondOrder whether to use second-order upwind differences where + * enough accepted pixels are available, for better accuracy + * (default: true) + * @param asCost if true, the speed image is instead interpreted as a cost + * image (time per unit length), and the speed is its reciprocal + * (default: false) + * @return the arrival times, with the interval of the speed image; + * unreachable pixels are positive infinity + * @param seed mask type + * @param speed image type + * @implNote op names='image.fastMarchingTimes', type=Function + */ + public static , T extends RealType> + RandomAccessibleInterval timesFromMask( + final RandomAccessibleInterval seeds, + final RandomAccessibleInterval speed, + @Nullable final double[] calibration, @Nullable final Double stopTime, + @Nullable final Boolean secondOrder, @Nullable final Boolean asCost) + { + return times(speed, maskSeeds(seeds, speed), calibration, stopTime, + secondOrder, asCost); + } + + /** + * Computes the arrival time map of a front starting at seed points. + * + * @param seeds the seed points, in the coordinates of the speed image, where + * the arrival time is 0 + * @param speed the speed of the front at each pixel, in calibrated units per + * unit of time; pixels whose speed is zero, negative or NaN are + * impassable + * @param calibration the pixel spacing along each dimension (default: 1 in + * every dimension) + * @param stopTime the arrival time after which propagation stops; later + * pixels are left at positive infinity (default: no limit) + * @param secondOrder whether to use second-order upwind differences where + * enough accepted pixels are available, for better accuracy + * (default: true) + * @param asCost if true, the speed image is instead interpreted as a cost + * image (time per unit length), and the speed is its reciprocal + * (default: false) + * @return the arrival times, with the interval of the speed image; + * unreachable pixels are positive infinity + * @param speed image type + * @implNote op names='image.fastMarchingTimes', type=Function + */ + public static > RandomAccessibleInterval + timesFromPoints(final List seeds, + final RandomAccessibleInterval speed, + @Nullable final double[] calibration, @Nullable final Double stopTime, + @Nullable final Boolean secondOrder, @Nullable final Boolean asCost) + { + return times(speed, pointSeeds(seeds, speed), calibration, stopTime, + secondOrder, asCost); + } + + /** + * Segments the region reached by a front starting at the pixels of a seed + * mask within a given time: the pixels whose fast marching arrival time + * (see {@code image.fastMarchingTimes}) is at most {@code threshold}. + * + * @param seeds the seed pixels, where the arrival time is 0; must have the + * dimensions of the speed image + * @param speed the speed of the front at each pixel, in calibrated units per + * unit of time; pixels whose speed is zero, negative or NaN are + * impassable + * @param threshold the maximal arrival time of the segmented pixels + * @param calibration the pixel spacing along each dimension (default: 1 in + * every dimension) + * @param secondOrder whether to use second-order upwind differences where + * enough accepted pixels are available (default: true) + * @param asCost if true, the speed image is instead interpreted as a cost + * image (time per unit length), and the speed is its reciprocal + * (default: false) + * @return the segmentation mask, with the interval of the speed image + * @param seed mask type + * @param speed image type + * @implNote op names='segment.fastMarching', type=Function + */ + public static , T extends RealType> + RandomAccessibleInterval maskFromMask( + final RandomAccessibleInterval seeds, + final RandomAccessibleInterval speed, final Double threshold, + @Nullable final double[] calibration, + @Nullable final Boolean secondOrder, @Nullable final Boolean asCost) + { + return mask(speed, maskSeeds(seeds, speed), threshold, calibration, + secondOrder, asCost); + } + + /** + * Segments the region reached by a front starting at seed points within a + * given time: the pixels whose fast marching arrival time (see + * {@code image.fastMarchingTimes}) is at most {@code threshold}. + * + * @param seeds the seed points, in the coordinates of the speed image, where + * the arrival time is 0 + * @param speed the speed of the front at each pixel, in calibrated units per + * unit of time; pixels whose speed is zero, negative or NaN are + * impassable + * @param threshold the maximal arrival time of the segmented pixels + * @param calibration the pixel spacing along each dimension (default: 1 in + * every dimension) + * @param secondOrder whether to use second-order upwind differences where + * enough accepted pixels are available (default: true) + * @param asCost if true, the speed image is instead interpreted as a cost + * image (time per unit length), and the speed is its reciprocal + * (default: false) + * @return the segmentation mask, with the interval of the speed image + * @param speed image type + * @implNote op names='segment.fastMarching', type=Function + */ + public static > RandomAccessibleInterval + maskFromPoints(final List seeds, + final RandomAccessibleInterval speed, final Double threshold, + @Nullable final double[] calibration, + @Nullable final Boolean secondOrder, @Nullable final Boolean asCost) + { + return mask(speed, pointSeeds(seeds, speed), threshold, calibration, + secondOrder, asCost); + } + + // -- Helper methods -- + + private static > RandomAccessibleInterval + mask(final RandomAccessibleInterval speed, final int[] seeds, + final Double threshold, final double[] calibration, + final Boolean secondOrder, final Boolean asCost) + { + if (threshold == null || Double.isNaN(threshold)) { + throw new IllegalArgumentException("A time threshold is required"); + } + final var times = march(speed, seeds, calibration, threshold, secondOrder, + asCost); + final var bits = ArrayImgs.bits(speed.dimensionsAsLongArray()); + var i = 0; + for (final var b : bits) + b.set(times[i++] <= threshold); + return Views.translate(bits, speed.minAsLongArray()); + } + + private static > RandomAccessibleInterval + times(final RandomAccessibleInterval speed, final int[] seeds, + final double[] calibration, final Double stopTime, + final Boolean secondOrder, final Boolean asCost) + { + final var times = march(speed, seeds, calibration, stopTime == null + ? Double.POSITIVE_INFINITY : stopTime, secondOrder, asCost); + return Views.translate(ArrayImgs.doubles(times, speed + .dimensionsAsLongArray()), speed.minAsLongArray()); + } + + private static > double[] march( + final RandomAccessibleInterval speed, final int[] seeds, + final double[] calibration, final double stopTime, + final Boolean secondOrder, final Boolean asCost) + { + final var n = speed.numDimensions(); + final double[] cal; + if (calibration == null) { + cal = new double[n]; + Arrays.fill(cal, 1); + } + else cal = calibration; + final var cost = asCost != null && asCost; + final var f = new double[size(speed)]; + var i = 0; + for (final var t : Views.flatIterable(speed)) { + final var v = t.getRealDouble(); + f[i++] = cost ? 1 / v : v; + } + final var solver = new FastMarching(speed.dimensionsAsLongArray(), cal, f, + secondOrder == null || secondOrder); + return solver.propagate(seeds, stopTime); + } + + private static > int[] maskSeeds( + final RandomAccessibleInterval seeds, + final RandomAccessibleInterval speed) + { + if (!Arrays.equals(seeds.dimensionsAsLongArray(), speed + .dimensionsAsLongArray())) + { + throw new IllegalArgumentException( + "Seed and speed image dimensions differ: " + Arrays.toString(seeds + .dimensionsAsLongArray()) + " vs " + Arrays.toString(speed + .dimensionsAsLongArray())); + } + final var indices = new int[size(seeds)]; + var count = 0; + var i = 0; + for (final var b : Views.flatIterable(seeds)) { + if (b.get()) indices[count++] = i; + i++; + } + return Arrays.copyOf(indices, count); + } + + private static int[] pointSeeds(final List seeds, + final RandomAccessibleInterval speed) + { + final var n = speed.numDimensions(); + final var indices = new int[seeds.size()]; + var s = 0; + for (final var p : seeds) { + if (p.numDimensions() != n) { + throw new IllegalArgumentException("Seed point has " + p + .numDimensions() + " dimensions but the image has " + n); + } + var index = 0L; + var stride = 1L; + for (var d = 0; d < n; d++) { + final var c = p.getLongPosition(d) - speed.min(d); + if (c < 0 || c >= speed.dimension(d)) { + throw new IllegalArgumentException( + "Seed point outside the image: " + Arrays.toString(p + .positionAsLongArray())); + } + index += c * stride; + stride *= speed.dimension(d); + } + indices[s++] = (int) index; + } + return indices; + } + + private static int size(final RandomAccessibleInterval image) { + final var size = Intervals.numElements(image); + if (size > Integer.MAX_VALUE - 8) { + throw new IllegalArgumentException("Image too large: " + size + + " pixels"); + } + return (int) size; + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/segment/graphCut/BKMaxFlow.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/segment/graphCut/BKMaxFlow.java new file mode 100644 index 000000000..da98a46a5 --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/segment/graphCut/BKMaxFlow.java @@ -0,0 +1,473 @@ +/*- + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.segment.graphCut; + +import java.util.Arrays; + +/** + * Computes the maximum flow, and the corresponding minimum cut, of a directed + * graph with a source and a sink terminal, using the augmenting path algorithm + * of Boykov & Kolmogorov (2004), "An experimental comparison of + * min-cut/max-flow algorithms for energy minimization in vision", IEEE Trans. + * Pattern Anal. Mach. Intell. 26(9):1124-1137. + *

+ * The algorithm grows two search trees, from the source and from the sink, + * through edges with residual capacity. When the trees touch, the flow is + * augmented along the path they form, and the nodes whose tree edges became + * saturated are orphaned. The orphans are then adopted by other nodes of the + * same tree, or freed. Unlike other augmenting path methods, the trees are + * reused from one augmentation to the next, which makes the algorithm fast on + * the sparse, grid-like graphs of image segmentation. A new parent of an + * orphan is chosen among its candidates with the shortest path to the + * terminal, which is tracked with time stamps as described in the paper. + *

+ *

+ * Usage: create the graph with its number of nodes, set the terminal + * capacities with {@link #addTerminalWeights} and the edges with + * {@link #addEdge}, then call {@link #computeMaximumFlow()} once and query + * {@link #isSource} for the segmentation. Capacities must be nonnegative. + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +public class BKMaxFlow { + + private static final byte FREE = 0; + private static final byte SOURCE = 1; + private static final byte SINK = 2; + + /** Parent marker of a node attached directly to its terminal. */ + private static final int TERMINAL = -1; + + /** Parent marker of a node which has lost its parent. */ + private static final int ORPHAN = -2; + + private final int nodeCount; + + /** + * The residual terminal capacity of each node: from the source if positive, + * to the sink if negative. + */ + private final double[] terminalCapacity; + + /** The first edge leaving each node, or -1. */ + private final int[] firstEdge; + + /** The tree of each node. */ + private final byte[] tree; + + /** The edge from each node to its parent in its tree, or a marker. */ + private final int[] parent; + + /** The augmentation at which the distance of each node was last known. */ + private final int[] timestamp; + + /** The number of edges from each node to its terminal. */ + private final int[] distance; + + private final boolean[] active; + + /** The node each edge points to. Edge {@code e ^ 1} is its reverse. */ + private int[] head; + + /** The next edge leaving the same node as each edge, or -1. */ + private int[] nextEdge; + + /** The residual capacity of each edge. */ + private double[] capacity; + + private int edgeCount; + private double flow; + private int time; + private boolean computed; + + /** + * Creates a graph without edges. + * + * @param nodeCount the number of nodes, which are numbered from 0 + * @param edgeCountHint the expected number of calls to {@link #addEdge} + */ + public BKMaxFlow(final int nodeCount, final int edgeCountHint) { + this.nodeCount = nodeCount; + terminalCapacity = new double[nodeCount]; + firstEdge = new int[nodeCount]; + Arrays.fill(firstEdge, -1); + tree = new byte[nodeCount]; + parent = new int[nodeCount]; + timestamp = new int[nodeCount]; + distance = new int[nodeCount]; + active = new boolean[nodeCount]; + final var capacityHint = 2 * Math.max(1, edgeCountHint); + head = new int[capacityHint]; + nextEdge = new int[capacityHint]; + capacity = new double[capacityHint]; + } + + /** Gets the number of nodes. */ + public int nodeCount() { + return nodeCount; + } + + /** + * Adds capacities to the edges from the source to a node and from the node + * to the sink. + * + * @param node the node + * @param source the capacity to add to the edge from the source + * @param sink the capacity to add to the edge to the sink + */ + public void addTerminalWeights(final int node, final double source, + final double sink) + { + checkModifiable(); + checkCapacity(source); + checkCapacity(sink); + // Note: pushing min(source, sink) through the node right away leaves + // residual capacity on at most one of its terminal edges. + final var residual = terminalCapacity[node]; + final double fromSource = Math.max(residual, 0) + source; + final double toSink = Math.max(-residual, 0) + sink; + flow += Math.min(fromSource, toSink); + terminalCapacity[node] = fromSource - toSink; + } + + /** + * Adds a pair of opposite edges between two nodes. + * + * @param p the first node + * @param q the second node + * @param capacityPQ the capacity of the edge from {@code p} to {@code q} + * @param capacityQP the capacity of the edge from {@code q} to {@code p} + */ + public void addEdge(final int p, final int q, final double capacityPQ, + final double capacityQP) + { + checkModifiable(); + checkCapacity(capacityPQ); + checkCapacity(capacityQP); + if (p == q) throw new IllegalArgumentException("Self loops are not allowed."); + if (edgeCount + 2 > head.length) { + final var length = Math.max(4, 2 * head.length); + head = Arrays.copyOf(head, length); + nextEdge = Arrays.copyOf(nextEdge, length); + capacity = Arrays.copyOf(capacity, length); + } + final var e = edgeCount; + head[e] = q; + capacity[e] = capacityPQ; + nextEdge[e] = firstEdge[p]; + firstEdge[p] = e; + head[e + 1] = p; + capacity[e + 1] = capacityQP; + nextEdge[e + 1] = firstEdge[q]; + firstEdge[q] = e + 1; + edgeCount += 2; + } + + /** + * Computes the maximum flow from the source to the sink. + * + * @return the value of the maximum flow, which equals the capacity of the + * minimum cut + */ + public double computeMaximumFlow() { + checkModifiable(); + computed = true; + + final var queue = new IntQueue(nodeCount); + final var orphans = new IntQueue(16); + for (var p = 0; p < nodeCount; p++) { + if (terminalCapacity[p] == 0) continue; + tree[p] = terminalCapacity[p] > 0 ? SOURCE : SINK; + parent[p] = TERMINAL; + distance[p] = 1; + activate(queue, p); + } + + var current = -1; + while (true) { + // Growth: extend the trees from an active node until they touch. + // Note: the current node is active, but no longer queued. + if (current >= 0 && tree[current] == FREE) { + active[current] = false; + current = -1; + } + var meeting = -1; + while (meeting < 0) { + if (current < 0) { + if (queue.isEmpty()) return flow; + current = queue.poll(); + if (tree[current] == FREE) { + active[current] = false; + current = -1; + continue; + } + } + meeting = grow(queue, current); + if (meeting < 0) { + active[current] = false; + current = -1; + } + } + + // Augmentation: push the bottleneck flow along the path. + time++; + augment(meeting, orphans); + + // Adoption: find new parents for the orphans, or free them. + while (!orphans.isEmpty()) + adopt(orphans.poll(), queue, orphans); + } + } + + /** + * Gets whether a node is on the source side of the minimum cut, i.e. can be + * reached from the source through edges with residual capacity. + */ + public boolean isSource(final int node) { + if (!computed) throw new IllegalStateException( + "The maximum flow has not been computed."); + return tree[node] == SOURCE; + } + + /** Gets the value of the maximum flow, once computed. */ + public double getFlow() { + if (!computed) throw new IllegalStateException( + "The maximum flow has not been computed."); + return flow; + } + + /** + * Grows the tree of a node into its neighbors. + * + * @return an edge from the source tree to the sink tree with residual + * capacity, or -1 if the node has no neighbor in the other tree + */ + private int grow(final IntQueue queue, final int p) { + final var fromSource = tree[p] == SOURCE; + for (var e = firstEdge[p]; e >= 0; e = nextEdge[e]) { + // The residual capacity in the direction of the tree's flow. + final var residual = fromSource ? capacity[e] : capacity[e ^ 1]; + if (residual <= 0) continue; + final var q = head[e]; + if (tree[q] == FREE) { + tree[q] = tree[p]; + parent[q] = e ^ 1; + timestamp[q] = timestamp[p]; + distance[q] = distance[p] + 1; + activate(queue, q); + } + else if (tree[q] != tree[p]) { + return fromSource ? e : e ^ 1; + } + } + return -1; + } + + /** Augments the flow along the path through the given edge. */ + private void augment(final int meeting, final IntQueue orphans) { + // Find the bottleneck capacity. + var bottleneck = capacity[meeting]; + var p = head[meeting ^ 1]; + while (parent[p] != TERMINAL) { + final var e = parent[p]; + bottleneck = Math.min(bottleneck, capacity[e ^ 1]); + p = head[e]; + } + bottleneck = Math.min(bottleneck, terminalCapacity[p]); + p = head[meeting]; + while (parent[p] != TERMINAL) { + final var e = parent[p]; + bottleneck = Math.min(bottleneck, capacity[e]); + p = head[e]; + } + bottleneck = Math.min(bottleneck, -terminalCapacity[p]); + + // Push it, orphaning the nodes whose tree edges become saturated. + capacity[meeting] -= bottleneck; + capacity[meeting ^ 1] += bottleneck; + p = head[meeting ^ 1]; + while (parent[p] != TERMINAL) { + final var e = parent[p]; + capacity[e] += bottleneck; + capacity[e ^ 1] -= bottleneck; + final var q = head[e]; + if (capacity[e ^ 1] <= 0) orphan(p, orphans); + p = q; + } + terminalCapacity[p] -= bottleneck; + if (terminalCapacity[p] <= 0) { + terminalCapacity[p] = 0; + orphan(p, orphans); + } + p = head[meeting]; + while (parent[p] != TERMINAL) { + final var e = parent[p]; + capacity[e] -= bottleneck; + capacity[e ^ 1] += bottleneck; + final var q = head[e]; + if (capacity[e] <= 0) orphan(p, orphans); + p = q; + } + terminalCapacity[p] += bottleneck; + if (terminalCapacity[p] >= 0) { + terminalCapacity[p] = 0; + orphan(p, orphans); + } + flow += bottleneck; + } + + private void orphan(final int p, final IntQueue orphans) { + parent[p] = ORPHAN; + orphans.add(p); + } + + /** Finds a new parent for an orphan, or frees it. */ + private void adopt(final int p, final IntQueue queue, + final IntQueue orphans) + { + final var fromSource = tree[p] == SOURCE; + + // Look for the valid parent with the shortest path to the terminal. + var bestEdge = -1; + var bestDistance = Integer.MAX_VALUE; + for (var e = firstEdge[p]; e >= 0; e = nextEdge[e]) { + final var q = head[e]; + if (tree[q] != tree[p]) continue; + final var residual = fromSource ? capacity[e ^ 1] : capacity[e]; + if (residual <= 0) continue; + final var d = rootDistance(q); + if (d < bestDistance) { + bestDistance = d; + bestEdge = e; + } + } + if (bestEdge >= 0) { + parent[p] = bestEdge; + timestamp[p] = time; + distance[p] = bestDistance + 1; + return; + } + + // Free the orphan, orphaning its children. + for (var e = firstEdge[p]; e >= 0; e = nextEdge[e]) { + final var q = head[e]; + if (tree[q] != tree[p]) continue; + final var residual = fromSource ? capacity[e ^ 1] : capacity[e]; + if (residual > 0) activate(queue, q); + if (parent[q] >= 0 && head[parent[q]] == p) orphan(q, orphans); + } + tree[p] = FREE; + } + + /** + * Gets the number of edges from a node to its terminal, or + * {@link Integer#MAX_VALUE} if its path leads to an orphan. Updates the + * distances along the path. + */ + private int rootDistance(final int q) { + var steps = 0; + var u = q; + int d; + while (true) { + if (timestamp[u] == time) { + d = distance[u] + steps; + break; + } + if (parent[u] == TERMINAL) { + d = 1 + steps; + break; + } + if (parent[u] == ORPHAN) return Integer.MAX_VALUE; + u = head[parent[u]]; + steps++; + } + u = q; + for (var k = d; timestamp[u] != time; k--) { + timestamp[u] = time; + distance[u] = k; + if (parent[u] == TERMINAL) break; + u = head[parent[u]]; + } + return d; + } + + private void activate(final IntQueue queue, final int p) { + if (active[p]) return; + active[p] = true; + queue.add(p); + } + + private void checkModifiable() { + if (computed) throw new IllegalStateException( + "The maximum flow has already been computed."); + } + + private static void checkCapacity(final double c) { + if (!(c >= 0) || Double.isInfinite(c)) throw new IllegalArgumentException( + "Capacities must be finite and nonnegative: " + c); + } + + /** A growable FIFO queue of ints. */ + private static class IntQueue { + + private int[] values; + private int start; + private int size; + + private IntQueue(final int capacity) { + values = new int[Math.max(1, capacity)]; + } + + private boolean isEmpty() { + return size == 0; + } + + private void add(final int value) { + if (size == values.length) { + final var grown = new int[2 * values.length]; + for (var i = 0; i < size; i++) + grown[i] = values[(start + i) % values.length]; + values = grown; + start = 0; + } + values[(start + size) % values.length] = value; + size++; + } + + private int poll() { + final var value = values[start]; + start = (start + 1) % values.length; + size--; + return value; + } + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/segment/graphCut/DefaultGraphCut.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/segment/graphCut/DefaultGraphCut.java new file mode 100644 index 000000000..0609ebe71 --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/segment/graphCut/DefaultGraphCut.java @@ -0,0 +1,240 @@ +/*- + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.segment.graphCut; + +import java.util.function.BiFunction; + +import net.imglib2.Dimensions; +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.algorithm.neighborhood.Shape; +import net.imglib2.type.logic.BitType; +import net.imglib2.type.numeric.RealType; + +import org.scijava.function.Functions; +import org.scijava.ops.spi.Nullable; +import org.scijava.ops.spi.OpDependency; + +/** + * Segments an n-dimensional image into a foreground and a background region + * of different mean intensities, by a minimum graph cut. + *

+ * The intensities of each region are modeled as Gaussian with the region mean + * and a common standard deviation {@code sigma}. The data term is the negative + * log-likelihood {@code (I_p - mean)^2 / (2 sigma^2)} of each label, and the + * smoothness term is a Potts model with weight {@code lambda}, optionally + * reduced across intensity edges. The labeling of minimal energy is found + * exactly with the min-cut/max-flow algorithm of Boykov & Kolmogorov + * (2004); see {@link GridGraphCut} for the energy and {@link BKMaxFlow} for + * the algorithm. With {@code lambda = 0}, a pixel is foreground if and only + * if it is closer to the foreground mean than to the background mean. + *

+ *

+ * The means and {@code sigma} are estimated by fitting a mixture of two + * Gaussians of equal variance to the intensities with the EM algorithm + * (Dempster, Laird & Rubin (1977), J. R. Stat. Soc. B 39(1):1-38), + * starting from the two classes of the iterative intermeans threshold + * (Ridler & Calvard (1978), "Picture thresholding using an iterative + * selection method", IEEE Trans. Syst. Man Cybern. 8(8):630-632), the + * brighter class being the foreground. Given means are kept fixed during the + * fit. For other data terms, pass cost images to the + * {@code segment.graphCut} op which takes them. + *

+ *

+ * Implemented from the papers; the code of the Fiji Graph_Cut plugin was not + * consulted. The graph is held in flat arrays, so the image is limited to + * about 231 / (number of neighbors) pixels. + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @param the image type + * @implNote op names='segment.graphCut' + */ +public class DefaultGraphCut> implements + Functions.Arity6, Double, Double, Double, Boolean, Shape, RandomAccessibleInterval> +{ + + private static final int MAX_ITERATIONS = 1000; + + @OpDependency(name = "create.img") + private BiFunction> createOp; + + /** + * Segments an image. + * + * @param image the image to segment + * @param lambda the weight of the smoothness term relative to the data + * term (default 1); 0 gives a pixelwise segmentation + * @param foregroundMean the mean intensity of the foreground (default: + * estimated) + * @param backgroundMean the mean intensity of the background (default: + * estimated) + * @param contrast whether to weaken the smoothness term across intensity + * edges, by the factor exp(-beta (I_p - I_q)^2) with beta the + * inverse of twice the mean squared neighbor difference (default + * false) + * @param connectivity the neighborhood defining which pixels are adjacent, + * such as a {@code DiamondShape} of radius 1 for face connectivity + * (4-connectivity in 2D, 6-connectivity in 3D) or a + * {@code RectangleShape} of span 1 for full connectivity + * (8-connectivity in 2D, 26-connectivity in 3D), with the weight + * of each pair divided by its distance. Defaults to full + * connectivity. Full connectivity makes boundaries costlier than + * face connectivity, by a factor of about 2.4 in 2D and 6 in 3D, so + * it calls for a smaller lambda. + * @return the foreground mask + */ + @Override + public RandomAccessibleInterval apply( + final RandomAccessibleInterval image, @Nullable Double lambda, + @Nullable Double foregroundMean, @Nullable Double backgroundMean, + @Nullable Boolean contrast, @Nullable Shape connectivity) + { + if (lambda == null) lambda = 1.0; + if (contrast == null) contrast = false; + + final var values = GridGraphCut.flatten(image); + final var means = intermeans(values); + if (backgroundMean != null) means[0] = backgroundMean; + if (foregroundMean != null) means[1] = foregroundMean; + final var variance = fitMixture(values, means, backgroundMean != null, + foregroundMean != null); + final double fg = means[1], bg = means[0]; + + final var fgCost = new double[values.length]; + final var bgCost = new double[values.length]; + for (var i = 0; i < values.length; i++) { + final var df = values[i] - fg; + final var db = values[i] - bg; + fgCost[i] = df * df / (2 * variance); + bgCost[i] = db * db / (2 * variance); + } + + final var foreground = GridGraphCut.segment(image.dimensionsAsLongArray(), + fgCost, bgCost, contrast ? values : null, lambda, connectivity); + return GridGraphCut.mask(createOp, image, foreground); + } + + /** + * Fits a mixture of two Gaussians of equal variance with the EM algorithm. + * + * @param means the initial background and foreground means, updated in + * place + * @return the variance + */ + private static double fitMixture(final double[] values, final double[] means, + final boolean fixBackground, final boolean fixForeground) + { + final var count = values.length; + if (count == 0) return 1; + + // Start from the pooled variance of the nearest-mean classes. + var variance = 0.0; + var weight = 0.0; + for (final var v : values) { + final var d0 = v - means[0]; + final var d1 = v - means[1]; + variance += Math.min(d0 * d0, d1 * d1); + if (d1 * d1 < d0 * d0) weight++; + } + variance /= count; + weight = Math.min(Math.max(weight / count, 0.01), 0.99); + final var scale = Math.abs(means[1] - means[0]); + if (!(variance > 0) || scale == 0) return variance > 0 ? variance : 1; + + for (var iteration = 0; iteration < MAX_ITERATIONS; iteration++) { + double n1 = 0, s0 = 0, s1 = 0, q0 = 0, q1 = 0; + final var prior = Math.log(weight / (1 - weight)); + for (final var v : values) { + final var d0 = v - means[0]; + final var d1 = v - means[1]; + final var logOdds = prior + (d0 * d0 - d1 * d1) / (2 * variance); + final var r = 1 / (1 + Math.exp(-logOdds)); + n1 += r; + s0 += (1 - r) * v; + s1 += r * v; + q0 += (1 - r) * v * v; + q1 += r * v * v; + } + final var n0 = count - n1; + final double m0 = fixBackground || n0 <= 0 ? means[0] : s0 / n0; + final double m1 = fixForeground || n1 <= 0 ? means[1] : s1 / n1; + final var next = (q0 - 2 * m0 * s0 + m0 * m0 * n0 + q1 - 2 * m1 * s1 + + m1 * m1 * n1) / count; + if (!(next > 1e-12 * scale * scale)) break; + final var change = Math.abs(m0 - means[0]) + Math.abs(m1 - means[1]) + + Math.abs(Math.sqrt(next) - Math.sqrt(variance)); + means[0] = m0; + means[1] = m1; + variance = next; + weight = Math.min(Math.max(n1 / count, 1e-6), 1 - 1e-6); + if (change < 1e-9 * scale) break; + } + return variance; + } + + /** + * Computes the class means of the iterative intermeans threshold. + * + * @return the lower and upper class means + */ + private static double[] intermeans(final double[] values) { + var min = Double.POSITIVE_INFINITY; + var max = Double.NEGATIVE_INFINITY; + for (final var v : values) { + min = Math.min(min, v); + max = Math.max(max, v); + } + var threshold = (min + max) / 2; + var lower = min; + var upper = max; + for (var iteration = 0; iteration < MAX_ITERATIONS; iteration++) { + double sumLower = 0, sumUpper = 0; + long countLower = 0, countUpper = 0; + for (final var v : values) { + if (v <= threshold) { + sumLower += v; + countLower++; + } + else { + sumUpper += v; + countUpper++; + } + } + if (countLower == 0 || countUpper == 0) break; + lower = sumLower / countLower; + upper = sumUpper / countUpper; + final var next = (lower + upper) / 2; + if (next == threshold) break; + threshold = next; + } + return new double[] { lower, upper }; + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/segment/graphCut/GraphCutCosts.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/segment/graphCut/GraphCutCosts.java new file mode 100644 index 000000000..70001ee83 --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/segment/graphCut/GraphCutCosts.java @@ -0,0 +1,123 @@ +/*- + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.segment.graphCut; + +import java.util.function.BiFunction; + +import net.imglib2.Dimensions; +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.algorithm.neighborhood.Shape; +import net.imglib2.type.logic.BitType; +import net.imglib2.type.numeric.RealType; + +import org.scijava.function.Functions; +import org.scijava.ops.spi.Nullable; +import org.scijava.ops.spi.OpDependency; + +/** + * Binary segmentation of an n-dimensional image by a minimum graph cut, with a + * data term given as cost images. + *

+ * Minimizes {@code sum_p D_p(L_p) + sum_{p,q} w_pq [L_p != L_q]}, where + * {@code D_p(foreground)} and {@code D_p(background)} are read from the cost + * images, typically negative log-likelihoods of the labels (e.g. + * {@code -ln P(foreground)} and {@code -ln(1 - P(foreground))} from a + * probability map), and {@code w_pq} is the smoothness weight {@code lambda}, + * optionally reduced across the edges of a contrast image. The labeling of + * minimal energy is found exactly with the min-cut/max-flow algorithm of + * Boykov & Kolmogorov (2004); see {@link GridGraphCut} for the energy and + * {@link BKMaxFlow} for the algorithm. Only the difference of the two costs + * of a pixel matters, so the costs may be negative. With {@code lambda = 0}, + * a pixel is foreground if and only if its foreground cost is lower than its + * background cost. + *

+ *

+ * Implemented from the papers; the code of the Fiji Graph_Cut plugin was not + * consulted. The graph is held in flat arrays, so the image is limited to + * about 231 / (number of neighbors) pixels. + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @param the type of the foreground costs + * @param the type of the background costs + * @param the type of the contrast image + * @implNote op names='segment.graphCut' + */ +public class GraphCutCosts, U extends RealType, V extends RealType> + implements + Functions.Arity5, RandomAccessibleInterval, Double, RandomAccessibleInterval, Shape, RandomAccessibleInterval> +{ + + @OpDependency(name = "create.img") + private BiFunction> createOp; + + /** + * Segments an image given its label costs. + * + * @param foregroundCost the cost of labeling each pixel foreground + * @param backgroundCost the cost of labeling each pixel background, of the + * same size + * @param lambda the weight of the smoothness term (default 1); 0 gives a + * pixelwise segmentation + * @param contrastImage if given, the smoothness term is weakened across its + * edges, by the factor exp(-beta (I_p - I_q)^2) with beta the + * inverse of twice the mean squared neighbor difference (default: + * no contrast term) + * @param connectivity the neighborhood defining which pixels are adjacent, + * such as a {@code DiamondShape} of radius 1 for face connectivity + * (4-connectivity in 2D, 6-connectivity in 3D) or a + * {@code RectangleShape} of span 1 for full connectivity + * (8-connectivity in 2D, 26-connectivity in 3D), with the weight + * of each pair divided by its distance. Defaults to full + * connectivity. Full connectivity makes boundaries costlier than + * face connectivity, by a factor of about 2.4 in 2D and 6 in 3D, so + * it calls for a smaller lambda. + * @return the foreground mask + */ + @Override + public RandomAccessibleInterval apply( + final RandomAccessibleInterval foregroundCost, + final RandomAccessibleInterval backgroundCost, @Nullable Double lambda, + @Nullable RandomAccessibleInterval contrastImage, + @Nullable Shape connectivity) + { + if (lambda == null) lambda = 1.0; + GridGraphCut.checkDimensions(foregroundCost, backgroundCost); + if (contrastImage != null) GridGraphCut.checkDimensions(foregroundCost, + contrastImage); + + final var foreground = GridGraphCut.segment(foregroundCost + .dimensionsAsLongArray(), GridGraphCut.flatten(foregroundCost), + GridGraphCut.flatten(backgroundCost), contrastImage == null ? null + : GridGraphCut.flatten(contrastImage), lambda, connectivity); + return GridGraphCut.mask(createOp, foregroundCost, foreground); + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/segment/graphCut/GridGraphCut.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/segment/graphCut/GridGraphCut.java new file mode 100644 index 000000000..e39902b0d --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/segment/graphCut/GridGraphCut.java @@ -0,0 +1,252 @@ +/*- + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.segment.graphCut; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.BiFunction; + +import net.imglib2.Dimensions; +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.algorithm.neighborhood.Shape; +import net.imglib2.type.logic.BitType; +import net.imglib2.type.numeric.RealType; +import net.imglib2.view.Views; + +import org.scijava.ops.image.util.Neighborhoods; + +/** + * Binary segmentation of an n-dimensional grid by a minimum graph cut, shared + * by the {@code segment.graphCut} ops. + *

+ * It minimizes the energy + * {@code E(L) = sum_p D_p(L_p) + sum_{p,q} w_pq [L_p != L_q]} over the binary + * labelings {@code L}, where {@code D_p} are the costs of labeling pixel + * {@code p} foreground or background, and the sum of the smoothness term runs + * over the pairs of neighboring pixels, with + * {@code w_pq = lambda exp(-beta (I_p - I_q)^2) / |p - q|} (Boykov & + * Jolly (2001), "Interactive graph cuts for optimal boundary & region + * segmentation of objects in N-D images", Proc. ICCV 1:105-112). Without a + * contrast image, {@code beta = 0}, i.e. the Potts model. With one, + * {@code beta = 1 / (2 <(I_p - I_q)^2>)}, averaged over all neighbor pairs + * (Rother, Kolmogorov & Blake (2004), "GrabCut: interactive foreground + * extraction using iterated graph cuts", ACM Trans. Graph. 23(3):309-314). + * The minimum is found exactly as the minimum cut of a graph with one node + * per pixel (Greig, Porteous & Seheult (1989), J. R. Stat. Soc. B + * 51(2):271-279), computed with {@link BKMaxFlow}. + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +public final class GridGraphCut { + + private GridGraphCut() { + // Prevent instantiation of static utility class. + } + + /** + * Segments a grid. + * + * @param dims the grid dimensions + * @param foregroundCost the cost of labeling each pixel foreground, + * flattened with dimension 0 varying fastest + * @param backgroundCost the cost of labeling each pixel background + * @param contrast the contrast image, or {@code null} + * @param lambda the weight of the smoothness term + * @param connectivity the neighborhood defining which pixels are adjacent, + * or null for full connectivity + * @return whether each pixel is foreground + */ + public static boolean[] segment(final long[] dims, + final double[] foregroundCost, final double[] backgroundCost, + final double[] contrast, final double lambda, final Shape connectivity) + { + if (!(lambda >= 0) || Double.isInfinite(lambda)) + throw new IllegalArgumentException( + "The smoothness weight must be finite and nonnegative."); + final var n = dims.length; + final var size = foregroundCost.length; + final var offsets = offsets(n, connectivity); + final var strides = new long[n]; + var stride = 1L; + for (var d = 0; d < n; d++) { + strides[d] = stride; + stride *= dims[d]; + } + + final var hint = lambda > 0 ? (long) size * offsets.size() : 0; + if (hint > Integer.MAX_VALUE / 2) throw new IllegalArgumentException( + "The image is too large."); + final var graph = new BKMaxFlow(size, (int) hint); + for (var p = 0; p < size; p++) { + final var f = foregroundCost[p]; + final var b = backgroundCost[p]; + if (Double.isNaN(f) || Double.isNaN(b) || Double.isInfinite(f - b)) + throw new IllegalArgumentException( + "The costs must be finite numbers."); + final var min = Math.min(f, b); + // Note: the source side of the cut is the foreground. + graph.addTerminalWeights(p, b - min, f - min); + } + + if (lambda > 0) { + final var beta = contrast == null ? 0 : beta(dims, strides, offsets, + contrast); + forEachPair(dims, strides, offsets, (p, q, offset) -> { + var w = lambda / offset.distance; + if (beta > 0) { + final var diff = contrast[p] - contrast[q]; + w *= Math.exp(-beta * diff * diff); + } + graph.addEdge(p, q, w, w); + }); + } + + graph.computeMaximumFlow(); + final var foreground = new boolean[size]; + for (var p = 0; p < size; p++) + foreground[p] = graph.isSource(p); + return foreground; + } + + /** Copies an image into an array, with dimension 0 varying fastest. */ + static > double[] flatten( + final RandomAccessibleInterval image) + { + if (image.size() > Integer.MAX_VALUE) throw new IllegalArgumentException( + "The image is too large."); + final var data = new double[(int) image.size()]; + var i = 0; + for (final var v : Views.flatIterable(image)) + data[i++] = v.getRealDouble(); + return data; + } + + /** Creates the mask image of a segmentation. */ + static RandomAccessibleInterval mask( + final BiFunction> createOp, + final Dimensions dims, final boolean[] foreground) + { + final var mask = createOp.apply(dims, new BitType()); + var i = 0; + for (final var v : Views.flatIterable(mask)) + v.set(foreground[i++]); + return mask; + } + + /** Checks that the images have the same dimensions. */ + static void checkDimensions(final RandomAccessibleInterval a, + final RandomAccessibleInterval b) + { + if (a.numDimensions() != b.numDimensions()) throw new IllegalArgumentException( + "The images must have the same dimensions."); + for (var d = 0; d < a.numDimensions(); d++) + if (a.dimension(d) != b.dimension(d)) + throw new IllegalArgumentException( + "The images must have the same dimensions."); + } + + /** + * Computes {@code beta = 1 / (2 <(I_p - I_q)^2>)}, or 0 if the contrast + * image is constant. + */ + private static double beta(final long[] dims, final long[] strides, + final List offsets, final double[] contrast) + { + final var sum = new double[2]; + forEachPair(dims, strides, offsets, (p, q, offset) -> { + final var diff = contrast[p] - contrast[q]; + sum[0] += diff * diff; + sum[1]++; + }); + return sum[0] > 0 ? sum[1] / (2 * sum[0]) : 0; + } + + /** + * Gets the offsets to half of the neighbors (those whose last nonzero + * coordinate is positive), so that each pair is visited once. + */ + private static List offsets(final int n, final Shape connectivity) { + final List offsets = new ArrayList<>(); + for (final var offset : Neighborhoods.rasterOffsets(connectivity, n)) { + var last = 0; + var norm2 = 0; + for (var d = 0; d < n; d++) { + if (offset[d] != 0) last = offset[d]; + norm2 += offset[d] * offset[d]; + } + if (last > 0) offsets.add(new Offset(offset, Math.sqrt(norm2))); + } + return offsets; + } + + private static void forEachPair(final long[] dims, final long[] strides, + final List offsets, final PairConsumer action) + { + final var n = dims.length; + var size = 1L; + for (final var d : dims) + size *= d; + final var position = new long[n]; + for (var p = 0; p < size; p++) { + for (final var offset : offsets) { + var q = p; + var inside = true; + for (var d = 0; d < n && inside; d++) { + final var x = position[d] + offset.offset[d]; + if (x < 0 || x >= dims[d]) inside = false; + q += offset.offset[d] * strides[d]; + } + if (inside) action.accept(p, q, offset); + } + for (var d = 0; d < n; d++) { + if (++position[d] < dims[d]) break; + position[d] = 0; + } + } + } + + private interface PairConsumer { + + void accept(int p, int q, Offset offset); + } + + private static class Offset { + + private final int[] offset; + private final double distance; + + private Offset(final int[] offset, final double distance) { + this.offset = offset; + this.distance = distance; + } + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/segment/hough/HoughCircle.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/segment/hough/HoughCircle.java new file mode 100644 index 000000000..f8854b648 --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/segment/hough/HoughCircle.java @@ -0,0 +1,148 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.segment.hough; + +import net.imglib2.RealLocalizable; +import net.imglib2.RealPoint; + +/** + * A circle (or n-dimensional sphere) detected by + * {@code segment.detectHoughCircles}: a center position, a radius and a + * sensitivity. + *

+ * The sensitivity is the surface area of the sphere divided by the number of + * votes it received in the Hough transform. It is approximately 1 for a + * complete, one-pixel-thick circle, and grows as the circle gets fainter or + * more incomplete: lower values denote stronger detections. + *

+ * Adapted from {@code HoughCircle} of CircleSkinner by Jean-Yves Tinevez + * (CircleSkinner, + * BSD-2-Clause). + *

+ * The original CircleSkinner license notice follows, with the copyright line + * as it appears in the CircleSkinner sources. + *

+ * + *
+ * Copyright (C) 2016 - 2022 My Company, Inc.
+ *
+ * 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.
+ * 
+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @author Jean-Yves Tinevez + */ +public class HoughCircle extends RealPoint implements Comparable { + + private final double radius; + + private final double sensitivity; + + /** + * Creates a new circle. + * + * @param center the center of the circle + * @param radius the radius of the circle + * @param sensitivity the sensitivity of the detection (lower is stronger) + */ + public HoughCircle(final RealLocalizable center, final double radius, + final double sensitivity) + { + super(center); + this.radius = radius; + this.sensitivity = sensitivity; + } + + /** + * Gets the radius of this circle. + * + * @return the radius, in pixels + */ + public double getRadius() { + return radius; + } + + /** + * Gets the sensitivity of this circle: the surface area of the circle + * divided by its number of votes. Lower values denote stronger detections. + * + * @return the sensitivity + */ + public double getSensitivity() { + return sensitivity; + } + + /** + * Tests whether a point lies inside (or on) this circle. Only the first + * {@link #numDimensions()} coordinates of the point are considered. + * + * @param point the point to test + * @return true iff the point lies within {@link #getRadius()} of the center + */ + public boolean contains(final RealLocalizable point) { + var dr2 = 0.0; + for (var d = 0; d < numDimensions(); d++) { + final var dx = getDoublePosition(d) - point.getDoublePosition(d); + dr2 += dx * dx; + } + return dr2 <= radius * radius; + } + + /** Orders circles by ascending sensitivity, i.e. strongest first. */ + @Override + public int compareTo(final HoughCircle o) { + return Double.compare(sensitivity, o.sensitivity); + } + + @Override + public String toString() { + return String.format("%s R=%.2f sensitivity=%.3f", super.toString(), + radius, sensitivity); + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/segment/hough/HoughCircleDetector.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/segment/hough/HoughCircleDetector.java new file mode 100644 index 000000000..adc7fd991 --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/segment/hough/HoughCircleDetector.java @@ -0,0 +1,251 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.segment.hough; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import net.imglib2.Localizable; +import net.imglib2.Point; +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.RealPoint; +import net.imglib2.algorithm.localextrema.RefinedPeak; +import net.imglib2.algorithm.localextrema.SubpixelLocalization; +import net.imglib2.algorithm.neighborhood.RectangleShape; +import net.imglib2.type.numeric.RealType; +import net.imglib2.view.Views; + +import org.scijava.function.Functions; +import org.scijava.ops.spi.Nullable; + +/** + * Detects circles (or n-dimensional spheres) as peaks in a Hough vote image, + * as produced by {@code transform.houghCircle}. + *

+ * The last dimension of the vote image indexes the radii, so that index + * {@code i} corresponds to radius {@code minRadius + i * stepRadius}. The + * algorithm proceeds as follows: + *

    + *
  1. Find the local maxima of the vote image over its full (n+1)-dimensional + * 3×3×...×3 neighborhood, discarding those with fewer votes than + * {@code S(minRadius) / sensitivity}, where {@code S(r)} is the surface area of + * the n-sphere of radius {@code r} ({@code 2πr} in 2D).
  2. + *
  3. Assign each maximum the sensitivity {@code S(r) / votes}, where + * {@code r} is its radius.
  4. + *
  5. Non-maxima suppression: visiting maxima by ascending sensitivity, discard + * every maximum whose center lies inside an already retained circle.
  6. + *
  7. Refine the position and radius of the retained maxima to subpixel + * accuracy by quadratic fitting, recompute their sensitivity, and discard those + * whose sensitivity exceeds the given one.
  8. + *
+ *

+ * Ported from {@code HoughCircleLocalMaxDetectorOp} of CircleSkinner by + * Jean-Yves Tinevez + * (CircleSkinner, + * BSD-2-Clause), generalized to n dimensions. The original sensitivity is + * {@code 2πr * circleThickness / votes}; this op corresponds to a circle + * thickness of 1 (divide the sensitivity by the thickness to reproduce the + * original threshold). + *

+ * The original CircleSkinner license notice follows, with the copyright line + * as it appears in the CircleSkinner sources. + *

+ * + *
+ * Copyright (C) 2016 - 2022 My Company, Inc.
+ *
+ * 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.
+ * 
+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @author Jean-Yves Tinevez + * @param the type of the vote image + * @implNote op names='segment.detectHoughCircles' + */ +public class HoughCircleDetector> implements + Functions.Arity4, Integer, Integer, Double, List> +{ + + /** + * Detects circles in a Hough vote image. + * + * @param votes the (n+1)-dimensional vote image, whose last dimension indexes + * the tested radii; NaN votes are ignored + * @param minRadius the radius corresponding to the first index of the last + * dimension of the vote image + * @param stepRadius the radius increment between successive indices of the + * last dimension of the vote image (defaults to 1) + * @param sensitivity the maximal sensitivity of the reported circles; larger + * values detect fainter or more incomplete circles (defaults to 20) + * @return the detected circles, sorted by ascending sensitivity (strongest + * first) + */ + @Override + public List apply( // + final RandomAccessibleInterval votes, // + final Integer minRadius, // + @Nullable Integer stepRadius, // + @Nullable Double sensitivity // + ) { + if (stepRadius == null) stepRadius = 1; + if (sensitivity == null) sensitivity = 20.; + final var nDims = votes.numDimensions(); + if (nDims < 2) throw new IllegalArgumentException( + "Vote image must have at least 2 dimensions: " + nDims); + if (sensitivity <= 0) throw new IllegalArgumentException( + "Sensitivity must be positive: " + sensitivity); + final var n = nDims - 1; + final var radiusMin = votes.min(n); + + // Find local maxima above the threshold. + final var threshold = sphereSurface(n, minRadius) / sensitivity; + final List peaks = new ArrayList<>(); + final var neighborhoods = new RectangleShape(1, true) + .neighborhoodsRandomAccessible(Views.extendBorder(votes)) + .randomAccess(); + final var cursor = Views.iterable(votes).localizingCursor(); + CANDIDATE: while (cursor.hasNext()) { + final var value = cursor.next().getRealDouble(); + // Note: NaN votes are never circles. + if (!(value >= threshold)) continue; + neighborhoods.setPosition(cursor); + for (final var neighbor : neighborhoods.get()) { + if (neighbor.getRealDouble() > value) continue CANDIDATE; + } + final var radius = radiusOf(cursor.getDoublePosition(n) - radiusMin, + minRadius, stepRadius); + peaks.add(new Candidate(cursor, radius, sphereSurface(n, radius) / + value)); + } + + // Non-maxima suppression: discard centers inside stronger circles. + Collections.sort(peaks); + final List retained = new ArrayList<>(); + NEXT: for (final var tested : peaks) { + for (final var kept : retained) { + if (kept.contains(tested)) continue NEXT; + } + retained.add(tested); + } + + // Refine to subpixel accuracy. + final var spl = new SubpixelLocalization(nDims); + spl.setAllowMaximaTolerance(true); + spl.setMaxNumMoves(10); + spl.setNumThreads(1); + final List> refined = spl.process(retained, Views + .extendBorder(votes), votes); + + final List circles = new ArrayList<>(refined.size()); + for (final var peak : refined) { + final var radius = radiusOf(peak.getDoublePosition(n) - radiusMin, + minRadius, stepRadius); + final var ls = sphereSurface(n, radius) / peak.getValue(); + if (ls <= 0 || ls > sensitivity) continue; + final var center = new RealPoint(n); + for (var d = 0; d < n; d++) + center.setPosition(peak.getDoublePosition(d), d); + circles.add(new HoughCircle(center, radius, ls)); + } + Collections.sort(circles); + return circles; + } + + private static double radiusOf(final double index, final int minRadius, + final int stepRadius) + { + return minRadius + index * stepRadius; + } + + /** + * Computes the surface area of the n-sphere of the given radius, that is, + * {@code 2 π^(n/2) r^(n-1) / Γ(n/2)}. + */ + private static double sphereSurface(final int n, final double radius) { + // Note: Γ(n/2) by recurrence from Γ(1) = 1 or Γ(1/2) = √π. + var gamma = n % 2 == 0 ? 1.0 : Math.sqrt(Math.PI); + for (var x = n % 2 == 0 ? 1.0 : 0.5; x < n / 2.0; x++) + gamma *= x; + return 2 * Math.pow(Math.PI, n / 2.0) * Math.pow(radius, n - 1) / gamma; + } + + /** A candidate circle on the integer grid of the vote image. */ + private static final class Candidate extends Point implements + Comparable + { + + private final double radius; + + private final double sensitivity; + + Candidate(final Localizable pos, final double radius, + final double sensitivity) + { + super(pos); + this.radius = radius; + this.sensitivity = sensitivity; + } + + /** Tests whether the spatial position of another candidate is inside. */ + boolean contains(final Candidate other) { + var dr2 = 0.0; + for (var d = 0; d < numDimensions() - 1; d++) { + final var dx = getDoublePosition(d) - other.getDoublePosition(d); + dr2 += dx * dx; + } + return dr2 <= radius * radius; + } + + @Override + public int compareTo(final Candidate o) { + return Double.compare(sensitivity, o.sensitivity); + } + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/segment/hwatershed/HSegmentTree.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/segment/hwatershed/HSegmentTree.java new file mode 100644 index 000000000..d74779324 --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/segment/hwatershed/HSegmentTree.java @@ -0,0 +1,703 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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% + */ + +/* + * This file is derived from Interactive-H-Watershed + * (https://github.com/mpicbg-scicomp/Interactive-H-Watershed), whose original + * notice follows. + * + * Author: Benoit Lombardot, Scientific Computing Facility, MPI-CBG, Dresden + * + * Copyright 2017 Max Planck Institute of Molecular Cell Biology and Genetics, + * Dresden, Germany + * + * 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. + * + * 3 - Neither the name of the copyright holder nor the names of its + * contributors may be used to endorse or promote products derived from this + * software without specific prior written permission. + * + * 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 HOLDER 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. + */ + +package org.scijava.ops.image.segment.hwatershed; + +import java.util.ArrayDeque; + +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.algorithm.neighborhood.Shape; +import net.imglib2.type.numeric.RealType; +import net.imglib2.util.Intervals; +import net.imglib2.view.Views; + +import org.scijava.ops.image.util.Neighborhoods; + +/** + * Hierarchy of the peaks (regional maxima) of an n-dimensional image, together + * with the watershed partition of the image into one catchment basin per peak. + *

+ * The hierarchy is built once by flooding the image from its regional maxima in + * decreasing intensity order (an IFT-style watershed). Whenever two flooded + * regions meet at a saddle, the dynamics of the lower peak (peak + * height minus saddle height) is recorded and the two nodes are merged under a + * new node. The resulting binary tree can then be cut cheaply for any seed + * dynamics {@code hMin}, intensity threshold and peak flooding percentage via + * {@link #labels(double, double, double, boolean)}. + *

+ *

+ * This is a translation of {@code HWatershedLabeling}, + * {@code imgTools.LocalMaximaLabeling}, {@code HTreeLabeling} and + * {@code SegmentHierarchyToLabelMap} from the Interactive H-Watershed Fiji + * plugin by Benoit Lombardot (Scientific Computing Facility, MPI-CBG, Dresden), + * released under the BSD-3-Clause license. Differences from the original: + * intensities are kept as {@code double} instead of being truncated to + * {@code int}, so the hierarchical FIFO queue is replaced by an equivalent + * priority queue (higher value first, FIFO among equal values); the interactive + * UI, progress dialog and slice-wise relabelling were dropped. On + * integer-valued images, results are identical to the original. + *

+ *

+ * Node ids: {@code 0} is unused (background), leaves (one per regional maximum) + * are {@code 1..numLeaves()}, and merge nodes follow. + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @author Benoit Lombardot (MPI-CBG) + */ +public final class HSegmentTree { + + /** Value subtracted to break ties between sibling peaks of equal height. */ + private static final double EPSILON = 1e-9; + + private final double[] values; + private final int[] leafMap; + private final int numLeaves; + private final int[] parent; + private final int[][] children; + private final double[] dynamics; + private final double[] imax; + private final double min; + private final double max; + + // These derived features are used to cut the tree (see HTreeLabeling). + private final double[] imin; + private final double[] criteria; + private final int[] roots; + + private HSegmentTree(final double[] values, final int[] leafMap, + final int numLeaves, final int[] parent, final int[][] children, + final double[] dynamics, final double[] imax, final double min, + final double max) + { + this.values = values; + this.leafMap = leafMap; + this.numLeaves = numLeaves; + this.parent = parent; + this.children = children; + this.dynamics = dynamics; + this.imax = imax; + this.min = min; + this.max = max; + + final var nNodes = parent.length; + // Break ties between sibling peaks of identical height. + for (var node = 0; node < nNodes; node++) { + final var c = children[node]; + if (c[0] >= 0 && c[1] >= 0 && imax[c[0]] == imax[c[1]]) { + imax[c[1]] -= EPSILON; + dynamics[c[1]] -= EPSILON; + } + } + imin = new double[nNodes]; + criteria = new double[nNodes]; + var rootCount = 0; + for (var node = 0; node < nNodes; node++) { + imin[node] = imax[node] - dynamics[node]; + final var c = children[node]; + criteria[node] = isLeaf(node) ? 0 : // + Math.min(dynamics[c[0]], dynamics[c[1]]); + if (parent[node] == node) rootCount++; + } + roots = new int[rootCount]; + for (int node = 0, r = 0; node < nNodes; node++) { + if (parent[node] == node) roots[r++] = node; + } + } + + // -- Construction -- + + /** + * Builds the peak hierarchy of the given image. + * + * @param image the image whose bright peaks are to be organized. + * @param connectivity the neighborhood defining which pixels are adjacent + * during the flooding, or null for full connectivity (8 in 2D, 26 + * in 3D); regional maxima are always detected with full + * connectivity, as in the original plugin. + * @return the peak hierarchy + */ + public static > HSegmentTree build( + final RandomAccessibleInterval image, final Shape connectivity) + { + final var size = Intervals.numElements(image); + if (size > Integer.MAX_VALUE) { + throw new IllegalArgumentException("Image too large: " + size + + " elements"); + } + final var dims = image.dimensionsAsLongArray(); + final var values = new double[(int) size]; + var min = Double.POSITIVE_INFINITY; + var max = Double.NEGATIVE_INFINITY; + var i = 0; + for (final var t : Views.flatIterable(image)) { + final var v = t.getRealDouble(); + if (Double.isNaN(v)) { + throw new IllegalArgumentException("NaN values are not supported"); + } + values[i++] = v; + if (v < min) min = v; + if (v > max) max = v; + } + + // Label the regional maxima; these are the leaves of the tree. + final var leafMap = new int[values.length]; + final var numLeaves = labelMaxima(values, dims, min, leafMap); + if (numLeaves <= 0) { + // A flat image has no peaks at all. + return new HSegmentTree(values, new int[values.length], 0, new int[0], + new int[0][], new double[0], new double[0], min, max); + } + + // Initialize the tree. + final var nNodes = 2 * numLeaves; + final var dynamics = new double[nNodes]; + final var imax = new double[nNodes]; + final var parent = new int[nNodes]; + final var children = new int[nNodes][]; + for (var n = 0; n < nNodes; n++) { + children[n] = new int[] { -1, -1 }; + parent[n] = n; + imax[n] = min; + } + + // Queue the seeds. + final var queue = new PixelQueue(values); + for (var p = 0; p < values.length; p++) { + if (leafMap[p] > 0) { + queue.add(p); + imax[leafMap[p]] = values[p]; + } + } + + // Flood the image from the seeds, building the tree as basins meet. + final var offsets = neighborOffsets(dims.length, connectivity); + final var dequeued = new boolean[values.length]; + final var pos = new long[dims.length]; + final var nPos = new long[dims.length]; + var newNode = numLeaves; + while (!queue.isEmpty()) { + final var p = queue.poll(); + final var pVal = values[p]; + indexToPosition(p, dims, pos); + final var pLeaf = leafMap[p]; + var pNode = findRoot(pLeaf, parent); + dequeued[p] = true; + + for (final var offset : offsets) { + if (!neighbor(pos, offset, dims, nPos)) continue; + final var n = positionToIndex(nPos, dims); + if (dequeued[n]) { + // p is the lowest point between the two regions. + final var nLeaf = leafMap[n]; + final var nNode = findRoot(nLeaf, parent); + if (nNode != pNode) { + // Two distinct nodes meet and p is the saddle: merge them. + newNode++; + final var hn = imax[nNode] - pVal; + dynamics[nNode] = hn; + final var hp = imax[pNode] - pVal; + dynamics[pNode] = hp; + + // Merge the node with the smallest dynamics with the first + // node on the other branch that has a higher dynamics. + final int node1; + final int node2; + if (hp == hn) { + node1 = pNode; + node2 = nNode; + } + else if (hp < hn) { + node1 = pNode; + node2 = mergeTarget(nLeaf, hp, parent, children, dynamics); + } + else { + node1 = nNode; + node2 = mergeTarget(pLeaf, hn, parent, children, dynamics); + } + mergeNodes(node1, node2, newNode, parent, children); + pNode = findRoot(newNode, parent); + imax[newNode] = Math.max(imax[node1], imax[node2]); + dynamics[newNode] = Math.max(dynamics[node1], dynamics[node2]); + } + } + if (leafMap[n] == 0) { + // The neighbor is not queued yet. + leafMap[n] = pLeaf; + queue.add(n); + } + } + } + + // Root heights are measured relative to the image minimum. + for (var n = 0; n < nNodes; n++) { + if (dynamics[n] > 0 && parent[n] == n) dynamics[n] = imax[n] - min; + } + + return new HSegmentTree(values, leafMap, numLeaves, parent, children, + dynamics, imax, min, max); + } + + // -- Accessors -- + + /** @return the number of regional maxima (leaves of the tree). */ + public int numLeaves() { + return numLeaves; + } + + /** @return the number of node slots in the tree (including unused id 0). */ + public int numNodes() { + return parent.length; + } + + /** + * @param node a node id + * @return the parent of the node, or the node itself if it is a root + */ + public int parent(final int node) { + return parent[node]; + } + + /** + * @param node a node id + * @return the dynamics (peak height above its merge saddle) of the node + */ + public double dynamics(final int node) { + return dynamics[node]; + } + + /** + * @param node a node id + * @return the maximum intensity of the node + */ + public double peakValue(final int node) { + return imax[node]; + } + + /** @return the minimum intensity of the image. */ + public double min() { + return min; + } + + /** @return the maximum intensity of the image. */ + public double max() { + return max; + } + + /** + * @return the leaf (regional maximum basin) each pixel was flooded from, in + * flat iteration order; 0 only for a flat image. + */ + public int[] leafMap() { + return leafMap; + } + + // -- Tree cut -- + + /** + * Cuts the tree and labels the image accordingly. + * + * @param hMin minimal dynamics of a peak for it to seed its own segment. + * @param threshold pixels below this intensity are background. + * @param peakFlooding percentage (0-100) of each segment's peak height above + * the threshold that is flooded; pixels below + * {@code threshold + (peak - threshold) * (1 - peakFlooding / 100)} + * are background. + * @param allowSplitting if true, peaks with dynamics below {@code hMin} that + * get separated from their parent segment by the threshold (or by + * the peak flooding level) become segments of their own; otherwise + * they are discarded. + * @return the label of each pixel (in flat iteration order), 0 for + * background; labels are numbered consecutively from 1 + */ + public int[] labels(final double hMin, final double threshold, + double peakFlooding, final boolean allowSplitting) + { + final var out = new int[values.length]; + final var nNodes = parent.length; + if (nNodes == 0) return out; + + peakFlooding = Math.min(100, Math.max(0, peakFlooding)) / 100; + final var thresholds = new double[nNodes]; + for (var n = 0; n < nNodes; n++) { + thresholds[n] = threshold + (imax[n] - threshold) * (1 - peakFlooding); + } + + final var labelRoot = new int[nNodes]; + + // Search breadth-first for the topmost nodes below the hMin cut. + final var queue = new ArrayDeque(); + final var queueA = new ArrayDeque(); + for (final var r : roots) { + labelRoot[r] = r; + queue.add(r); + } + while (!queue.isEmpty()) { + final int node = queue.poll(); + if (criteria[node] <= hMin) { + labelRoot[node] = node; + queueA.add(node); + } + else { + labelRoot[node] = 0; + for (final var c : children[node]) { + if (c >= 0) queue.add(c); + } + } + } + + // Propagate labels down, removing or splitting off the peaks that are + // disconnected from their segment by the threshold or flooding level. + while (!queueA.isEmpty()) { + final int node = queueA.poll(); + final var root = labelRoot[node]; + if (root == 0) { + // There is nothing to do. + } + else if (imax[node] < threshold) { + labelRoot[node] = 0; + } + else if (threshold > imin[node] && imax[root] > imax[node]) { + labelRoot[node] = allowSplitting ? node : 0; + } + else if (threshold <= imin[node]) { + if (thresholds[root] > imin[node] && imax[root] > imax[node]) { + labelRoot[node] = allowSplitting ? node : 0; + } + } + for (final var c : children[node]) { + if (c >= 0) { + labelRoot[c] = labelRoot[node]; + queueA.add(c); + } + } + } + + // Number the segments consecutively. + final var nodeToLabel = new int[nNodes]; + var label = 0; + for (var n = 0; n < nNodes; n++) { + if (labelRoot[n] == n && n > 0) nodeToLabel[n] = ++label; + } + + // Label the pixels. + for (var p = 0; p < values.length; p++) { + final var v = values[p]; + if (v < threshold) continue; + final var node = leafMap[p]; + final var root = labelRoot[node]; + if (v >= thresholds[root]) out[p] = nodeToLabel[root]; + } + return out; + } + + // -- Helper methods -- + + private boolean isLeaf(final int node) { + return children[node][0] < 0 && children[node][1] < 0; + } + + /** + * Labels the regional maxima (plateaus with full connectivity whose + * neighbors are all lower) strictly above {@code threshold}. Labels are + * assigned in reverse flat order, as in the original plugin. + * + * @return the number of maxima + */ + private static int labelMaxima(final double[] values, final long[] dims, + final double threshold, final int[] labels) + { + final var n = values.length; + final var par = labels; // These are union-find parents, reused as output. + final var isMaxRoot = new boolean[n]; + java.util.Arrays.fill(par, -1); + + // Only the neighbors preceding the center pixel in flat order are needed. + final var allOffsets = neighborOffsets(dims.length, null); + final var offsets = java.util.Arrays.copyOf(allOffsets, allOffsets.length / + 2); + final var pos = new long[dims.length]; + final var nPos = new long[dims.length]; + + for (var p = 0; p < n; p++) { + final var pVal = values[p]; + if (!(pVal > threshold)) continue; + par[p] = p; + isMaxRoot[p] = true; + indexToPosition(p, dims, pos); + for (final var offset : offsets) { + if (!neighbor(pos, offset, dims, nPos)) continue; + final var q = positionToIndex(nPos, dims); + final var nVal = values[q]; + if (!(nVal > threshold)) continue; + if (pVal >= nVal) { + final var r = findRootCompress(q, par); + if (r == p) continue; + if (pVal == nVal) { + par[r] = p; + isMaxRoot[p] &= isMaxRoot[r]; + } + isMaxRoot[r] = false; + } + else { + isMaxRoot[p] = false; + } + } + } + + // Plateau roots always come after their members in flat order. + var label = 0; + for (var i = n - 1; i >= 0; i--) { + if (par[i] < 0) continue; + if (par[i] == i) par[i] = isMaxRoot[i] ? ++label : 0; + else par[i] = par[par[i]]; + } + for (var i = 0; i < n; i++) { + if (par[i] < 0) par[i] = 0; + } + return label; + } + + private static int findRootCompress(final int p, final int[] par) { + var root = p; + while (par[root] != root) + root = par[root]; + var q = p; + while (par[q] != root) { + final var next = par[q]; + par[q] = root; + q = next; + } + return root; + } + + /** + * Walks up from {@code leaf} to the first node with dynamics greater than + * {@code h1} whose parent merge does not involve a sibling of lower dynamics + * than {@code h1}. + */ + private static int mergeTarget(final int leaf, final double h1, + final int[] parent, final int[][] children, final double[] dynamics) + { + var node2 = leaf; + while (dynamics[node2] <= h1) + node2 = parent[node2]; + while (h1 > mergeLevel(node2, parent, children, dynamics)) + node2 = parent[node2]; + return node2; + } + + private static double mergeLevel(final int node, final int[] parent, + final int[][] children, final double[] dynamics) + { + if (parent[node] == node) return Double.POSITIVE_INFINITY; + final var c = children[parent[node]]; + return Math.min(dynamics[c[0]], dynamics[c[1]]); + } + + private static void mergeNodes(final int node1, final int node2, + final int newNode, final int[] parent, final int[][] children) + { + replaceChild(node1, newNode, parent, children); + replaceChild(node2, newNode, parent, children); + parent[node1] = newNode; + parent[node2] = newNode; + children[newNode][0] = node1; + children[newNode][1] = node2; + } + + private static void replaceChild(final int node, final int newNode, + final int[] parent, final int[][] children) + { + final var par = parent[node]; + if (par == node) return; + for (var i = 0; i < 2; i++) { + if (children[par][i] == node) { + children[par][i] = newNode; + parent[newNode] = par; + } + } + } + + private static int findRoot(int node, final int[] parent) { + while (parent[node] != node) + node = parent[node]; + return node; + } + + /** + * Offsets of the neighbors of a pixel, in flat (raster) order with dimension + * 0 varying fastest. + */ + private static long[][] neighborOffsets(final int nDims, + final Shape connectivity) + { + final var offsets = Neighborhoods.rasterOffsets(connectivity, nDims); + final var result = new long[offsets.length][nDims]; + for (var i = 0; i < offsets.length; i++) + for (var d = 0; d < nDims; d++) + result[i][d] = offsets[i][d]; + return result; + } + + private static boolean neighbor(final long[] pos, final long[] offset, + final long[] dims, final long[] nPos) + { + for (var d = 0; d < dims.length; d++) { + final var v = pos[d] + offset[d]; + if (v < 0 || v >= dims[d]) return false; + nPos[d] = v; + } + return true; + } + + private static void indexToPosition(int index, final long[] dims, + final long[] pos) + { + for (var d = 0; d < dims.length; d++) { + pos[d] = index % dims[d]; + index /= (int) dims[d]; + } + } + + private static int positionToIndex(final long[] pos, final long[] dims) { + long index = 0; + for (var d = dims.length - 1; d >= 0; d--) { + index = index * dims[d] + pos[d]; + } + return (int) index; + } + + /** + * Priority queue of pixel indices: higher values first, first-in first-out + * among equal values. Equivalent to the hierarchical FIFO queue of the + * original implementation, without requiring integer intensities. + */ + private static final class PixelQueue { + + private final double[] values; + private int[] heap = new int[16]; + private long[] order = new long[16]; + private int size; + private long counter; + + PixelQueue(final double[] values) { + this.values = values; + } + + boolean isEmpty() { + return size == 0; + } + + void add(final int index) { + if (size == heap.length) { + final var capacity = (int) Math.min(Integer.MAX_VALUE - 8, 2L * size); + heap = java.util.Arrays.copyOf(heap, capacity); + order = java.util.Arrays.copyOf(order, capacity); + } + var i = size++; + final var seq = counter++; + while (i > 0) { + final var parent = (i - 1) / 2; + if (!before(index, seq, heap[parent], order[parent])) break; + heap[i] = heap[parent]; + order[i] = order[parent]; + i = parent; + } + heap[i] = index; + order[i] = seq; + } + + int poll() { + final var result = heap[0]; + final var lastIndex = heap[--size]; + final var lastSeq = order[size]; + var i = 0; + while (true) { + var child = 2 * i + 1; + if (child >= size) break; + if (child + 1 < size && before(heap[child + 1], order[child + 1], + heap[child], order[child])) child++; + if (!before(heap[child], order[child], lastIndex, lastSeq)) break; + heap[i] = heap[child]; + order[i] = order[child]; + i = child; + } + heap[i] = lastIndex; + order[i] = lastSeq; + return result; + } + + private boolean before(final int a, final long seqA, final int b, + final long seqB) + { + final var va = values[a]; + final var vb = values[b]; + return va > vb || (va == vb && seqA < seqB); + } + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/segment/hwatershed/HWatershed.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/segment/hwatershed/HWatershed.java new file mode 100644 index 000000000..b60cb1272 --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/segment/hwatershed/HWatershed.java @@ -0,0 +1,181 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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% + */ + +/* + * This file is derived from Interactive-H-Watershed + * (https://github.com/mpicbg-scicomp/Interactive-H-Watershed), whose original + * notice follows. + * + * Author: Benoit Lombardot, Scientific Computing Facility, MPI-CBG, Dresden + * + * Copyright 2017 Max Planck Institute of Molecular Cell Biology and Genetics, + * Dresden, Germany + * + * 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. + * + * 3 - Neither the name of the copyright holder nor the names of its + * contributors may be used to endorse or promote products derived from this + * software without specific prior written permission. + * + * 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 HOLDER 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. + */ + +package org.scijava.ops.image.segment.hwatershed; + +import java.util.function.BiFunction; + +import net.imglib2.Dimensions; +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.algorithm.neighborhood.Shape; +import net.imglib2.roi.labeling.ImgLabeling; +import net.imglib2.type.numeric.RealType; +import net.imglib2.type.numeric.integer.IntType; +import net.imglib2.view.Views; + +import org.scijava.function.Functions; +import org.scijava.ops.spi.Nullable; +import org.scijava.ops.spi.OpDependency; + +/** + * H-watershed: segments the bright objects of an n-dimensional grayscale image + * by a watershed seeded with its h-maxima. + *

+ * The image is flooded from its regional maxima, and the resulting hierarchy of + * peaks (see {@link HSegmentTree}) is cut so that only peaks whose + * dynamics (height above the saddle where they meet a higher peak) is + * at least {@code hMin} seed their own segment; lower peaks are merged into the + * segment of their higher neighbor. Pixels below {@code threshold} are + * background, and each segment can be restricted to the top + * {@code peakFlooding} percent of its height above the threshold. Touching + * segments are not separated by watershed lines (there is no option for + * them, unlike in {@code segment.compactWatershed}). + *

+ *

+ * To segment dark objects, invert the image first. + *

+ *

+ * This op is a port of the headless {@code H_Watershed} command of the + * Interactive H-Watershed Fiji plugin by Benoit Lombardot (Scientific Computing + * Facility, MPI-CBG, Dresden), licensed under the BSD-3-Clause license; see + * its + * documentation. Unlike the original, intensities are not truncated to + * integers and images of any dimensionality are supported. + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @author Benoit Lombardot (MPI-CBG) + * @param element type of the input image + * @implNote op names='segment.hWatershed' + */ +public class HWatershed> implements + Functions.Arity6, Double, Double, Double, Boolean, Shape, ImgLabeling> +{ + + @OpDependency(name = "create.imgLabeling") + private BiFunction> labelingCreator; + + /** + * Computes the H-watershed segmentation of an image. + * + * @param input the grayscale image to segment, without NaN; bright peaks + * become segments. + * @param hMin the minimal dynamics (peak height above its saddle) for a peak + * to seed its own segment. Defaults to 5% of the image intensity + * range. + * @param threshold pixels with intensity below this value are background. + * Defaults to the image minimum (i.e. no thresholding). + * @param peakFlooding the percentage (0-100) of the height of each segment above + * {@code threshold} that is flooded: pixels below + * {@code threshold + (peak - threshold) * (1 - peakFlooding / 100)} + * are background. Defaults to 100 (flood down to the threshold). + * @param allowSplitting if true, peaks whose dynamics is below {@code hMin} + * but which get separated from their segment by the threshold or the + * peak flooding level form segments of their own; if false they are + * discarded. Defaults to true. + * @param connectivity the neighborhood defining which pixels are adjacent + * during the flooding, such as a {@code DiamondShape} of radius 1 + * for face connectivity (4-connectivity in 2D, 6-connectivity in + * 3D) or a {@code RectangleShape} of span 1 for full connectivity + * (8-connectivity in 2D, 26-connectivity in 3D). Defaults to full + * connectivity, unlike the original plugin, which defaults to face + * connectivity. Regional maxima are always detected with full + * connectivity. + * @return a labeling with one label (numbered from 1) per segment; + * background pixels have no label + */ + @Override + public ImgLabeling apply( // + final RandomAccessibleInterval input, // + @Nullable Double hMin, // + @Nullable Double threshold, // + @Nullable Double peakFlooding, // + @Nullable Boolean allowSplitting, // + @Nullable Shape connectivity // + ) { + if (peakFlooding == null) peakFlooding = 100.0; + if (allowSplitting == null) allowSplitting = true; + + final var tree = HSegmentTree.build(input, connectivity); + if (hMin == null) hMin = 0.05 * (tree.max() - tree.min()); + if (threshold == null) threshold = tree.min(); + if (hMin < 0) { + throw new IllegalArgumentException("hMin must be non-negative: " + hMin); + } + + final var labels = tree.labels(hMin, threshold, peakFlooding, + allowSplitting); + + final var labeling = labelingCreator.apply(input, new IntType()); + var i = 0; + for (final var t : Views.flatIterable(labeling)) { + final var label = labels[i++]; + if (label > 0) t.add(label); + } + return labeling; + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/segment/kmeans/KMeans.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/segment/kmeans/KMeans.java new file mode 100644 index 000000000..8a91dc6a2 --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/segment/kmeans/KMeans.java @@ -0,0 +1,293 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.segment.kmeans; + +import java.util.Arrays; +import java.util.Random; +import java.util.function.BiFunction; + +import net.imglib2.Dimensions; +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.roi.labeling.ImgLabeling; +import net.imglib2.type.numeric.RealType; +import net.imglib2.type.numeric.integer.IntType; +import net.imglib2.util.Intervals; +import net.imglib2.view.Views; + +import org.scijava.function.Functions; +import org.scijava.ops.spi.Nullable; +import org.scijava.ops.spi.OpDependency; + +/** + * Clusters the pixels of an image by k-means, on their intensities or on + * feature vectors stored along a channel axis. + *

+ * The cluster centers are initialized by k-means++ seeding (Arthur & + * Vassilvitskii 2007): the first center is a uniformly random pixel, and each + * further center is a pixel chosen with probability proportional to its + * squared distance to the nearest center chosen so far. Lloyd iterations + * (Lloyd 1982) then alternate between assigning each pixel to its nearest + * center (in Euclidean distance) and moving each center to the mean of its + * pixels, until no assignment changes or the iteration limit is reached. A + * cluster that becomes empty is moved to the pixel farthest from its center. + * The iterations always end with an assignment step, so every pixel belongs + * to the nearest of the centers by which the clusters are numbered. The + * result depends only on the input and the random seed. + *

+ *

+ * Pixels with a NaN value (in any channel) are not clustered and are left + * unlabeled. + *

+ *

+ * The clusters are labeled {@code 1, ..., k} in lexicographic order of their + * centers, so for intensity clustering label 1 is the darkest cluster. + *

+ *

+ * Implemented from the papers. + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @param input pixel type + * @see S. P. Lloyd, "Least + * squares quantization in PCM", IEEE Trans. Inf. Theory 28(2):129-137, + * 1982 + * @see D. Arthur and + * S. Vassilvitskii, "k-means++: the advantages of careful seeding", Proc. + * 18th ACM-SIAM Symp. Discrete Algorithms, 1027-1035, 2007 + * @implNote op names='segment.kMeans' + */ +public class KMeans> implements + Functions.Arity5, Integer, Integer, Long, Integer, ImgLabeling> +{ + + @OpDependency(name = "create.imgLabeling") + private BiFunction> labelingCreator; + + /** + * Clusters the pixels by k-means. + * + * @param input the image; with a channel axis, the values along that axis + * form the feature vector of each pixel + * @param k the number of clusters + * @param channelAxis the axis holding the features, or null (the default) + * to cluster scalar intensities + * @param seed the seed of the random k-means++ initialization (default: 0) + * @param maxIterations the maximum number of Lloyd iterations, i.e. of + * updates of the centers (default: 100); with 0, the pixels are + * assigned to the nearest k-means++ seeds + * @return a labeling of the pixels (over all axes except the channel axis, + * with the same interval as the input) with cluster labels + * {@code 1, ..., k}; pixels with a NaN value are unlabeled + */ + @Override + public ImgLabeling apply( + final RandomAccessibleInterval input, final Integer k, + @Nullable final Integer channelAxis, @Nullable final Long seed, + @Nullable final Integer maxIterations) + { + final var n = input.numDimensions(); + if (channelAxis != null && (channelAxis < 0 || channelAxis >= n)) { + throw new IllegalArgumentException("Invalid channel axis: " + + channelAxis); + } + final RandomAccessibleInterval first = channelAxis == null ? input + : Views.hyperSlice(input, channelAxis, input.min(channelAxis)); + final var size = Intervals.numElements(first); + if (size > Integer.MAX_VALUE) { + throw new IllegalArgumentException("Image is too large: " + size + + " pixels"); + } + final var total = (int) size; + final var f = channelAxis == null ? 1 : (int) input.dimension(channelAxis); + final var all = new double[total * f]; + for (var c = 0; c < f; c++) { + final RandomAccessibleInterval slice = channelAxis == null ? input + : Views.hyperSlice(input, channelAxis, input.min(channelAxis) + c); + var i = 0; + for (final var t : Views.flatIterable(slice)) + all[i++ * f + c] = t.getRealDouble(); + } + // Note: pixels with a NaN feature are not clustered. + final var valid = new boolean[total]; + var count = 0; + for (var p = 0; p < total; p++) { + valid[p] = true; + for (var c = 0; c < f; c++) + if (Double.isNaN(all[p * f + c])) valid[p] = false; + if (valid[p]) count++; + } + if (k == null || k < 1 || k > count) { + throw new IllegalArgumentException("k must be in [1, " + count + + "] (the number of pixels without NaN): " + k); + } + final int maxIter = maxIterations == null ? 100 : maxIterations; + if (maxIter < 0) { + throw new IllegalArgumentException("Invalid maximum number of " + + "iterations: " + maxIter); + } + final double[] features; + if (count == total) features = all; + else { + features = new double[count * f]; + var q = 0; + for (var p = 0; p < total; p++) + if (valid[p]) System.arraycopy(all, p * f, features, q++ * f, f); + } + + final var centers = seedCenters(features, count, f, k, new Random( + seed == null ? 0 : seed)); + final var assignment = new int[count]; + Arrays.fill(assignment, -1); + final var sums = new double[k * f]; + final var sizes = new int[k]; + for (var it = 0;; it++) { + var changed = false; + for (var p = 0; p < count; p++) { + final var nearest = nearest(features, p, centers, k, f); + if (nearest != assignment[p]) { + assignment[p] = nearest; + changed = true; + } + } + // Note: stopping right after an assignment step keeps the labels + // consistent with the centers by which they are numbered. + if (!changed || it >= maxIter) break; + Arrays.fill(sums, 0); + Arrays.fill(sizes, 0); + for (var p = 0; p < count; p++) { + final var a = assignment[p]; + sizes[a]++; + for (var c = 0; c < f; c++) + sums[a * f + c] += features[p * f + c]; + } + for (var j = 0; j < k; j++) { + if (sizes[j] > 0) { + for (var c = 0; c < f; c++) + centers[j * f + c] = sums[j * f + c] / sizes[j]; + } + else { + // Move the empty cluster to the worst-fitting pixel. + var worst = 0; + var worstDist = -1.0; + for (var p = 0; p < count; p++) { + final var d = dist2(features, p, centers, assignment[p], f); + if (d > worstDist) { + worstDist = d; + worst = p; + } + } + System.arraycopy(features, worst * f, centers, j * f, f); + assignment[worst] = j; + } + } + } + + // Number the clusters in lexicographic order of their centers. + final var order = new Integer[k]; + for (var j = 0; j < k; j++) + order[j] = j; + Arrays.sort(order, (a, b) -> { + for (var c = 0; c < f; c++) { + final var cmp = Double.compare(centers[a * f + c], centers[b * f + c]); + if (cmp != 0) return cmp; + } + return Integer.compare(a, b); + }); + final var rank = new int[k]; + for (var j = 0; j < k; j++) + rank[order[j]] = j; + + final var output = labelingCreator.apply(first, new IntType()); + var p = 0; + var q = 0; + for (final var label : Views.flatIterable(output)) + if (valid[p++]) label.add(rank[assignment[q++]] + 1); + return output; + } + + /** Chooses the initial centers by k-means++ seeding. */ + private static double[] seedCenters(final double[] features, + final int count, final int f, final int k, final Random random) + { + final var centers = new double[k * f]; + System.arraycopy(features, random.nextInt(count) * f, centers, 0, f); + final var minDist = new double[count]; + Arrays.fill(minDist, Double.POSITIVE_INFINITY); + for (var j = 1; j < k; j++) { + var total = 0.0; + for (var p = 0; p < count; p++) { + minDist[p] = Math.min(minDist[p], dist2(features, p, centers, j - 1, + f)); + total += minDist[p]; + } + var chosen = count - 1; + if (total > 0) { + var r = random.nextDouble() * total; + for (var p = 0; p < count; p++) { + r -= minDist[p]; + if (r < 0) { + chosen = p; + break; + } + } + } + else chosen = random.nextInt(count); + System.arraycopy(features, chosen * f, centers, j * f, f); + } + return centers; + } + + private static int nearest(final double[] features, final int p, + final double[] centers, final int k, final int f) + { + var best = 0; + var bestDist = Double.POSITIVE_INFINITY; + for (var j = 0; j < k; j++) { + final var d = dist2(features, p, centers, j, f); + if (d < bestDist) { + bestDist = d; + best = j; + } + } + return best; + } + + private static double dist2(final double[] features, final int p, + final double[] centers, final int j, final int f) + { + var sum = 0.0; + for (var c = 0; c < f; c++) { + final var d = features[p * f + c] - centers[j * f + c]; + sum += d * d; + } + return sum; + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/segment/levelset/LevelSetEvolution.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/segment/levelset/LevelSetEvolution.java new file mode 100644 index 000000000..4c495f35e --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/segment/levelset/LevelSetEvolution.java @@ -0,0 +1,360 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.segment.levelset; + +import java.util.function.UnaryOperator; + +/** + * Explicit finite-difference evolution of a level-set function on an + * n-dimensional grid, for the region-based model of Chan and Vese and the + * geodesic active contours of Caselles, Kimmel and Sapiro. The level-set + * function is positive inside the contour. + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @see LevelSetFunction + */ +final class LevelSetEvolution { + + /** Interval (in iterations) between reinitializations. */ + static final int REINIT_INTERVAL = 5; + + private final int n; + private final int size; + private final long[] dims; + private final int[] strides; + private final double[] h; + private final double hMin; + + // Scratch space for the per-pixel computations. + private final int[] pos; + private final int[] lo; + private final int[] hi; + private final double[] grad; + + LevelSetEvolution(final long[] dims, final double[] spacing) { + n = dims.length; + this.dims = dims; + strides = new int[n]; + h = spacing.clone(); + var stride = 1L; + var min = Double.POSITIVE_INFINITY; + for (var d = 0; d < n; d++) { + if (!(spacing[d] > 0) || Double.isInfinite(spacing[d])) { + throw new IllegalArgumentException("Invalid calibration: " + spacing[d]); + } + strides[d] = (int) stride; + stride *= dims[d]; + min = Math.min(min, spacing[d]); + } + size = (int) stride; + hMin = min; + pos = new int[n]; + lo = new int[n]; + hi = new int[n]; + grad = new double[n]; + } + + // -- Chan-Vese -- + + /** + * Evolves the level-set function by the gradient flow of the Chan-Vese + * energy {@code mu Length + nu Area + sum_inside (u - c1)^2 + sum_outside + * (u - c2)^2}. + * + * @return the number of iterations performed + */ + int chanVese(final double[] phi, final double[] u, final double mu, + final double nu, final int maxIterations, final double tolerance, + final UnaryOperator sdt) + { + final var eps = hMin; + final var deltaMax = 1 / (Math.PI * eps); + final var dtCurvature = mu > 0 ? 0.9 * hMin * hMin / (2 * n * mu * + deltaMax) : Double.POSITIVE_INFINITY; + final var update = new double[size]; + final var check = new Convergence(phi, tolerance); + var it = 0; + while (it < maxIterations) { + double s1 = 0, s2 = 0; + long n1 = 0; + for (var i = 0; i < size; i++) { + if (phi[i] > 0) { + s1 += u[i]; + n1++; + } + else s2 += u[i]; + } + final var c1 = n1 > 0 ? s1 / n1 : 0; + final var c2 = n1 < size ? s2 / (size - n1) : 0; + var maxUpdate = 0.0; + resetPosition(); + for (var i = 0; i < size; i++) { + final var p = phi[i]; + final var delta = eps / (Math.PI * (eps * eps + p * p)); + final var k = mu == 0 ? 0 : curvature(phi, i); + final var a = u[i] - c1; + final var b = u[i] - c2; + update[i] = delta * (mu * k - nu - a * a + b * b); + maxUpdate = Math.max(maxUpdate, Math.abs(update[i])); + advancePosition(); + } + // Note: The time step is chosen for the front to move by at most half a + // pixel, within the stability limit of the curvature term. + final var dt = maxUpdate > 0 ? Math.min(dtCurvature, 0.5 * hMin / + maxUpdate) : 0; + for (var i = 0; i < size; i++) + phi[i] += dt * update[i]; + it++; + if (it % REINIT_INTERVAL == 0) { + reinitialize(phi, sdt); + if (check.converged(phi)) break; + } + } + return it; + } + + // -- Geodesic active contours -- + + /** + * Evolves the level-set function by + * {@code phi_t = g (alpha kappa + c) |grad phi| + beta grad g . grad phi}, + * with upwind differences for the propagation and advection terms. + * + * @return the number of iterations performed + */ + int geodesic(final double[] phi, final double[] g, final double alpha, + final double c, final double beta, final int maxIterations, + final double tolerance, final UnaryOperator sdt) + { + // Precompute the gradient of the edge map. + final var gradG = new double[n][size]; + var gMax = 0.0; + final var gradMax = new double[n]; + resetPosition(); + for (var i = 0; i < size; i++) { + gMax = Math.max(gMax, g[i]); + for (var d = 0; d < n; d++) { + final var v = (g[hi[d]] - g[lo[d]]) / ((hi[d] - lo[d]) / strides[d] * + h[d]); + gradG[d][i] = Double.isNaN(v) ? 0 : v; + gradMax[d] = Math.max(gradMax[d], Math.abs(gradG[d][i])); + } + advancePosition(); + } + var cfl = Math.abs(c) * gMax / hMin; + for (var d = 0; d < n; d++) + cfl += Math.abs(beta) * gradMax[d] / h[d]; + var dt = cfl > 0 ? 0.5 / cfl : 1; + if (alpha > 0 && gMax > 0) dt = Math.min(dt, 0.9 * hMin * hMin / (2 * n * + alpha * gMax)); + + final var update = new double[size]; + final var check = new Convergence(phi, tolerance); + var it = 0; + while (it < maxIterations) { + resetPosition(); + for (var i = 0; i < size; i++) { + final var p = phi[i]; + final var f = c * g[i]; + double up = 0, adv = 0, mean = 0; + for (var d = 0; d < n; d++) { + final var dm = lo[d] == i ? 0 : (p - phi[lo[d]]) / h[d]; + final var dp = hi[d] == i ? 0 : (phi[hi[d]] - p) / h[d]; + if (f > 0) { + final var m = Math.min(dm, 0); + final var q = Math.max(dp, 0); + up += m * m + q * q; + } + else { + final var m = Math.max(dm, 0); + final var q = Math.min(dp, 0); + up += m * m + q * q; + } + final var gd = gradG[d][i]; + adv += gd * (gd < 0 ? dm : dp); + mean += (dm * dm + dp * dp) / 2; + } + var term = f * Math.sqrt(up) + beta * adv; + if (alpha != 0) { + term += alpha * g[i] * curvature(phi, i) * Math.sqrt(mean); + } + update[i] = term; + advancePosition(); + } + for (var i = 0; i < size; i++) + phi[i] += dt * update[i]; + it++; + if (it % REINIT_INTERVAL == 0) { + reinitialize(phi, sdt); + if (check.converged(phi)) break; + } + } + return it; + } + + // -- Shared helpers -- + + /** + * Replaces the level-set function by a signed distance function with the + * same zero level set. Pixels next to the interface keep their sub-pixel + * position estimate {@code phi / |grad phi|}; all others get the signed + * distance of the binary mask {@code phi > 0}. + */ + void reinitialize(final double[] phi, final UnaryOperator sdt) { + final var dist = sdt.apply(phi); + resetPosition(); + for (var i = 0; i < size; i++) { + final var inside = phi[i] > 0; + var front = false; + for (var d = 0; d < n && !front; d++) { + front = (phi[lo[d]] > 0) != inside || (phi[hi[d]] > 0) != inside; + } + if (front) { + centralGradient(phi, i); + final var norm = gradientNorm(); + var v = norm > 0 ? phi[i] / norm : dist[i]; + // Keep the sign, and stay within one pixel of the interface. + if (inside) v = Math.min(Math.max(v, Double.MIN_VALUE), hMin); + else v = Math.max(Math.min(v, 0), -hMin); + dist[i] = v; + } + advancePosition(); + } + System.arraycopy(dist, 0, phi, 0, size); + } + + /** + * Mean curvature {@code div(grad phi / |grad phi|)}, as the difference of + * the unit normals between the pixel and its neighbors, so that it is also + * defined (and large) at isolated extrema of the level-set function. + */ + private double curvature(final double[] phi, final int i) { + var k = 0.0; + for (var d = 0; d < n; d++) { + if (hi[d] != i) k += normal(phi, i, i, hi[d], d) / h[d]; + if (lo[d] != i) k -= normal(phi, i, lo[d], i, d) / h[d]; + } + return k; + } + + /** + * Component along dimension {@code d} of the unit normal halfway between + * the adjacent pixels {@code a} and {@code b = a + stride_d}, which share + * all other coordinates with pixel {@code i}. + */ + private double normal(final double[] phi, final int i, final int a, + final int b, final int d) + { + final var gd = (phi[b] - phi[a]) / h[d]; + var s = gd * gd; + for (var e = 0; e < n; e++) { + if (e == d) continue; + final var oe = hi[e] - i; + final var me = lo[e] - i; + final var steps = (oe - me) / strides[e]; + if (steps == 0) continue; + final var ge = (phi[a + oe] - phi[a + me] + phi[b + oe] - phi[b + me]) / + (2 * steps * h[e]); + s += ge * ge; + } + return s > 0 ? gd / Math.sqrt(s) : 0; + } + + private void centralGradient(final double[] phi, final int i) { + for (var d = 0; d < n; d++) { + final var steps = (hi[d] - lo[d]) / strides[d]; + grad[d] = steps == 0 ? 0 : (phi[hi[d]] - phi[lo[d]]) / (steps * h[d]); + } + } + + /** Norm of the gradient last computed by {@link #centralGradient}. */ + private double gradientNorm() { + var s = 0.0; + for (var d = 0; d < n; d++) + s += grad[d] * grad[d]; + return Math.sqrt(s); + } + + private int index; + + private void resetPosition() { + index = 0; + for (var d = 0; d < n; d++) + pos[d] = 0; + neighbors(); + } + + private void advancePosition() { + index++; + for (var d = 0; d < n; d++) { + if (++pos[d] < dims[d]) break; + pos[d] = 0; + } + if (index < size) neighbors(); + } + + /** Neighbor indices along each dimension, clamped at the borders. */ + private void neighbors() { + for (var d = 0; d < n; d++) { + lo[d] = pos[d] > 0 ? index - strides[d] : index; + hi[d] = pos[d] < dims[d] - 1 ? index + strides[d] : index; + } + } + + /** + * Convergence test: the contour has converged when the number of pixels + * that changed sides since the previous test is at most {@code tolerance} + * times the number of inside pixels. + */ + private static final class Convergence { + + private final boolean[] previous; + private final double tolerance; + + Convergence(final double[] phi, final double tolerance) { + previous = new boolean[phi.length]; + for (var i = 0; i < phi.length; i++) + previous[i] = phi[i] > 0; + this.tolerance = tolerance; + } + + boolean converged(final double[] phi) { + long changed = 0, inside = 0; + for (var i = 0; i < phi.length; i++) { + final var in = phi[i] > 0; + if (in != previous[i]) changed++; + if (in) inside++; + previous[i] = in; + } + return changed <= tolerance * Math.max(inside, 1); + } + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/segment/levelset/LevelSetFunction.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/segment/levelset/LevelSetFunction.java new file mode 100644 index 000000000..62d2559a1 --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/segment/levelset/LevelSetFunction.java @@ -0,0 +1,175 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.segment.levelset; + +import java.util.function.BiFunction; + +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.img.array.ArrayImgs; +import net.imglib2.type.BooleanType; +import net.imglib2.type.logic.BitType; +import net.imglib2.type.numeric.RealType; +import net.imglib2.type.numeric.real.DoubleType; +import net.imglib2.type.numeric.real.FloatType; +import net.imglib2.view.Views; + +import org.scijava.function.Computers; +import org.scijava.function.Functions; +import org.scijava.ops.spi.Nullable; +import org.scijava.ops.spi.OpDependency; + +/** + * Level-set active contour evolution: evolves an initial contour on an image + * and returns the final level-set function, whose positive values are the + * segmented region (see {@code segment.levelSet} for the mask itself). + *

+ * Two models are available, selected by {@code method}: + *

+ *
    + *
  • {@code "chanVese"} (default): the region-based, piecewise-constant + * active contour without edges of T. F. Chan and L. A. Vese ("Active contours + * without edges", IEEE Trans. Image Process. 10(2): 266–277, + * 2001). The contour minimizes {@code mu Length + nu Area + sum over the + * inside of (u - c1)^2 + sum over the outside of (u - c2)^2}, where {@code u} + * is the image standardized to zero mean and unit variance (which makes the + * weights independent of the intensity scale) and {@code c1}, {@code c2} are + * the mean values inside and outside, by the gradient flow + * {@code phi_t = delta(phi) (mu kappa - nu - (u - c1)^2 + (u - c2)^2)} with a + * regularized Dirac delta of width one pixel. No edges are needed, so it + * segments noisy or blurred objects that differ in mean intensity from the + * background.
  • + *
  • {@code "geodesic"}: the geodesic active contours of V. Caselles, R. + * Kimmel and G. Sapiro ("Geodesic active contours", Int. J. Comput. + * Vision 22(1): 61–79, 1997), with the balloon force of their model: + * {@code phi_t = g (alpha kappa + c) |grad phi| + beta grad g . grad phi}. The + * edge stopping function is {@code g = 1 / (1 + (|grad u_sigma| / + * lambda)^2)}, where {@code u_sigma} is the image rescaled to [0, 1] and + * smoothed by a Gaussian of standard deviation {@code sigma} and + * {@code lambda} is a tenth + * of the largest gradient magnitude, so that {@code g} is close to 0 on the + * strongest edges and to 1 in flat regions. The contour expands ({@code c > + * 0}) or shrinks ({@code c < 0}) until it is stopped and attracted by + * edges.
  • + *
+ *

+ * Here {@code phi} is the level-set function (positive inside the contour) and + * {@code kappa = div(grad phi / |grad phi|)} its mean curvature, which + * regularizes the contour. The level-set function is initialized with the + * signed distance transform ({@code image.signedDistanceTransform}) of the + * initial mask and evolved with an explicit scheme on the full grid, using + * upwind differences for the propagation and advection terms, and differences + * of the unit normals between adjacent pixels for the curvature (which thus + * also removes isolated pixels). The time step is chosen for stability and, + * for Chan-Vese, so that the contour moves by at most half a pixel per + * iteration. Every five iterations, the level-set function is reinitialized + * to a signed distance function: pixels next to the zero level set keep + * their sub-pixel distance estimate {@code phi / |grad phi|}, and all others + * get the signed distance of the current mask. + * The evolution stops after {@code maxIterations} iterations, or earlier when, + * between two reinitializations, at most {@code tolerance} times the number of + * inside pixels have changed sides. The computation works in any number of + * dimensions, with anisotropic calibration. + *

+ *

+ * Both models are implemented from the cited papers; the code of the Fiji + * Level Sets plugin was not consulted, and its parameters do not carry over. + * The level-set framework + * is that of S. Osher and J. A. Sethian ("Fronts propagating with + * curvature-dependent speed", J. Comput. Phys. 79(1): 12–49, + * 1988). + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @param image type + * @param initial mask type + * @see Chan and Vese 2001 + * @see Caselles, Kimmel and + * Sapiro 1997 + * @implNote op names='image.levelSetFunction' + */ +public class LevelSetFunction, B extends BooleanType> + implements + Functions.Arity10, RandomAccessibleInterval, String, Double, Double, Double, Double, Integer, Double, double[], RandomAccessibleInterval> +{ + + @OpDependency(name = "image.signedDistanceTransform") + private BiFunction, double[], RandomAccessibleInterval> sdtOp; + + @OpDependency(name = "filter.gauss") + private Computers.Arity2, double[], RandomAccessibleInterval> gaussOp; + + /** + * Evolves the active contour and returns its level-set function. + * + * @param image the image to segment + * @param init the initial contour, as a mask with the dimensions of the + * image + * @param method the active contour model, {@code chanVese} or + * {@code geodesic} (default: chanVese) + * @param curvature the weight of the curvature term, {@code mu} for + * Chan-Vese and {@code alpha} for geodesic contours (default: 1) + * @param propagation the area penalty {@code nu} for Chan-Vese (positive + * values shrink the contour; default 0), or the balloon force + * {@code c} for geodesic contours (positive values expand the + * contour; default 1) + * @param advection the weight {@code beta} of the edge attraction term of + * geodesic contours (default: 1; ignored by Chan-Vese) + * @param sigma the standard deviation, in calibrated units, of the Gaussian + * smoothing applied before computing the edge map of geodesic + * contours, or 0 for none (default: 1; ignored by Chan-Vese) + * @param maxIterations the maximal number of iterations (default: 500) + * @param tolerance the convergence tolerance: the evolution stops when the + * fraction of pixels (relative to the inside area) that changed + * sides in five iterations is at most this value (default: 0.005) + * @param calibration the pixel spacing along each dimension (default: 1 in + * every dimension) + * @return the final level-set function, an approximate signed distance to + * the contour (positive inside), with the interval of the image + */ + @Override + public RandomAccessibleInterval apply( + final RandomAccessibleInterval image, + final RandomAccessibleInterval init, @Nullable final String method, + @Nullable final Double curvature, @Nullable final Double propagation, + @Nullable final Double advection, @Nullable final Double sigma, + @Nullable final Integer maxIterations, @Nullable final Double tolerance, + @Nullable final double[] calibration) + { + final var phi = LevelSets.evolve(image, init, method, curvature, + propagation, advection, sigma, maxIterations, tolerance, calibration, + sdtOp, gaussOp); + final var out = ArrayImgs.floats(image.dimensionsAsLongArray()); + var i = 0; + for (final var t : out) + t.setReal(phi[i++]); + return Views.translate(out, image.minAsLongArray()); + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/segment/levelset/LevelSetSegmentation.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/segment/levelset/LevelSetSegmentation.java new file mode 100644 index 000000000..03dc851f6 --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/segment/levelset/LevelSetSegmentation.java @@ -0,0 +1,175 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.segment.levelset; + +import java.util.function.BiFunction; + +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.img.array.ArrayImgs; +import net.imglib2.type.BooleanType; +import net.imglib2.type.logic.BitType; +import net.imglib2.type.numeric.RealType; +import net.imglib2.type.numeric.real.DoubleType; +import net.imglib2.type.numeric.real.FloatType; +import net.imglib2.view.Views; + +import org.scijava.function.Computers; +import org.scijava.function.Functions; +import org.scijava.ops.spi.Nullable; +import org.scijava.ops.spi.OpDependency; + +/** + * Level-set active contour segmentation: evolves an initial contour on an + * image and returns the final segmentation mask (see + * {@code image.levelSetFunction} for the level-set function). + *

+ * Two models are available, selected by {@code method}: + *

+ *
    + *
  • {@code "chanVese"} (default): the region-based, piecewise-constant + * active contour without edges of T. F. Chan and L. A. Vese ("Active contours + * without edges", IEEE Trans. Image Process. 10(2): 266–277, + * 2001). The contour minimizes {@code mu Length + nu Area + sum over the + * inside of (u - c1)^2 + sum over the outside of (u - c2)^2}, where {@code u} + * is the image standardized to zero mean and unit variance (which makes the + * weights independent of the intensity scale) and {@code c1}, {@code c2} are + * the mean values inside and outside, by the gradient flow + * {@code phi_t = delta(phi) (mu kappa - nu - (u - c1)^2 + (u - c2)^2)} with a + * regularized Dirac delta of width one pixel. No edges are needed, so it + * segments noisy or blurred objects that differ in mean intensity from the + * background.
  • + *
  • {@code "geodesic"}: the geodesic active contours of V. Caselles, R. + * Kimmel and G. Sapiro ("Geodesic active contours", Int. J. Comput. + * Vision 22(1): 61–79, 1997), with the balloon force of their model: + * {@code phi_t = g (alpha kappa + c) |grad phi| + beta grad g . grad phi}. The + * edge stopping function is {@code g = 1 / (1 + (|grad u_sigma| / + * lambda)^2)}, where {@code u_sigma} is the image rescaled to [0, 1] and + * smoothed by a Gaussian of standard deviation {@code sigma} and + * {@code lambda} is a tenth + * of the largest gradient magnitude, so that {@code g} is close to 0 on the + * strongest edges and to 1 in flat regions. The contour expands ({@code c > + * 0}) or shrinks ({@code c < 0}) until it is stopped and attracted by + * edges.
  • + *
+ *

+ * Here {@code phi} is the level-set function (positive inside the contour) and + * {@code kappa = div(grad phi / |grad phi|)} its mean curvature, which + * regularizes the contour. The level-set function is initialized with the + * signed distance transform ({@code image.signedDistanceTransform}) of the + * initial mask and evolved with an explicit scheme on the full grid, using + * upwind differences for the propagation and advection terms, and differences + * of the unit normals between adjacent pixels for the curvature (which thus + * also removes isolated pixels). The time step is chosen for stability and, + * for Chan-Vese, so that the contour moves by at most half a pixel per + * iteration. Every five iterations, the level-set function is reinitialized + * to a signed distance function: pixels next to the zero level set keep + * their sub-pixel distance estimate {@code phi / |grad phi|}, and all others + * get the signed distance of the current mask. + * The evolution stops after {@code maxIterations} iterations, or earlier when, + * between two reinitializations, at most {@code tolerance} times the number of + * inside pixels have changed sides. The computation works in any number of + * dimensions, with anisotropic calibration. + *

+ *

+ * Both models are implemented from the cited papers; the code of the Fiji + * Level Sets plugin was not consulted, and its parameters do not carry over. + * The level-set framework + * is that of S. Osher and J. A. Sethian ("Fronts propagating with + * curvature-dependent speed", J. Comput. Phys. 79(1): 12–49, + * 1988). + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @param image type + * @param initial mask type + * @see Chan and Vese 2001 + * @see Caselles, Kimmel and + * Sapiro 1997 + * @implNote op names='segment.levelSet' + */ +public class LevelSetSegmentation, B extends BooleanType> + implements + Functions.Arity10, RandomAccessibleInterval, String, Double, Double, Double, Double, Integer, Double, double[], RandomAccessibleInterval> +{ + + @OpDependency(name = "image.signedDistanceTransform") + private BiFunction, double[], RandomAccessibleInterval> sdtOp; + + @OpDependency(name = "filter.gauss") + private Computers.Arity2, double[], RandomAccessibleInterval> gaussOp; + + /** + * Evolves the active contour and returns the segmented region. + * + * @param image the image to segment + * @param init the initial contour, as a mask with the dimensions of the + * image + * @param method the active contour model, {@code chanVese} or + * {@code geodesic} (default: chanVese) + * @param curvature the weight of the curvature term, {@code mu} for + * Chan-Vese and {@code alpha} for geodesic contours (default: 1) + * @param propagation the area penalty {@code nu} for Chan-Vese (positive + * values shrink the contour; default 0), or the balloon force + * {@code c} for geodesic contours (positive values expand the + * contour; default 1) + * @param advection the weight {@code beta} of the edge attraction term of + * geodesic contours (default: 1; ignored by Chan-Vese) + * @param sigma the standard deviation, in calibrated units, of the Gaussian + * smoothing applied before computing the edge map of geodesic + * contours, or 0 for none (default: 1; ignored by Chan-Vese) + * @param maxIterations the maximal number of iterations (default: 500) + * @param tolerance the convergence tolerance: the evolution stops when the + * fraction of pixels (relative to the inside area) that changed + * sides in five iterations is at most this value (default: 0.005) + * @param calibration the pixel spacing along each dimension (default: 1 in + * every dimension) + * @return the segmentation mask (the positive values of the final level-set + * function), with the interval of the image + */ + @Override + public RandomAccessibleInterval apply( + final RandomAccessibleInterval image, + final RandomAccessibleInterval init, @Nullable final String method, + @Nullable final Double curvature, @Nullable final Double propagation, + @Nullable final Double advection, @Nullable final Double sigma, + @Nullable final Integer maxIterations, @Nullable final Double tolerance, + @Nullable final double[] calibration) + { + final var phi = LevelSets.evolve(image, init, method, curvature, + propagation, advection, sigma, maxIterations, tolerance, calibration, + sdtOp, gaussOp); + final var out = ArrayImgs.bits(image.dimensionsAsLongArray()); + var i = 0; + for (final var t : out) + t.set(phi[i++] > 0); + return Views.translate(out, image.minAsLongArray()); + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/segment/levelset/LevelSets.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/segment/levelset/LevelSets.java new file mode 100644 index 000000000..30c8b393b --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/segment/levelset/LevelSets.java @@ -0,0 +1,250 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.segment.levelset; + +import java.util.Arrays; +import java.util.Locale; +import java.util.function.BiFunction; +import java.util.function.UnaryOperator; + +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.img.array.ArrayImgs; +import net.imglib2.type.BooleanType; +import net.imglib2.type.logic.BitType; +import net.imglib2.type.numeric.RealType; +import net.imglib2.type.numeric.real.DoubleType; +import net.imglib2.type.numeric.real.FloatType; +import net.imglib2.util.Intervals; +import net.imglib2.view.Views; + +import org.scijava.function.Computers; + +/** + * Shared implementation of {@code image.levelSetFunction} and + * {@code segment.levelSet}. + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +final class LevelSets { + + static final String CHAN_VESE = "chanVese"; + static final String GEODESIC = "geodesic"; + + private LevelSets() { + // Prevent instantiation of static utility class. + } + + /** Evolves the level-set function and returns it in flat order. */ + static , B extends BooleanType> double[] evolve( + final RandomAccessibleInterval image, + final RandomAccessibleInterval init, final String method, + final Double curvature, final Double propagation, final Double advection, + final Double sigma, final Integer maxIterations, final Double tolerance, + final double[] calibration, + final BiFunction, double[], RandomAccessibleInterval> sdtOp, + final Computers.Arity2, double[], RandomAccessibleInterval> gaussOp) + { + final var dims = image.dimensionsAsLongArray(); + final var n = dims.length; + if (!Arrays.equals(dims, init.dimensionsAsLongArray())) { + throw new IllegalArgumentException( + "Image and initial mask dimensions differ: " + Arrays.toString(dims) + + " vs " + Arrays.toString(init.dimensionsAsLongArray())); + } + final var size = Intervals.numElements(image); + if (size > Integer.MAX_VALUE - 8) { + throw new IllegalArgumentException("Image too large: " + size + + " pixels"); + } + final double[] cal; + if (calibration == null) { + cal = new double[n]; + Arrays.fill(cal, 1); + } + else if (calibration.length != n) { + throw new IllegalArgumentException("Expected " + n + + " calibration values but got " + calibration.length); + } + else cal = calibration; + final var geodesic = isGeodesic(method); + final var iterations = maxIterations == null ? 500 : maxIterations; + final var tol = tolerance == null ? 0.005 : tolerance; + if (iterations < 0 || tol < 0) { + throw new IllegalArgumentException( + "Iterations and tolerance must be non-negative"); + } + + // Initialization with the signed distance of the initial mask. + final var mask = ArrayImgs.bits(dims); + final var mc = mask.cursor(); + for (final var b : Views.flatIterable(init)) + mc.next().set(b.get()); + final var phi = flat(sdtOp.apply(mask, cal), (int) size); + final UnaryOperator sdt = p -> { + var i = 0; + for (final var b : mask) + b.set(p[i++] > 0); + return flat(sdtOp.apply(mask, cal), p.length); + }; + + final var u = normalized(image, (int) size, !geodesic); + final var evolution = new LevelSetEvolution(dims, cal); + if (geodesic) { + final var s = sigma == null ? 1 : sigma; + final var g = edgeMap(u, dims, cal, s, gaussOp); + evolution.geodesic(phi, g, curvature == null ? 1 : curvature, + propagation == null ? 1 : propagation, advection == null ? 1 + : advection, iterations, tol, sdt); + } + else { + evolution.chanVese(phi, u, curvature == null ? 1 : curvature, + propagation == null ? 0 : propagation, iterations, tol, sdt); + } + evolution.reinitialize(phi, sdt); + return phi; + } + + private static boolean isGeodesic(final String method) { + if (method == null) return false; + switch (method.toLowerCase(Locale.ROOT)) { + case "chanvese": + case "chan-vese": + case "cv": + return false; + case "geodesic": + case "gac": + return true; + default: + throw new IllegalArgumentException("Unknown level-set method: " + + method + " (expected " + CHAN_VESE + " or " + GEODESIC + ")"); + } + } + + /** + * The edge stopping function {@code 1 / (1 + (|grad u_sigma| / lambda)^2)}, + * with {@code lambda} a tenth of the largest gradient magnitude. + */ + private static double[] edgeMap(final double[] u, final long[] dims, + final double[] cal, final double sigma, + final Computers.Arity2, double[], RandomAccessibleInterval> gaussOp) + { + final var n = dims.length; + final double[] smooth; + if (sigma > 0) { + final var sigmas = new double[n]; + for (var d = 0; d < n; d++) + sigmas[d] = sigma / cal[d]; + final var out = ArrayImgs.doubles(dims); + gaussOp.compute(ArrayImgs.doubles(u, dims), sigmas, out); + smooth = out.update(null).getCurrentStorageArray(); + } + else smooth = u; + final var strides = new long[n]; + var stride = 1L; + for (var d = 0; d < n; d++) { + strides[d] = stride; + stride *= dims[d]; + } + final var mag = new double[u.length]; + final var pos = new long[n]; + var max = 0.0; + for (var i = 0; i < u.length; i++) { + var s = 0.0; + for (var d = 0; d < n; d++) { + final var lo = pos[d] > 0 ? i - strides[d] : i; + final var hi = pos[d] < dims[d] - 1 ? i + strides[d] : i; + final var steps = (hi - lo) / strides[d]; + if (steps == 0) continue; + final var v = (smooth[(int) hi] - smooth[(int) lo]) / (steps * cal[d]); + s += v * v; + } + mag[i] = Math.sqrt(s); + max = Math.max(max, mag[i]); + for (var d = 0; d < n; d++) { + if (++pos[d] < dims[d]) break; + pos[d] = 0; + } + } + final var lambda = 0.1 * max; + for (var i = 0; i < mag.length; i++) { + final var r = lambda > 0 ? mag[i] / lambda : 0; + mag[i] = 1 / (1 + r * r); + } + return mag; + } + + /** + * Rescales the image to zero mean and unit variance (for Chan-Vese), or to + * the range [0, 1]. + */ + private static > double[] normalized( + final RandomAccessibleInterval image, final int size, + final boolean standardize) + { + final var u = new double[size]; + var min = Double.POSITIVE_INFINITY; + var max = Double.NEGATIVE_INFINITY; + var i = 0; + for (final var t : Views.flatIterable(image)) { + final var v = t.getRealDouble(); + u[i++] = v; + min = Math.min(min, v); + max = Math.max(max, v); + } + if (standardize) { + var mean = 0.0; + for (final var v : u) + mean += v; + mean /= size; + var variance = 0.0; + for (final var v : u) + variance += (v - mean) * (v - mean); + final var std = Math.sqrt(variance / size); + for (i = 0; i < size; i++) + u[i] = std > 0 ? (u[i] - mean) / std : 0; + return u; + } + final var range = max - min; + for (i = 0; i < size; i++) + u[i] = range > 0 ? (u[i] - min) / range : 0; + return u; + } + + private static double[] flat(final RandomAccessibleInterval img, + final int size) + { + final var out = new double[size]; + var i = 0; + for (final var t : Views.flatIterable(img)) + out[i++] = t.getRealDouble(); + return out; + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/segment/phantast/DefaultPhantast.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/segment/phantast/DefaultPhantast.java new file mode 100644 index 000000000..2ffd79d0d --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/segment/phantast/DefaultPhantast.java @@ -0,0 +1,244 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.segment.phantast; + +import java.util.function.BiFunction; + +import net.imglib2.Dimensions; +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.algorithm.labeling.ConnectedComponents.StructuringElement; +import net.imglib2.converter.Converter; +import net.imglib2.converter.Converters; +import net.imglib2.loops.LoopBuilder; +import net.imglib2.roi.labeling.ImgLabeling; +import net.imglib2.roi.labeling.LabelRegions; +import net.imglib2.type.logic.BitType; +import net.imglib2.type.numeric.RealType; +import net.imglib2.type.numeric.integer.IntType; +import net.imglib2.type.numeric.real.FloatType; +import net.imglib2.view.Views; + +import org.scijava.function.Functions; +import org.scijava.ops.spi.Nullable; +import org.scijava.ops.spi.OpDependency; + +/** + * PHANTAST (PHase contrast microscopy segmentation toolbox) segmentation of + * cells in phase contrast microscopy images, e.g. for measuring the confluency + * of adherent cell cultures. + *

+ * The algorithm proceeds in four steps: + *

+ *
    + *
  1. Compute the local contrast image, i.e. the Gaussian-weighted local + * coefficient of variation (see {@code filter.localCoefficientOfVariation}).
  2. + *
  3. Threshold it: pixels with local contrast greater than {@code epsilon} + * are foreground.
  4. + *
  5. Remove foreground objects smaller than {@code minObjectSize} pixels, + * then fill background regions smaller than {@code minHoleSize} pixels (both + * 8-connected, i.e. fully connected in n-D).
  6. + *
  7. Optionally (2D only) correct for the bright halo artifacts that surround + * cells in phase contrast images: starting from the object outlines, pixels + * are iteratively removed by walking along the local intensity gradient + * direction (estimated with the eight Kirsch compass kernels) towards the + * brighter halo, as long as the walk remains inside the object.
  8. + *
+ *

+ * The confluency (fraction of the image covered by cells) is the mean of the + * resulting mask, e.g. via {@code stats.mean}. + *

+ *

+ * Translated from the PHANTAST plugin for Fiji (PHANTAST-FIJI), Copyright (c) + * 2013-2014, Nicolas Jaccard, Department of Biochemical Engineering and CoMPLEX, + * University College London, with modifications by Olivier Burri (BIOP, EPFL), + * used under the BSD 3-Clause license. Reference: N. Jaccard, L. D. Griffin, + * A. Keser, R. J. Macown, A. Super, F. S. Veraitch and N. Szita, "Automated + * method for the rapid and precise estimation of adherent cell culture + * characteristics from phase contrast microscopy images", Biotechnology and + * Bioengineering 111(3):504-517, 2014. + *

+ *

+ * Differences from the plugin: + *

+ *
    + *
  • The plugin performs the small object removal and hole filling with + * ImageJ's particle analyzer, whose interpretation of foreground depends on + * the global "black background" setting. With that setting off, the plugin + * actually fills holes smaller than 100 pixels and removes objects smaller + * than 25 pixels, while its code comments state the opposite intent (remove + * objects smaller than 100 pixels, fill holes smaller than 25 pixels); with + * the setting on, its mask is inverted. This op follows the stated intent by + * default, and both sizes are parameters.
  • + *
  • The plugin's halo correction skips the first queued pixel of each + * iteration when removing pixels, and never walks into the first row or + * column of the image. This op removes all of them, and treats all image + * borders alike.
  • + *
+ *

+ * The original PHANTAST license notice follows. + *

+ * + *
+ * Copyright (c) 2013, Nicolas Jaccard
+ * Department of Biochemical Engineering, UCL
+ * Centre for Mathematics and Physics in the Life Sciences and Experimental
+ * Biology, UCL
+ * The British Heart Foundation
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are
+ * met:
+ *
+ *   * Redistributions of source code must retain the above copyright
+ *     notice, this list of conditions and the following disclaimer.
+ *   * 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
+ *   * Neither the names of the University College London or British Heart
+ *     Foundation nor the names of its contributors may be used to endorse
+ *     or promote products derived from this software without specific
+ *     prior written permission.
+ *
+ * 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 OWNER 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.
+ * 
+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @author Nicolas Jaccard + * @author Olivier Burri + * @param the input type + * @implNote op names='segment.phantast' + */ +public class DefaultPhantast> implements + Functions.Arity6, Double, Double, Boolean, Long, Long, RandomAccessibleInterval> +{ + + @OpDependency(name = "filter.localCoefficientOfVariation") + private BiFunction, Double, RandomAccessibleInterval> localCoefficientOfVariationOp; + + @OpDependency(name = "create.img") + private BiFunction> createOp; + + @OpDependency(name = "labeling.cca") + private BiFunction, StructuringElement, ImgLabeling> ccaOp; + + /** + * Segments cells in a phase contrast image. + * + * @param input the phase contrast image, without NaN + * @param sigma the standard deviation, in pixels, of the Gaussian window of + * the local contrast filter (PHANTAST default: 1.2) + * @param epsilon the local contrast threshold above which pixels are + * foreground (PHANTAST default: 0.03) + * @param haloCorrection whether to correct for halo artifacts, which is + * only supported for 2D images; defaults to true for 2D images and + * false otherwise + * @param minObjectSize the minimum size, in pixels, of foreground objects + * to keep; defaults to 100 + * @param minHoleSize the minimum size, in pixels, of background regions to + * keep (smaller ones are filled); defaults to 25 + * @return the binary mask of the cells + */ + @Override + public RandomAccessibleInterval apply( // + final RandomAccessibleInterval input, // + final Double sigma, // + final Double epsilon, // + @Nullable Boolean haloCorrection, // + @Nullable Long minObjectSize, // + @Nullable Long minHoleSize // + ) { + final var is2D = input.numDimensions() == 2; + if (haloCorrection == null) haloCorrection = is2D; + if (haloCorrection && !is2D) { + throw new IllegalArgumentException( + "Halo correction is only supported for 2D images"); + } + if (minObjectSize == null) minObjectSize = 100L; + if (minHoleSize == null) minHoleSize = 25L; + + for (final var t : Views.iterable(input)) { + if (Double.isNaN(t.getRealDouble())) { + throw new IllegalArgumentException("NaN values are not supported"); + } + } + final var zeroMin = Views.zeroMin(input); + final var contrast = localCoefficientOfVariationOp.apply(zeroMin, sigma); + final var mask = createOp.apply(zeroMin, new BitType()); + final double eps = epsilon; + LoopBuilder.setImages(contrast, mask).forEachPixel((c, m) -> m.set(c + .getRealDouble() > eps)); + + removeSmallComponents(mask, minObjectSize, true); + removeSmallComponents(mask, minHoleSize, false); + + if (haloCorrection) HaloCorrection.apply(mask, zeroMin); + return mask; + } + + /** + * Flips the 8-connected components of the given value which are smaller + * than {@code minSize} pixels. + */ + private void removeSmallComponents(final RandomAccessibleInterval mask, + final long minSize, final boolean value) + { + if (minSize <= 1) return; + final var selected = Converters.convert(mask, + (Converter) (in, out) -> out.set(in.get() == value), + new BitType()); + final var labeling = ccaOp.apply(selected, + StructuringElement.EIGHT_CONNECTED); + final var regions = new LabelRegions<>(labeling); + final var ra = mask.randomAccess(); + for (final var region : regions) { + if (region.inside().size() >= minSize) continue; + final var c = region.inside().localizingCursor(); + while (c.hasNext()) { + c.fwd(); + ra.setPosition(c); + ra.get().set(!value); + } + } + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/segment/phantast/HaloCorrection.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/segment/phantast/HaloCorrection.java new file mode 100644 index 000000000..c3d7dcc70 --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/segment/phantast/HaloCorrection.java @@ -0,0 +1,267 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.segment.phantast; + +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.type.logic.BitType; +import net.imglib2.type.numeric.RealType; +import net.imglib2.util.Intervals; +import net.imglib2.view.Views; + +/** + * Halo artifact correction of PHANTAST, for 2D binary masks of phase contrast + * images. See {@link DefaultPhantast}. + *

+ * Translated from {@code PHANTAST_.haloCorrection} and + * {@code PHANTAST_.getDirectionImage} of the PHANTAST plugin for Fiji, + * Copyright (c) 2013-2014, Nicolas Jaccard, University College London, used + * under the BSD 3-Clause license. + *

+ *

+ * The original PHANTAST license notice follows. + *

+ * + *
+ * Copyright (c) 2013, Nicolas Jaccard
+ * Department of Biochemical Engineering, UCL
+ * Centre for Mathematics and Physics in the Life Sciences and Experimental
+ * Biology, UCL
+ * The British Heart Foundation
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are
+ * met:
+ *
+ *   * Redistributions of source code must retain the above copyright
+ *     notice, this list of conditions and the following disclaimer.
+ *   * 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
+ *   * Neither the names of the University College London or British Heart
+ *     Foundation nor the names of its contributors may be used to endorse
+ *     or promote products derived from this software without specific
+ *     prior written permission.
+ *
+ * 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 OWNER 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.
+ * 
+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @author Nicolas Jaccard + */ +final class HaloCorrection { + + private HaloCorrection() { + // Prevent instantiation of static utility class. + } + + /** + * The eight Kirsch compass kernels, in row-major order with the first row + * at {@code y - 1}, for the directions E, NE, N, NW, W, SW, S, SE (with + * north pointing towards decreasing {@code y}). + */ + private static final int[][] KIRSCH = { // + { -3, -3, 5, -3, 0, 5, -3, -3, 5 }, // + { -3, 5, 5, -3, 0, 5, -3, -3, -3 }, // + { 5, 5, 5, -3, 0, -3, -3, -3, -3 }, // + { 5, 5, -3, 5, 0, -3, -3, -3, -3 }, // + { 5, -3, -3, 5, 0, -3, 5, -3, -3 }, // + { -3, -3, -3, 5, 0, -3, 5, 5, -3 }, // + { -3, -3, -3, -3, 0, -3, 5, 5, 5 }, // + { -3, -3, -3, -3, 0, 5, -3, 5, 5 } }; + + /** The (x, y) offsets of the directions E, NE, N, NW, W, SW, S, SE. */ + private static final int[][] OFFSETS = { { 1, 0 }, { 1, -1 }, { 0, -1 }, { + -1, -1 }, { -1, 0 }, { -1, 1 }, { 0, 1 }, { 1, 1 } }; + + /** + * For each direction, the indices into {@link #OFFSETS} of the cone of + * three directions centered on it. + */ + private static final int[][] CONES = { { 0, 1, 7 }, { 1, 0, 2 }, { 2, 1, + 3 }, { 3, 2, 4 }, { 4, 3, 5 }, { 5, 4, 6 }, { 6, 5, 7 }, { 7, 0, 6 } }; + + /** + * Shrinks the given mask in place to remove halo artifacts. + * + * @param mask the 2D binary mask to correct + * @param image the 2D phase contrast image, with the same interval as the + * mask + */ + static > void apply( + final RandomAccessibleInterval mask, + final RandomAccessibleInterval image) + { + if (!Intervals.equals(mask, image)) { + throw new IllegalArgumentException( + "Mask and image intervals do not match"); + } + final var w = (int) mask.dimension(0); + final var h = (int) mask.dimension(1); + if ((long) w * h > Integer.MAX_VALUE) { + throw new IllegalArgumentException("Image is too large"); + } + + // Copy the mask and the image into flat arrays. + final var fg = new boolean[w * h]; + final var pixels = new float[w * h]; + final var mc = Views.flatIterable(mask).cursor(); + final var ic = Views.flatIterable(image).cursor(); + for (var i = 0; i < fg.length; i++) { + fg[i] = mc.next().get(); + pixels[i] = ic.next().getRealFloat(); + } + + final var direction = directions(pixels, w, h); + + // Start from the outline: foreground pixels with a background pixel + // among their 8 neighbors. + var queue = new IntList(); + for (var y = 0; y < h; y++) { + for (var x = 0; x < w; x++) { + if (fg[x + y * w] && touchesBackground(fg, w, h, x, y)) { + queue.add(x + y * w); + } + } + } + + // Walk from each queued pixel into the foreground, along the cone of + // the local gradient direction, removing the pixels that lead somewhere. + final var visited = new boolean[w * h]; + while (queue.size > 0) { + final var next = new IntList(); + final var toRemove = new IntList(); + for (var q = 0; q < queue.size; q++) { + final var i = queue.data[q]; + if (visited[i]) continue; + final var x = i % w; + final var y = i / w; + if (x <= 0 || y <= 0 || x >= w - 1 || y >= h - 1) continue; + visited[i] = true; + var validPath = false; + for (final var d : CONES[direction[i]]) { + final var n = (x + OFFSETS[d][0]) + (y + OFFSETS[d][1]) * w; + if (fg[n]) { + validPath = true; + next.add(n); + } + } + if (validPath) toRemove.add(i); + } + for (var r = 0; r < toRemove.size; r++) { + fg[toRemove.data[r]] = false; + } + queue = next; + } + + // Write the corrected mask back. + final var oc = Views.flatIterable(mask).cursor(); + for (final var b : fg) { + oc.next().set(b); + } + } + + private static boolean touchesBackground(final boolean[] fg, final int w, + final int h, final int x, final int y) + { + for (var yy = Math.max(0, y - 1); yy <= Math.min(h - 1, y + 1); yy++) { + for (var xx = Math.max(0, x - 1); xx <= Math.min(w - 1, x + 1); xx++) { + if (!fg[xx + yy * w]) return true; + } + } + return false; + } + + /** + * Computes, for each pixel, the index of the Kirsch compass kernel with the + * maximal response (the first one in case of ties), i.e. the direction of + * increasing intensity. The image border is extended by replication. + */ + private static byte[] directions(final float[] pixels, final int w, + final int h) + { + final var direction = new byte[w * h]; + final var nbh = new float[9]; + for (var y = 0; y < h; y++) { + for (var x = 0; x < w; x++) { + var k = 0; + for (var dy = -1; dy <= 1; dy++) { + final var yy = Math.min(h - 1, Math.max(0, y + dy)); + for (var dx = -1; dx <= 1; dx++) { + final var xx = Math.min(w - 1, Math.max(0, x + dx)); + nbh[k++] = pixels[xx + yy * w]; + } + } + var best = 0; + var max = Double.NEGATIVE_INFINITY; + for (var d = 0; d < KIRSCH.length; d++) { + var sum = 0.0; + for (var j = 0; j < 9; j++) { + sum += KIRSCH[d][j] * nbh[j]; + } + // Note: ImageJ stores the responses as floats before comparing them. + final var response = (float) sum; + if (response > max) { + max = response; + best = d; + } + } + direction[x + y * w] = (byte) best; + } + } + return direction; + } + + /** A minimal growable list of primitive ints. */ + private static final class IntList { + + private int[] data = new int[16]; + private int size; + + private void add(final int value) { + if (size == data.length) { + data = java.util.Arrays.copyOf(data, 2 * size); + } + data[size++] = value; + } + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/segment/regionGrowing/SeededRegionGrowing.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/segment/regionGrowing/SeededRegionGrowing.java new file mode 100644 index 000000000..b8ab419f5 --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/segment/regionGrowing/SeededRegionGrowing.java @@ -0,0 +1,265 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.segment.regionGrowing; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.PriorityQueue; +import java.util.function.BiFunction; + +import net.imglib2.Dimensions; +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.algorithm.neighborhood.Shape; +import net.imglib2.roi.labeling.ImgLabeling; +import net.imglib2.roi.labeling.LabelingType; +import net.imglib2.type.numeric.RealType; +import net.imglib2.type.numeric.integer.IntType; +import net.imglib2.util.Intervals; +import net.imglib2.view.Views; + +import org.scijava.function.Functions; +import org.scijava.ops.image.util.Neighborhoods; +import org.scijava.ops.spi.Nullable; +import org.scijava.ops.spi.OpDependency; + +/** + * Seeded region growing (Adams & Bischof 1994) in n dimensions. + *

+ * Starting from labeled seed regions, the regions grow one pixel at a time. + * All unlabeled pixels adjacent to a region are kept in a priority queue, + * ordered by {@code δ = |g(x) - mean(R)|}, the difference between the pixel + * intensity and the mean intensity of the adjacent region {@code R} at the + * time the pixel was queued. The pixel with the smallest {@code δ} is + * labeled next and the mean of its region is updated, so that the regions + * grow in order of increasing dissimilarity until every pixel reachable from + * a seed is labeled. Ties are broken first come, first served. + *

+ *

+ * Where a pixel adjoins several regions, it joins the one whose current mean + * is closest to its intensity (as in the improved algorithm of Mehnert & + * Jackway 1997), rather than being flagged as a boundary pixel as in the + * original algorithm, so that the result is a complete partition. + *

+ *

+ * Images containing NaN are rejected with an + * {@link IllegalArgumentException}, since NaN has no order, and so are seeds + * with several labels at one pixel. + *

+ *

+ * Implemented from the papers. + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @param input pixel type + * @param label type + * @see R. Adams and L. Bischof, + * "Seeded region growing", IEEE Trans. Pattern Anal. Mach. Intell. + * 16(6):641-647, 1994 + * @see A. Mehnert and + * P. Jackway, "An improved seeded region growing algorithm", Pattern + * Recognit. Lett. 18(10):1065-1071, 1997 + * @implNote op names='segment.seededRegionGrowing' + */ +public class SeededRegionGrowing, L> implements + Functions.Arity3, RandomAccessibleInterval>, Shape, ImgLabeling> +{ + + @OpDependency(name = "create.img") + private BiFunction> createOp; + + /** + * Grows the seed regions. + * + * @param input the intensity image, without NaN + * @param seeds the seed labeling, with the same dimensions as the image; + * unlabeled pixels are to be assigned, and no pixel may have + * several labels + * @param connectivity the neighborhood defining which pixels are adjacent, + * such as a {@code DiamondShape} of radius 1 for face connectivity + * (4-connectivity in 2D, 6-connectivity in 3D) or a + * {@code RectangleShape} of span 1 for full connectivity + * (8-connectivity in 2D, 26-connectivity in 3D). Defaults to full + * connectivity. + * @return the grown labeling + */ + @Override + public ImgLabeling apply(final RandomAccessibleInterval input, + final RandomAccessibleInterval> seeds, + @Nullable final Shape connectivity) + { + if (!Intervals.equalDimensions(input, seeds)) { + throw new IllegalArgumentException( + "Image and seeds must have the same dimensions"); + } + final var n = input.numDimensions(); + final var size = Intervals.numElements(input); + if (size > Integer.MAX_VALUE) { + throw new IllegalArgumentException("Image is too large: " + size + + " pixels"); + } + final var count = (int) size; + final var dims = input.dimensionsAsLongArray(); + + final var values = new double[count]; + var i = 0; + for (final var t : Views.flatIterable(input)) { + final var v = t.getRealDouble(); + if (Double.isNaN(v)) { + throw new IllegalArgumentException("NaN values are not supported"); + } + values[i++] = v; + } + + // Map seed labels to region indices. + final var region = new int[count]; + final var labels = new ArrayList(); + final var indices = new HashMap(); + i = 0; + for (final var set : Views.flatIterable(seeds)) { + if (set.isEmpty()) region[i] = -1; + else if (set.size() > 1) { + throw new IllegalArgumentException( + "Seeds must have at most one label per pixel"); + } + else { + final var label = set.iterator().next(); + region[i] = indices.computeIfAbsent(label, l -> { + labels.add(l); + return labels.size() - 1; + }); + } + i++; + } + final var sums = new double[labels.size()]; + final var sizes = new long[labels.size()]; + for (var p = 0; p < count; p++) { + if (region[p] >= 0) { + sums[region[p]] += values[p]; + sizes[region[p]]++; + } + } + + final var offsets = Neighborhoods.rasterOffsets(connectivity, n); + final var coords = new int[n]; + final var queue = new PriorityQueue(); + var order = 0L; + for (var p = 0; p < count; p++) { + if (region[p] < 0) continue; + position(p, dims, coords); + for (final var o : offsets) { + final var q = neighbor(p, coords, o, dims); + if (q >= 0 && region[q] < 0) { + queue.add(new Candidate(q, Math.abs(values[q] - sums[region[p]] / + sizes[region[p]]), order++)); + } + } + } + + while (!queue.isEmpty()) { + final var p = queue.poll().index; + if (region[p] >= 0) continue; + position(p, dims, coords); + var best = -1; + var bestDelta = Double.POSITIVE_INFINITY; + for (final var o : offsets) { + final var q = neighbor(p, coords, o, dims); + if (q < 0 || region[q] < 0) continue; + final var r = region[q]; + final var delta = Math.abs(values[p] - sums[r] / sizes[r]); + if (delta < bestDelta) { + bestDelta = delta; + best = r; + } + } + region[p] = best; + sums[best] += values[p]; + sizes[best]++; + final var mean = sums[best] / sizes[best]; + for (final var o : offsets) { + final var q = neighbor(p, coords, o, dims); + if (q >= 0 && region[q] < 0) { + queue.add(new Candidate(q, Math.abs(values[q] - mean), order++)); + } + } + } + + final var output = new ImgLabeling(createOp.apply(input, + new IntType())); + i = 0; + for (final var set : Views.flatIterable(output)) { + if (region[i] >= 0) set.add(labels.get(region[i])); + i++; + } + return output; + } + + private static void position(int p, final long[] dims, final int[] coords) { + for (var d = 0; d < dims.length; d++) { + coords[d] = (int) (p % dims[d]); + p /= (int) dims[d]; + } + } + + /** Gets the flat index of a neighbor, or -1 if it is outside the image. */ + private static int neighbor(final int p, final int[] coords, final int[] o, + final long[] dims) + { + var q = 0; + var stride = 1; + for (var d = 0; d < dims.length; d++) { + final var c = coords[d] + o[d]; + if (c < 0 || c >= dims[d]) return -1; + q += c * stride; + stride *= (int) dims[d]; + } + return q; + } + + /** A queued pixel with its priority. */ + private static final class Candidate implements Comparable { + + private final int index; + private final double delta; + private final long order; + + private Candidate(final int index, final double delta, final long order) { + this.index = index; + this.delta = delta; + this.order = order; + } + + @Override + public int compareTo(final Candidate other) { + final var cmp = Double.compare(delta, other.delta); + return cmp != 0 ? cmp : Long.compare(order, other.order); + } + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/segment/siox/DefaultSiox.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/segment/siox/DefaultSiox.java new file mode 100644 index 000000000..273187f63 --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/segment/siox/DefaultSiox.java @@ -0,0 +1,134 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.segment.siox; + +import java.util.function.BiFunction; + +import net.imglib2.Dimensions; +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.type.logic.BitType; +import net.imglib2.type.numeric.ARGBType; +import net.imglib2.type.numeric.RealType; + +import org.scijava.function.Functions; +import org.scijava.ops.spi.Nullable; +import org.scijava.ops.spi.OpDependency; + +/** + * Extracts the foreground object of an {@link ARGBType} color image using + * SIOX (Simple Interactive Object Extraction), guided by a confidence matrix + * (trimap). + *

+ * The trimap marks each sample as known background (confidence of 0.1 or + * less), known foreground (confidence of 0.8 or more) or unknown (anything in + * between). Samples with a confidence of exactly 1 also mark connected + * components of the result that are always kept. Unknown samples are + * classified by comparing their CIELAB color to color signatures of the known + * background and foreground, followed by smoothing and connected component + * filtering. The image may have any number of dimensions; the original + * algorithm is 2D. + *

+ *

+ * SIOX is described in G. Friedland, K. Jantz and R. Rojas, "SIOX: Simple + * Interactive Object Extraction in Still Images", Proceedings of the Seventh + * IEEE International Symposium on Multimedia (ISM 2005), pp. 253–260, + * doi:10.1109/ISM.2005.106. + * This op is a translation of the SIOX reference implementation of the + * Fiji + * Siox_Segmentation project, Copyright 2005, 2006 by Gerald Friedland, + * Kristian Jantz and Lars Knipping, licensed under the Apache License, Version + * 2.0; see {@link Siox} for details and changes. + *

+ *

+ * The original SIOX license notice follows. + *

+ * + *
+ * Copyright 2005, 2006 by Gerald Friedland, Kristian Jantz and Lars Knipping
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * 
+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @author Gerald Friedland + * @author Kristian Jantz + * @author Lars Knipping + * @author Ignacio Arganda-Carreras + * @param type of the trimap + * @implNote op names='segment.siox' + */ +public class DefaultSiox> implements + Functions.Arity4, RandomAccessibleInterval, Integer, Double, RandomAccessibleInterval> +{ + + @OpDependency(name = "create.img") + private BiFunction> imgCreator; + + /** + * Segments the input image using SIOX. + * + * @param image the color image to segment + * @param trimap the foreground confidence of each sample, with the same + * dimensions as the image: at most 0.1 for known background, at + * least 0.8 for known foreground (exactly 1 to also always keep + * the connected component), anything else (but NaN) for unknown + * @param smoothness the number of smoothing steps in the post-processing + * (default 6); larger values yield smoother outlines + * @param sizeFactorToKeep which connected components to keep (default 0): + * if 0, only those containing samples of confidence 1; otherwise + * also the largest one and every one whose size is at least the + * largest size divided by sizeFactorToKeep (4 corresponds to the + * multiple-components option of the Fiji plugin) + * @return the foreground mask + */ + @Override + public RandomAccessibleInterval apply( // + final RandomAccessibleInterval image, // + final RandomAccessibleInterval trimap, // + @Nullable final Integer smoothness, // + @Nullable final Double sizeFactorToKeep // + ) { + final var output = imgCreator.apply(image, new BitType()); + Siox.segment(image, trimap, smoothness, sizeFactorToKeep, output); + return output; + } + +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/segment/siox/Siox.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/segment/siox/Siox.java new file mode 100644 index 000000000..0abfb008c --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/segment/siox/Siox.java @@ -0,0 +1,934 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.segment.siox; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import net.imglib2.Cursor; +import net.imglib2.Dimensions; +import net.imglib2.FinalDimensions; +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.type.BooleanType; +import net.imglib2.type.logic.BitType; +import net.imglib2.type.numeric.ARGBType; +import net.imglib2.type.numeric.IntegerType; +import net.imglib2.type.numeric.RealType; +import net.imglib2.util.Intervals; +import net.imglib2.view.Views; + +/** + * Core implementation of SIOX (Simple Interactive Object Extraction), shared + * by the {@code segment.siox} ops. + *

+ * SIOX is described in G. Friedland, K. Jantz and R. Rojas, "SIOX: Simple + * Interactive Object Extraction in Still Images", Proceedings of the Seventh + * IEEE International Symposium on Multimedia (ISM 2005), pp. 253–260, + * doi:10.1109/ISM.2005.106, + * and in G. Friedland, K. Jantz, L. Knipping and R. Rojas, "Image + * Segmentation by Uniform Color Clustering – Approach and Benchmark + * Results", Technical Report B-05-07, Department of Computer Science, Freie + * Universität Berlin (2005). Given a color image and a confidence matrix + * (trimap) marking known background, known foreground and unknown samples, + * the known samples are converted to CIELAB and clustered into a background + * and a foreground color signature with a two-stage k-d tree. Each + * unknown sample is then classified by its nearest signature color, and the + * result is post-processed by smoothing, erosion, retention of the large (or + * user-marked) connected components, color region filling and dilation. + *

+ *

+ * This class is a translation of {@code org.siox.SioxSegmentator}, + * {@code org.siox.ColorSignature} and {@code org.siox.util.Utils} of the + * Fiji + * Siox_Segmentation project (SIOX reference implementation version 1.13, + * with the user-marked component extension by Ignacio Arganda-Carreras), + * used under the Apache License, Version 2.0. The original license notice + * follows; the changes made are listed below it. + *

+ * + *
+ * Copyright 2005, 2006 by Gerald Friedland, Kristian Jantz and Lars Knipping
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * 
+ *

+ * Changes from the original: the post-processing (smoothing, erosion, + * dilation and connected components) is generalized from 2D to n dimensions, + * applying the original per-axis passes along every axis in turn; colors may + * come from {@link ARGBType} samples or from three {@link RealType} channels; + * a color signature whose samples all fall into a single cluster no longer + * includes unused (black) entries of the sample buffer in its centroid; a + * missing background is reported by an exception rather than a return value; + * the interactive detail refinement and the video variants are omitted. + * Otherwise, for 2D {@link ARGBType} images, the result is identical to that + * of {@code SioxSegmentator.segmentate}. + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @author Gerald Friedland + * @author Kristian Jantz + * @author Lars Knipping + * @author Ignacio Arganda-Carreras + */ +public final class Siox { + + /** Default number of smoothing steps in the post-processing. */ + public static final int DEFAULT_SMOOTHNESS = 6; + + /** Default size factor: keep only components containing known foreground. */ + public static final double DEFAULT_SIZE_FACTOR = 0; + + /** Confidence of a certain foreground sample. */ + public static final float CERTAIN_FOREGROUND_CONFIDENCE = 1.0f; + + /** Minimum confidence of a known foreground sample. */ + public static final float FOREGROUND_CONFIDENCE = 0.8f; + + /** Confidence of a sample of unknown affiliation. */ + public static final float UNKNOWN_REGION_CONFIDENCE = 0.5f; + + /** Maximum confidence of a known background sample. */ + public static final float BACKGROUND_CONFIDENCE = 0.1f; + + /** Confidence of a certain background sample. */ + public static final float CERTAIN_BACKGROUND_CONFIDENCE = 0.0f; + + /** Cluster diameters along the L, a and b axes. */ + private static final float[] LIMITS = { 0.64f, 1.28f, 2.56f }; + + private Siox() { + // Prevent instantiation of utility class. + } + + // -- Op entry points -- + + /** + * Runs SIOX on an {@link ARGBType} image with a confidence matrix. + * + * @param image the color image + * @param trimap the confidence matrix, with the same dimensions as the image + * @param smoothness number of smoothing steps, or null for the default + * @param sizeFactorToKeep size factor of components to keep, or null for + * the default + * @param output the output mask, with the same dimensions as the image + */ + static > void segment( + final RandomAccessibleInterval image, + final RandomAccessibleInterval trimap, final Integer smoothness, + final Double sizeFactorToKeep, final RandomAccessibleInterval output) + { + checkDims(image, trimap, "trimap"); + final float[] cm = confidences(trimap); + run(argbToLab(image, cm.length), cm, image, smoothness, sizeFactorToKeep, + output); + } + + /** + * Runs SIOX on an {@link ARGBType} image with foreground and background + * masks. + * + * @param image the color image + * @param foreground mask of known foreground samples + * @param background mask of known background samples + * @param smoothness number of smoothing steps, or null for the default + * @param sizeFactorToKeep size factor of components to keep, or null for + * the default + * @param output the output mask, with the same dimensions as the image + */ + static > void segment( + final RandomAccessibleInterval image, + final RandomAccessibleInterval foreground, + final RandomAccessibleInterval background, final Integer smoothness, + final Double sizeFactorToKeep, final RandomAccessibleInterval output) + { + checkDims(image, foreground, "foreground mask"); + checkDims(image, background, "background mask"); + final float[] cm = confidences(foreground, background); + run(argbToLab(image, cm.length), cm, image, smoothness, sizeFactorToKeep, + output); + } + + /** + * Runs SIOX on a three-channel image with a confidence matrix. + * + * @param image the color image, with the channels along its last axis + * @param trimap the confidence matrix, with the spatial dimensions of the + * image + * @param smoothness number of smoothing steps, or null for the default + * @param sizeFactorToKeep size factor of components to keep, or null for + * the default + * @param output the output mask, with the spatial dimensions of the image + */ + static , R extends RealType> void segmentChannels( + final RandomAccessibleInterval image, + final RandomAccessibleInterval trimap, final Integer smoothness, + final Double sizeFactorToKeep, final RandomAccessibleInterval output) + { + final Dimensions spatial = spatialDims(image); + checkDims(spatial, trimap, "trimap"); + final float[] cm = confidences(trimap); + run(channelsToLab(image, cm.length), cm, spatial, smoothness, + sizeFactorToKeep, output); + } + + /** + * Runs SIOX on a three-channel image with foreground and background masks. + * + * @param image the color image, with the channels along its last axis + * @param foreground mask of known foreground samples + * @param background mask of known background samples + * @param smoothness number of smoothing steps, or null for the default + * @param sizeFactorToKeep size factor of components to keep, or null for + * the default + * @param output the output mask, with the spatial dimensions of the image + */ + static , B extends BooleanType> void + segmentChannels(final RandomAccessibleInterval image, + final RandomAccessibleInterval foreground, + final RandomAccessibleInterval background, final Integer smoothness, + final Double sizeFactorToKeep, + final RandomAccessibleInterval output) + { + final Dimensions spatial = spatialDims(image); + checkDims(spatial, foreground, "foreground mask"); + checkDims(spatial, background, "background mask"); + final float[] cm = confidences(foreground, background); + run(channelsToLab(image, cm.length), cm, spatial, smoothness, + sizeFactorToKeep, output); + } + + /** + * Gets the spatial dimensions of a three-channel image. + * + * @param image the image, with the channels along its last axis + * @return the dimensions of the image without its last axis + */ + static Dimensions spatialDims(final RandomAccessibleInterval image) { + final int n = image.numDimensions(); + if (n < 2 || image.dimension(n - 1) != 3) { + throw new IllegalArgumentException( + "Image must have three channels along its last axis"); + } + final long[] dims = new long[n - 1]; + for (int d = 0; d < n - 1; d++) + dims[d] = image.dimension(d); + return new FinalDimensions(dims); + } + + // -- Core algorithm -- + + /** + * Segments an image with information from a confidence matrix, as in + * {@code SioxSegmentator.segmentate}. + *

+ * Confidences of {@link #BACKGROUND_CONFIDENCE} or less mark known + * background, those of at least {@link #FOREGROUND_CONFIDENCE} mark known + * foreground, and any other value marks a sample of unknown affiliation. + * Known foreground samples whose confidence is exactly + * {@link #CERTAIN_FOREGROUND_CONFIDENCE} additionally mark the connected + * components which are always kept. On return, each entry of {@code cm} is + * either {@link #CERTAIN_FOREGROUND_CONFIDENCE} or + * {@link #CERTAIN_BACKGROUND_CONFIDENCE}. + *

+ * + * @param lab CIELAB color of each sample, in flat iteration order + * @param cm confidence of each sample, in flat iteration order, which is + * overwritten with the result + * @param dims dimensions of the image + * @param smoothness number of smoothing steps in the post-processing + * @param sizeFactorToKeep if zero, only components containing certain + * foreground samples are kept; otherwise, the largest component, + * those containing certain foreground samples, and every component + * of size at least {@code largestSize / sizeFactorToKeep} are kept + * @throws IllegalArgumentException if there is no known background + * @throws IllegalStateException if there is no known foreground but some + * samples of unknown affiliation + */ + public static void segmentate(final float[][] lab, final float[] cm, + final long[] dims, final int smoothness, final double sizeFactorToKeep) + { + final int n = cm.length; + final Grid grid = new Grid(dims, n); + final boolean[] predefinedFg = new boolean[n]; + + // Create color signatures. + final float[][] knownBg = new float[n][]; + final float[][] knownFg = new float[n][]; + int knownBgCount = 0, knownFgCount = 0; + for (int i = 0; i < n; i++) { + if (cm[i] <= BACKGROUND_CONFIDENCE) { + knownBg[knownBgCount++] = lab[i]; + } + else if (cm[i] >= FOREGROUND_CONFIDENCE) { + knownFg[knownFgCount++] = lab[i]; + if (cm[i] == CERTAIN_FOREGROUND_CONFIDENCE) predefinedFg[i] = true; + } + } + final float[][] bgSignature = createSignature(knownBg, knownBgCount, + LIMITS, BACKGROUND_CONFIDENCE); + final float[][] fgSignature = createSignature(knownFg, knownFgCount, + LIMITS, BACKGROUND_CONFIDENCE); + if (bgSignature.length < 1) { + throw new IllegalArgumentException( + "Segmentation impossible: no known background"); + } + + // Classify using the color signatures, caching the result per color. + final Map cache = new HashMap<>(); + for (int i = 0; i < n; i++) { + if (cm[i] >= FOREGROUND_CONFIDENCE) { + cm[i] = CERTAIN_FOREGROUND_CONFIDENCE; + continue; + } + if (cm[i] <= BACKGROUND_CONFIDENCE) { + cm[i] = CERTAIN_BACKGROUND_CONFIDENCE; + continue; + } + final ColorKey key = new ColorKey(lab[i]); + final float[] minDists = cache.get(key); + final boolean isBackground; + if (minDists == null) { + if (fgSignature.length == 0) { + throw new IllegalStateException( + "Foreground signature does not exist"); + } + final float minBg = minSqrDist(lab[i], bgSignature); + final float minFg = minSqrDist(lab[i], fgSignature); + // Note: The original compares strictly for the first sample of a + // color but non-strictly for its cached repeats; this is preserved. + isBackground = minBg < minFg; + cache.put(key, new float[] { minBg, minFg }); + } + else { + isBackground = minDists[0] <= minDists[1]; + } + cm[i] = isBackground ? CERTAIN_BACKGROUND_CONFIDENCE + : CERTAIN_FOREGROUND_CONFIDENCE; + } + + // Post-processing. + smooth(cm, grid, 0.33f, 0.33f, 0.33f); + normalize(cm); + morph(cm, grid, false); + keepOnlyLargeComponents(cm, grid, UNKNOWN_REGION_CONFIDENCE, + sizeFactorToKeep, predefinedFg); + for (int i = 0; i < smoothness; i++) { + smooth(cm, grid, 0.33f, 0.33f, 0.33f); + } + normalize(cm); + for (int i = 0; i < n; i++) { + cm[i] = cm[i] >= UNKNOWN_REGION_CONFIDENCE + ? CERTAIN_FOREGROUND_CONFIDENCE : CERTAIN_BACKGROUND_CONFIDENCE; + } + keepOnlyLargeComponents(cm, grid, UNKNOWN_REGION_CONFIDENCE, + sizeFactorToKeep, predefinedFg); + fillColorRegions(cm, lab, grid); + morph(cm, grid, true); + } + + /** + * Converts an sRGB color to CIELAB (reference white D65, 2 degree + * observer), as in {@code Utils.rgbToClab}. + * + * @param r red component in [0, 1] + * @param g green component in [0, 1] + * @param b blue component in [0, 1] + * @return the {L, a, b} triple + */ + public static float[] rgbToLab(final float r, final float g, final float b) { + final float rl = linearize(r) * 100f; + final float gl = linearize(g) * 100f; + final float bl = linearize(b) * 100f; + + final float x = rl * 0.4124f + gl * 0.3576f + bl * 0.1805f; + final float y = rl * 0.2126f + gl * 0.7152f + bl * 0.0722f; + final float z = rl * 0.0193f + gl * 0.1192f + bl * 0.9505f; + + final float fx = labF(x / 95.047f); + final float fy = labF(y / 100f); + final float fz = labF(z / 108.883f); + return new float[] { (116f * fy) - 16f, 500f * (fx - fy), 200f * (fy - + fz) }; + } + + // -- Helper methods -- + + private static void run(final float[][] lab, final float[] cm, + final Dimensions dims, final Integer smoothness, + final Double sizeFactorToKeep, final RandomAccessibleInterval out) + { + checkDims(dims, out, "output"); + final int s = smoothness == null ? DEFAULT_SMOOTHNESS : smoothness; + final double f = sizeFactorToKeep == null ? DEFAULT_SIZE_FACTOR + : sizeFactorToKeep; + if (s < 0) { + throw new IllegalArgumentException("Smoothness must be non-negative"); + } + if (f < 0) { + throw new IllegalArgumentException("Size factor must be non-negative"); + } + segmentate(lab, cm, dims.dimensionsAsLongArray(), s, f); + final Cursor c = Views.flatIterable(out).cursor(); + for (final float v : cm) { + c.next().set(v >= UNKNOWN_REGION_CONFIDENCE); + } + } + + private static void checkDims(final Dimensions expected, + final Dimensions actual, final String what) + { + if (!Intervals.equalDimensions(expected, actual)) { + throw new IllegalArgumentException("Dimensions of " + what + + " do not match those of the image"); + } + } + + private static int size(final Dimensions dims) { + final long n = Intervals.numElements(dims); + if (n > Integer.MAX_VALUE) { + throw new IllegalArgumentException("Image is too large"); + } + return (int) n; + } + + private static > float[] confidences( + final RandomAccessibleInterval trimap) + { + final float[] cm = new float[size(trimap)]; + int i = 0; + for (final R t : Views.flatIterable(trimap)) { + final float v = t.getRealFloat(); + if (Float.isNaN(v)) { + throw new IllegalArgumentException("NaN values are not supported"); + } + cm[i++] = v; + } + return cm; + } + + private static > float[] confidences( + final RandomAccessibleInterval foreground, + final RandomAccessibleInterval background) + { + final float[] cm = new float[size(foreground)]; + final Cursor fg = Views.flatIterable(foreground).cursor(); + final Cursor bg = Views.flatIterable(background).cursor(); + for (int i = 0; i < cm.length; i++) { + final boolean isFg = fg.next().get(); + final boolean isBg = bg.next().get(); + if (isFg && isBg) { + throw new IllegalArgumentException( + "Foreground and background masks overlap"); + } + cm[i] = isFg ? CERTAIN_FOREGROUND_CONFIDENCE : isBg + ? CERTAIN_BACKGROUND_CONFIDENCE : UNKNOWN_REGION_CONFIDENCE; + } + return cm; + } + + private static float[][] argbToLab( + final RandomAccessibleInterval image, final int n) + { + final Map cache = new HashMap<>(); + final float[][] lab = new float[n][]; + int i = 0; + for (final ARGBType t : Views.flatIterable(image)) { + final int rgb = t.get() & 0xffffff; + lab[i++] = cache.computeIfAbsent(rgb, v -> rgbToLab( // + ARGBType.red(v) / 255.0f, // + ARGBType.green(v) / 255.0f, // + ARGBType.blue(v) / 255.0f)); + } + return lab; + } + + private static > float[][] channelsToLab( + final RandomAccessibleInterval image, final int n) + { + final int c = image.numDimensions() - 1; + final T type = image.getType(); + // Note: Integer samples are scaled by the maximum of their type, so + // that e.g. 8-bit and 16-bit images map onto [0, 1]; real samples are + // assumed to lie in [0, 1] already. + final float white = type instanceof IntegerType ? (float) type + .getMaxValue() : 1f; + final List> cursors = new ArrayList<>(); + for (int ch = 0; ch < 3; ch++) { + cursors.add(Views.flatIterable(Views.hyperSlice(image, c, image.min(c) + + ch)).cursor()); + } + final float[][] lab = new float[n][]; + final float[] rgb = new float[3]; + for (int i = 0; i < n; i++) { + for (int ch = 0; ch < 3; ch++) { + final float v = cursors.get(ch).next().getRealFloat() / white; + if (Float.isNaN(v)) { + throw new IllegalArgumentException("NaN values are not supported"); + } + rgb[ch] = Math.max(0f, Math.min(1f, v)); + } + lab[i] = rgbToLab(rgb[0], rgb[1], rgb[2]); + } + return lab; + } + + private static float linearize(final float v) { + return v > 0.04045 ? (float) Math.pow((v + 0.055f) / 1.055f, 2.4) : v / + 12.92f; + } + + private static float labF(final float v) { + return v > 0.008856f ? (float) Math.pow(v, (1f / 3f)) : (7.787f * v) + + (16f / 116f); + } + + private static float sqrDist(final float[] p, final float[] q) { + float sum = 0; + for (int i = 0; i < p.length; i++) { + sum += (p[i] - q[i]) * (p[i] - q[i]); + } + return sum; + } + + private static float minSqrDist(final float[] p, final float[][] signature) { + float min = Float.MAX_VALUE; + for (final float[] q : signature) { + final float d = sqrDist(p, q); + if (d < min) min = d; + } + return min; + } + + // -- Color signature (ColorSignature) -- + + /** + * Creates a color signature by two-stage k-d tree clustering, as in + * {@code ColorSignature.createSignature}. Stage one splits the samples + * until each leaf is smaller than the cluster diameters; stage two + * recombines the leaf centroids the same way, and drops clusters holding + * less than {@code threshold} percent of the samples. + */ + private static float[][] createSignature(final float[][] input, + final int length, final float[] limits, final float threshold) + { + final List clusters1 = new ArrayList<>(); + final List clusters2 = new ArrayList<>(); + stageOne(input, 0, clusters1, limits, length); + + final float[][] centroids = new float[clusters1.size()][]; + for (int i = 0; i < clusters1.size(); i++) { + final float[][] cluster = clusters1.get(i); + final int dims = cluster[0].length; + final float[] centroid = new float[dims + 1]; // +1 for cardinality. + for (final float[] p : cluster) { + for (int j = 0; j < dims; j++) { + centroid[j] += p[j]; + } + } + for (int j = 0; j < dims; j++) { + centroid[j] /= cluster.length; + } + centroid[dims] = cluster.length; + centroids[i] = centroid; + } + stageTwo(centroids, 0, clusters2, limits, length, threshold); + return clusters2.toArray(new float[0][]); + } + + private static void stageOne(final float[][] points, final int depth, + final List clusters, final float[] limits, final int length) + { + if (length < 1) return; + final int dims = points[0].length; + final int curdim = depth % dims; + float min = points[0][curdim]; + float max = points[0][curdim]; + for (int i = 1; i < length; i++) { + if (min > points[i][curdim]) min = points[i][curdim]; + if (max < points[i][curdim]) max = points[i][curdim]; + } + if (max - min > limits[curdim]) { + final float pivot = ((max - min) / 2.0f) + min; + final float[][][] halves = split(points, length, curdim, pivot); + stageOne(halves[0], depth + 1, clusters, limits, halves[0].length); + stageOne(halves[1], depth + 1, clusters, limits, halves[1].length); + } + else { + // Note: The original adds the whole buffer here, including its unused + // entries, when all samples fall into one cluster at the top level. + clusters.add(length == points.length ? points : Arrays.copyOf(points, + length)); + } + } + + private static void stageTwo(final float[][] points, final int depth, + final List clusters, final float[] limits, final int total, + final float threshold) + { + if (points.length < 1) return; + final int dims = points[0].length - 1; // Without cardinality. + final int curdim = depth % dims; + float min = points[0][curdim]; + float max = points[0][curdim]; + for (int i = 1; i < points.length; i++) { + if (min > points[i][curdim]) min = points[i][curdim]; + if (max < points[i][curdim]) max = points[i][curdim]; + } + if (max - min > limits[curdim]) { + final float pivot = ((max - min) / 2.0f) + min; + final float[][][] halves = split(points, points.length, curdim, pivot); + stageTwo(halves[0], depth + 1, clusters, limits, total, threshold); + stageTwo(halves[1], depth + 1, clusters, limits, total, threshold); + } + else { + int sum = 0; + for (final float[] p : points) { + sum += p[p.length - 1]; + } + if (((sum * 100.0) / total) >= threshold) { + final float[] point = new float[points[0].length]; + for (final float[] p : points) { + for (int j = 0; j < point.length; j++) { + point[j] += p[j]; + } + } + for (int j = 0; j < point.length - 1; j++) { + point[j] /= points.length; + } + clusters.add(point); + } + } + } + + private static float[][][] split(final float[][] points, final int length, + final int curdim, final float pivot) + { + int countSmaller = 0; + for (int i = 0; i < length; i++) { + if (points[i][curdim] <= pivot) countSmaller++; + } + final float[][] smaller = new float[countSmaller][]; + final float[][] bigger = new float[length - countSmaller][]; + int s = 0, b = 0; + for (int i = 0; i < length; i++) { + if (points[i][curdim] <= pivot) smaller[s++] = points[i]; + else bigger[b++] = points[i]; + } + return new float[][][] { smaller, bigger }; + } + + // -- Post-processing (Utils and SioxSegmentator) -- + + /** + * Blurs the confidence matrix in place with a three-sample kernel, running + * a forward and a backward pass along each axis, as in + * {@code Utils.smoothcm}. + */ + private static void smooth(final float[] cm, final Grid grid, final float f1, + final float f2, final float f3) + { + for (int d = 0; d < grid.dims.length; d++) { + final int len = (int) grid.dims[d]; + final int st = grid.strides[d]; + for (final int start : grid.lineStarts[d]) { + for (int k = 0; k < len - 2; k++) { + final int idx = start + k * st; + cm[idx] = f1 * cm[idx] + f2 * cm[idx + st] + f3 * cm[idx + 2 * st]; + } + } + for (final int start : grid.lineStarts[d]) { + for (int k = len - 1; k >= 2; k--) { + final int idx = start + k * st; + cm[idx] = f3 * cm[idx - 2 * st] + f2 * cm[idx - st] + f1 * cm[idx]; + } + } + } + } + + /** + * Applies a morphological dilation (if {@code max}) or erosion with a box + * of three samples per axis in place, as in {@code Utils.dilate} and + * {@code Utils.erode}. + */ + private static void morph(final float[] cm, final Grid grid, + final boolean max) + { + for (int d = 0; d < grid.dims.length; d++) { + final int len = (int) grid.dims[d]; + final int st = grid.strides[d]; + for (final int start : grid.lineStarts[d]) { + for (int k = 0; k < len - 1; k++) { + final int idx = start + k * st; + cm[idx] = max ? Math.max(cm[idx], cm[idx + st]) : Math.min(cm[idx], + cm[idx + st]); + } + } + for (final int start : grid.lineStarts[d]) { + for (int k = len - 1; k >= 1; k--) { + final int idx = start + k * st; + cm[idx] = max ? Math.max(cm[idx - st], cm[idx]) : Math.min(cm[idx - + st], cm[idx]); + } + } + } + } + + /** Scales the matrix so that its maximum becomes one. */ + private static void normalize(final float[] cm) { + float max = 0.0f; + for (final float v : cm) { + if (max < v) max = v; + } + if (max <= 0.0 || max == 1.00) return; + final float alpha = 1.00f / max; + for (int i = 0; i < cm.length; i++) { + cm[i] = alpha * cm[i]; + } + } + + /** + * Clears the confidence matrix except for the components containing + * predefined foreground samples and, unless {@code sizeFactorToKeep} is + * zero, the largest component and every component with + * {@code size * sizeFactorToKeep >= largestSize}, as in + * {@code SioxSegmentator.keepOnlyLargeComponents}. + */ + private static void keepOnlyLargeComponents(final float[] cm, + final Grid grid, final float threshold, final double sizeFactorToKeep, + final boolean[] predefinedFg) + { + final int[] labelField = new int[cm.length]; + Arrays.fill(labelField, -1); + final IntStack stack = new IntStack(); + final List labelSizes = new ArrayList<>(); + final Set predefinedLabels = new HashSet<>(); + int curLabel = 0; + int maxRegion = 0; + int maxBlob = 0; + for (int i = 0; i < cm.length; i++) { + int regionCount = 0; + if (labelField[i] == -1 && cm[i] >= threshold) { + regionCount = depthFirstSearch(cm, labelField, i, threshold, + curLabel++, grid, stack); + labelSizes.add(regionCount); + } + if (labelField[i] != -1 && predefinedFg[i]) { + predefinedLabels.add(labelField[i]); + } + if (regionCount > maxRegion) { + maxRegion = regionCount; + maxBlob = curLabel - 1; + } + } + + for (int i = 0; i < cm.length; i++) { + final int label = labelField[i]; + if (label == -1) continue; + if (sizeFactorToKeep == 0) { + cm[i] = predefinedLabels.contains(label) + ? CERTAIN_FOREGROUND_CONFIDENCE : CERTAIN_BACKGROUND_CONFIDENCE; + } + else if (labelSizes.get(label) * sizeFactorToKeep < maxRegion && + !predefinedLabels.contains(label)) + { + cm[i] = CERTAIN_BACKGROUND_CONFIDENCE; + } + else if (label == maxBlob) { + cm[i] = CERTAIN_FOREGROUND_CONFIDENCE; + } + } + } + + /** Labels the face-connected component of samples at or above threshold. */ + private static int depthFirstSearch(final float[] cm, final int[] labelField, + final int i, final float threshold, final int curLabel, final Grid grid, + final IntStack stack) + { + int componentSize = 0; + if (labelField[i] == -1 && cm[i] >= threshold) { + labelField[i] = curLabel; + ++componentSize; + stack.push(i); + } + while (!stack.isEmpty()) { + final int pos = stack.pop(); + for (int d = 0; d < grid.dims.length; d++) { + final int c = grid.coord(pos, d); + for (int dir = -1; dir <= 1; dir += 2) { + if (c + dir < 0 || c + dir >= grid.dims[d]) continue; + final int nb = pos + dir * grid.strides[d]; + if (labelField[nb] == -1 && cm[nb] >= threshold) { + labelField[nb] = curLabel; + ++componentSize; + stack.push(nb); + } + } + } + } + return componentSize; + } + + /** + * Marks as foreground every sample connected to a foreground sample through + * samples of (almost) the same color as that sample, as in + * {@code SioxSegmentator.fillColorRegions}. + */ + private static void fillColorRegions(final float[] cm, final float[][] lab, + final Grid grid) + { + final int[] labelField = new int[cm.length]; + Arrays.fill(labelField, -1); + final IntStack stack = new IntStack(); + for (int i = 0; i < cm.length; i++) { + if (labelField[i] != -1 || cm[i] < UNKNOWN_REGION_CONFIDENCE) continue; + final float[] origColor = lab[i]; + final int curLabel = i + 1; + labelField[i] = curLabel; + cm[i] = CERTAIN_FOREGROUND_CONFIDENCE; + stack.push(i); + while (!stack.isEmpty()) { + final int pos = stack.pop(); + for (int d = 0; d < grid.dims.length; d++) { + final int c = grid.coord(pos, d); + for (int dir = -1; dir <= 1; dir += 2) { + if (c + dir < 0 || c + dir >= grid.dims[d]) continue; + final int nb = pos + dir * grid.strides[d]; + if (labelField[nb] == -1 && (float) Math.sqrt(sqrDist(lab[nb], + origColor)) < 1.0) + { + labelField[nb] = curLabel; + cm[nb] = CERTAIN_FOREGROUND_CONFIDENCE; + stack.push(nb); + } + } + } + } + } + } + + // -- Helper classes -- + + /** Flat-indexing geometry of an n-dimensional image. */ + private static final class Grid { + + private final long[] dims; + private final int[] strides; + /** Flat index of the first sample of every line along each axis. */ + private final int[][] lineStarts; + + Grid(final long[] dims, final int n) { + this.dims = dims; + strides = new int[dims.length]; + long stride = 1; + for (int d = 0; d < dims.length; d++) { + strides[d] = (int) stride; + stride *= dims[d]; + } + if (stride != n) { + throw new IllegalArgumentException( + "Dimensions do not match data size"); + } + lineStarts = new int[dims.length][]; + for (int d = 0; d < dims.length; d++) { + final int[] starts = new int[dims[d] == 0 ? 0 : (int) (n / dims[d])]; + int k = 0; + for (int i = 0; i < n; i++) { + if (coord(i, d) == 0) starts[k++] = i; + } + lineStarts[d] = starts; + } + } + + int coord(final int pos, final int d) { + return (int) ((pos / strides[d]) % dims[d]); + } + } + + /** Hash key identifying a CIELAB color exactly. */ + private static final class ColorKey { + + private final float l, a, b; + + ColorKey(final float[] lab) { + l = lab[0]; + a = lab[1]; + b = lab[2]; + } + + @Override + public boolean equals(final Object o) { + if (!(o instanceof ColorKey)) return false; + final ColorKey k = (ColorKey) o; + return Float.compare(l, k.l) == 0 && Float.compare(a, k.a) == 0 && + Float.compare(b, k.b) == 0; + } + + @Override + public int hashCode() { + return Arrays.hashCode(new float[] { l, a, b }); + } + } + + /** Growable stack of {@code int} values. */ + private static final class IntStack { + + private int[] data = new int[64]; + private int size; + + void push(final int v) { + if (size == data.length) data = Arrays.copyOf(data, size * 2); + data[size++] = v; + } + + int pop() { + return data[--size]; + } + + boolean isEmpty() { + return size == 0; + } + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/segment/siox/SioxChannels.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/segment/siox/SioxChannels.java new file mode 100644 index 000000000..703a8f4cd --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/segment/siox/SioxChannels.java @@ -0,0 +1,140 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.segment.siox; + +import java.util.function.BiFunction; + +import net.imglib2.Dimensions; +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.type.logic.BitType; +import net.imglib2.type.numeric.RealType; + +import org.scijava.function.Functions; +import org.scijava.ops.spi.Nullable; +import org.scijava.ops.spi.OpDependency; + +/** + * Extracts the foreground object of a three-channel color image using + * SIOX (Simple Interactive Object Extraction), guided by a confidence matrix + * (trimap). + *

+ * The trimap marks each sample as known background (confidence of 0.1 or + * less), known foreground (confidence of 0.8 or more) or unknown (anything in + * between). Samples with a confidence of exactly 1 also mark connected + * components of the result that are always kept. Unknown samples are + * classified by comparing their CIELAB color to color signatures of the known + * background and foreground, followed by smoothing and connected component + * filtering. The channels (red, green and blue) lie along the last axis of + * the image, which may have any number of spatial dimensions; the original + * algorithm is 2D. Samples of integer types are scaled by the maximum value + * of their type (e.g. 255 for 8-bit), and real samples are expected to lie in + * [0, 1]; values outside that range are clamped. + *

+ *

+ * SIOX is described in G. Friedland, K. Jantz and R. Rojas, "SIOX: Simple + * Interactive Object Extraction in Still Images", Proceedings of the Seventh + * IEEE International Symposium on Multimedia (ISM 2005), pp. 253–260, + * doi:10.1109/ISM.2005.106. + * This op is a translation of the SIOX reference implementation of the + * Fiji + * Siox_Segmentation project, Copyright 2005, 2006 by Gerald Friedland, + * Kristian Jantz and Lars Knipping, licensed under the Apache License, Version + * 2.0; see {@link Siox} for details and changes. + *

+ *

+ * The original SIOX license notice follows. + *

+ * + *
+ * Copyright 2005, 2006 by Gerald Friedland, Kristian Jantz and Lars Knipping
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * 
+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @author Gerald Friedland + * @author Kristian Jantz + * @author Lars Knipping + * @author Ignacio Arganda-Carreras + * @param type of the image channels + * @param type of the trimap + * @implNote op names='segment.siox' + */ +public class SioxChannels, R extends RealType> implements + Functions.Arity4, RandomAccessibleInterval, Integer, Double, RandomAccessibleInterval> +{ + + @OpDependency(name = "create.img") + private BiFunction> imgCreator; + + /** + * Segments the input image using SIOX. + * + * @param image the color image to segment, with three channels along its + * last axis, without NaN + * @param trimap the foreground confidence of each sample, with the spatial + * dimensions of the image: at most 0.1 for known background, at + * least 0.8 for known foreground (exactly 1 to also always keep + * the connected component), anything else (but NaN) for unknown + * @param smoothness the number of smoothing steps in the post-processing + * (default 6); larger values yield smoother outlines + * @param sizeFactorToKeep which connected components to keep (default 0): + * if 0, only those containing samples of confidence 1; otherwise + * also the largest one and every one whose size is at least the + * largest size divided by sizeFactorToKeep (4 corresponds to the + * multiple-components option of the Fiji plugin) + * @return the foreground mask + */ + @Override + public RandomAccessibleInterval apply( // + final RandomAccessibleInterval image, // + final RandomAccessibleInterval trimap, // + @Nullable final Integer smoothness, // + @Nullable final Double sizeFactorToKeep // + ) { + final var output = imgCreator.apply(Siox.spatialDims(image), + new BitType()); + Siox.segmentChannels(image, trimap, smoothness, sizeFactorToKeep, + output); + return output; + } + +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/segment/siox/SioxChannelsMasks.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/segment/siox/SioxChannelsMasks.java new file mode 100644 index 000000000..ebd82bbe5 --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/segment/siox/SioxChannelsMasks.java @@ -0,0 +1,143 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.segment.siox; + +import java.util.function.BiFunction; + +import net.imglib2.Dimensions; +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.type.BooleanType; +import net.imglib2.type.logic.BitType; +import net.imglib2.type.numeric.RealType; + +import org.scijava.function.Functions; +import org.scijava.ops.spi.Nullable; +import org.scijava.ops.spi.OpDependency; + +/** + * Extracts the foreground object of a three-channel color image using + * SIOX (Simple Interactive Object Extraction), guided by masks of known + * foreground and known background samples. + *

+ * Samples in neither mask are of unknown affiliation; the connected + * components of the result containing known foreground samples are always + * kept. This is equivalent to the trimap variant of {@code segment.siox} + * with confidence 1 for foreground, 0 for background and 0.5 for unknown + * samples. Unknown samples are classified by comparing their CIELAB color + * to color signatures of the known background and foreground, followed by + * smoothing and connected component filtering. The channels (red, green and + * blue) lie along the last axis of the image, which may have any number of + * spatial dimensions; the original algorithm is 2D. Samples of integer types + * are scaled by the maximum value of their type (e.g. 255 for 8-bit), and + * real samples are expected to lie in [0, 1]; values outside that range are + * clamped. + *

+ *

+ * SIOX is described in G. Friedland, K. Jantz and R. Rojas, "SIOX: Simple + * Interactive Object Extraction in Still Images", Proceedings of the Seventh + * IEEE International Symposium on Multimedia (ISM 2005), pp. 253–260, + * doi:10.1109/ISM.2005.106. + * This op is a translation of the SIOX reference implementation of the + * Fiji + * Siox_Segmentation project, Copyright 2005, 2006 by Gerald Friedland, + * Kristian Jantz and Lars Knipping, licensed under the Apache License, Version + * 2.0; see {@link Siox} for details and changes. + *

+ *

+ * The original SIOX license notice follows. + *

+ * + *
+ * Copyright 2005, 2006 by Gerald Friedland, Kristian Jantz and Lars Knipping
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * 
+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @author Gerald Friedland + * @author Kristian Jantz + * @author Lars Knipping + * @author Ignacio Arganda-Carreras + * @param type of the image channels + * @param type of the masks + * @implNote op names='segment.siox' + */ +public class SioxChannelsMasks, B extends BooleanType> implements + Functions.Arity5, RandomAccessibleInterval, RandomAccessibleInterval, Integer, Double, RandomAccessibleInterval> +{ + + @OpDependency(name = "create.img") + private BiFunction> imgCreator; + + /** + * Segments the input image using SIOX. + * + * @param image the color image to segment, with three channels along its + * last axis, without NaN + * @param foreground mask of the known foreground samples, with the spatial + * dimensions of the image + * @param background mask of the known background samples, with the spatial + * dimensions of the image, which must not overlap the foreground + * @param smoothness the number of smoothing steps in the post-processing + * (default 6); larger values yield smoother outlines + * @param sizeFactorToKeep which connected components to keep (default 0): + * if 0, only those containing known foreground; otherwise + * also the largest one and every one whose size is at least the + * largest size divided by sizeFactorToKeep (4 corresponds to the + * multiple-components option of the Fiji plugin) + * @return the foreground mask + */ + @Override + public RandomAccessibleInterval apply( // + final RandomAccessibleInterval image, // + final RandomAccessibleInterval foreground, // + final RandomAccessibleInterval background, // + @Nullable final Integer smoothness, // + @Nullable final Double sizeFactorToKeep // + ) { + final var output = imgCreator.apply(Siox.spatialDims(image), + new BitType()); + Siox.segmentChannels(image, foreground, background, smoothness, + sizeFactorToKeep, output); + return output; + } + +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/segment/siox/SioxMasks.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/segment/siox/SioxMasks.java new file mode 100644 index 000000000..4823b0182 --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/segment/siox/SioxMasks.java @@ -0,0 +1,136 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.segment.siox; + +import java.util.function.BiFunction; + +import net.imglib2.Dimensions; +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.type.BooleanType; +import net.imglib2.type.logic.BitType; +import net.imglib2.type.numeric.ARGBType; + +import org.scijava.function.Functions; +import org.scijava.ops.spi.Nullable; +import org.scijava.ops.spi.OpDependency; + +/** + * Extracts the foreground object of an {@link ARGBType} color image using + * SIOX (Simple Interactive Object Extraction), guided by masks of known + * foreground and known background samples. + *

+ * Samples in neither mask are of unknown affiliation; the connected + * components of the result containing known foreground samples are always + * kept. This is equivalent to the trimap variant of {@code segment.siox} + * with confidence 1 for foreground, 0 for background and 0.5 for unknown + * samples. Unknown samples are classified by comparing their CIELAB color + * to color signatures of the known background and foreground, followed by + * smoothing and connected component filtering. The image may have any number + * of dimensions; the original algorithm is 2D. + *

+ *

+ * SIOX is described in G. Friedland, K. Jantz and R. Rojas, "SIOX: Simple + * Interactive Object Extraction in Still Images", Proceedings of the Seventh + * IEEE International Symposium on Multimedia (ISM 2005), pp. 253–260, + * doi:10.1109/ISM.2005.106. + * This op is a translation of the SIOX reference implementation of the + * Fiji + * Siox_Segmentation project, Copyright 2005, 2006 by Gerald Friedland, + * Kristian Jantz and Lars Knipping, licensed under the Apache License, Version + * 2.0; see {@link Siox} for details and changes. + *

+ *

+ * The original SIOX license notice follows. + *

+ * + *
+ * Copyright 2005, 2006 by Gerald Friedland, Kristian Jantz and Lars Knipping
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * 
+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @author Gerald Friedland + * @author Kristian Jantz + * @author Lars Knipping + * @author Ignacio Arganda-Carreras + * @param type of the masks + * @implNote op names='segment.siox' + */ +public class SioxMasks> implements + Functions.Arity5, RandomAccessibleInterval, RandomAccessibleInterval, Integer, Double, RandomAccessibleInterval> +{ + + @OpDependency(name = "create.img") + private BiFunction> imgCreator; + + /** + * Segments the input image using SIOX. + * + * @param image the color image to segment + * @param foreground mask of the known foreground samples, with the same + * dimensions as the image + * @param background mask of the known background samples, with the same + * dimensions as the image, which must not overlap the foreground + * @param smoothness the number of smoothing steps in the post-processing + * (default 6); larger values yield smoother outlines + * @param sizeFactorToKeep which connected components to keep (default 0): + * if 0, only those containing known foreground; otherwise + * also the largest one and every one whose size is at least the + * largest size divided by sizeFactorToKeep (4 corresponds to the + * multiple-components option of the Fiji plugin) + * @return the foreground mask + */ + @Override + public RandomAccessibleInterval apply( // + final RandomAccessibleInterval image, // + final RandomAccessibleInterval foreground, // + final RandomAccessibleInterval background, // + @Nullable final Integer smoothness, // + @Nullable final Double sizeFactorToKeep // + ) { + final var output = imgCreator.apply(image, new BitType()); + Siox.segment(image, foreground, background, smoothness, + sizeFactorToKeep, output); + return output; + } + +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/segment/srm/DefaultStatisticalRegionMerging.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/segment/srm/DefaultStatisticalRegionMerging.java new file mode 100644 index 000000000..68ffcc3d7 --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/segment/srm/DefaultStatisticalRegionMerging.java @@ -0,0 +1,150 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.segment.srm; + +import java.util.function.BiFunction; + +import net.imglib2.Dimensions; +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.roi.labeling.ImgLabeling; +import net.imglib2.type.numeric.RealType; +import net.imglib2.type.numeric.integer.IntType; +import net.imglib2.view.Views; + +import org.scijava.function.Functions; +import org.scijava.ops.spi.Nullable; +import org.scijava.ops.spi.OpDependency; + +/** + * Segments an n-dimensional image into homogeneous regions using Statistical + * Region Merging (SRM), producing a labeling in which every sample belongs to + * exactly one region. + *

+ * SRM is described in R. Nock and F. Nielsen, "Statistical Region Merging", + * IEEE Transactions on Pattern Analysis and Machine Intelligence + * 26(11):1452–1458 (2004), + * doi:10.1109/TPAMI.2004.110. + * Starting from one region per sample, all pairs of face-adjacent samples are + * visited in ascending order of their intensity difference, and the regions + * of the two samples are merged whenever their mean intensities differ by + * less than a statistical bound controlled by the complexity parameter Q. + *

+ *

+ * This op is an n-dimensional translation of the {@code SRM_} plugin of the + * Fiji + * Statistical_Region_Merging project, Copyright (C) 2009 - 2013 Johannes + * Schindelin, distributed under the Simplified (2-clause) BSD License. Unlike + * the original, which requires 8-bit images, it accepts any {@link RealType}: + * intensities are mapped linearly from the value range of the image + * {@code [min, max]} onto {@code [0, g - 1]}, and only the ordering of the + * neighbor pairs is quantized (into g buckets). For an 8-bit image spanning + * the full range 0–255, the result is identical to that of the plugin. + *

+ *

+ * Every sample is labeled: regions are labeled {@code 1, 2, 3, ...} in order + * of their first sample in flat iteration order, i.e. the region index image + * of the original plugin plus one. + *

+ *

+ * The original Statistical_Region_Merging license notice follows. + *

+ * + *
+ * Copyright (C) 2009 - 2013 Johannes Schindelin.
+ *
+ * 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.
+ *
+ * The views and conclusions contained in the software and documentation are
+ * those of the authors and should not be interpreted as representing official
+ * policies, either expressed or implied, of any organization.
+ * 
+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @author Johannes Schindelin + * @param input type + * @implNote op names='segment.statisticalRegionMerging' + */ +public class DefaultStatisticalRegionMerging> implements + Functions.Arity3, Double, Integer, ImgLabeling> +{ + + @OpDependency(name = "create.imgLabeling") + private BiFunction> imgLabelingCreator; + + /** + * Segments the input image using Statistical Region Merging. + * + * @param input the image to segment, without NaN + * @param q the complexity parameter Q, which must be positive (default 25); + * larger values yield more, smaller regions + * @param g the number of intensity levels g onto which the input + * values are mapped (default 256); it determines the scale of the + * merging predicate and the number of buckets used to sort the + * neighbor pairs + * @return a labeling assigning each sample its region label, from 1 + */ + @Override + public ImgLabeling apply( // + final RandomAccessibleInterval input, // + @Nullable Double q, // + @Nullable Integer g // + ) { + if (q == null) q = StatisticalRegionMerging.DEFAULT_Q; + if (g == null) g = StatisticalRegionMerging.DEFAULT_G; + final var srm = StatisticalRegionMerging.run(input, q, g); + final var output = imgLabelingCreator.apply(input, new IntType()); + final var c = Views.flatIterable(output).cursor(); + for (final var label : srm.labels) { + c.next().add(label + 1); + } + return output; + } + +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/segment/srm/StatisticalRegionMerging.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/segment/srm/StatisticalRegionMerging.java new file mode 100644 index 000000000..c7cb1d385 --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/segment/srm/StatisticalRegionMerging.java @@ -0,0 +1,307 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.segment.srm; + +import java.util.Arrays; + +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.type.numeric.RealType; +import net.imglib2.view.Views; + +/** + * Core implementation of Statistical Region Merging (SRM), shared by the + * {@code segment.statisticalRegionMerging} ops. + *

+ * The algorithm is described in R. Nock and F. Nielsen, "Statistical Region + * Merging", IEEE Transactions on Pattern Analysis and Machine Intelligence + * 26(11):1452–1458 (2004), + * doi:10.1109/TPAMI.2004.110. + *

+ *

+ * This is an n-dimensional translation of the {@code SRM_} plugin of the + * Fiji + * Statistical_Region_Merging project, Copyright (C) 2009 - 2013 Johannes + * Schindelin, distributed under the Simplified (2-clause) BSD License. The + * merge order, the relaxed merging predicate and the region numbering are + * those of the original plugin; see {@link #run} for the generalizations. + *

+ *

+ * The original Statistical_Region_Merging license notice follows. + *

+ * + *
+ * Copyright (C) 2009 - 2013 Johannes Schindelin.
+ *
+ * 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.
+ *
+ * The views and conclusions contained in the software and documentation are
+ * those of the authors and should not be interpreted as representing official
+ * policies, either expressed or implied, of any organization.
+ * 
+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @author Johannes Schindelin + */ +final class StatisticalRegionMerging { + + /** Default value of the complexity parameter Q. */ + static final double DEFAULT_Q = 25; + + /** Default number of intensity levels g. */ + static final int DEFAULT_G = 256; + + /** Region index (0-based) of each sample, in flat iteration order. */ + final int[] labels; + + /** Mean intensity (in the units of the input) of each region. */ + final double[] means; + + private StatisticalRegionMerging(final int[] labels, final double[] means) { + this.labels = labels; + this.means = means; + } + + /** Number of regions found. */ + int regionCount() { + return means.length; + } + + /** + * Runs Statistical Region Merging on the given image. + *

+ * The original plugin works on 8-bit images, i.e. g = 256 intensity levels. + * To support arbitrary {@link RealType}s, the intensities are mapped + * linearly from the image's actual value range {@code [min, max]} onto + * {@code [0, g - 1]}; all statistics (the merging predicate and the region + * means) are computed on these real-valued, unquantized intensities. Only the + * ordering of the neighbor pairs is quantized: the pairs are bucket-sorted + * into {@code g} buckets by the integer part of their scaled intensity + * difference. For an 8-bit image whose values span the full range 0-255 and + * g = 256, this reproduces the original plugin. + *

+ *

+ * Neighbors are the 2n face-adjacent samples (4-connectivity in 2D, + * 6-connectivity in 3D). The pair connecting a sample at flat index + * {@code i} to its successor along axis {@code d} has id {@code n * i + d}; + * within each bucket, pairs are processed in ascending id order, as in the + * original. + *

+ * + * @param input the image to segment + * @param q the complexity parameter Q; larger values yield more regions + * @param g the number of intensity levels + * @return the segmentation result + */ + static > StatisticalRegionMerging run( + final RandomAccessibleInterval input, final double q, final int g) + { + if (!(q > 0)) throw new IllegalArgumentException("Q must be positive: " + + q); + if (g < 2) throw new IllegalArgumentException("g must be at least 2: " + + g); + + final var n = input.numDimensions(); + final var dims = input.dimensionsAsLongArray(); + final var size = Views.iterable(input).size(); + if (n == 0 || size == 0) { + return new StatisticalRegionMerging(new int[0], new double[0]); + } + if ((long) n * size > Integer.MAX_VALUE - 8) { + throw new IllegalArgumentException("Image too large for SRM: " + size + + " samples in " + n + " dimensions"); + } + final var pixelCount = (int) size; + + // Note: these are the strides of flat iteration order, first axis fastest. + final var strides = new int[n]; + strides[0] = 1; + for (var d = 1; d < n; d++) { + strides[d] = strides[d - 1] * (int) dims[d - 1]; + } + + // Read the input values and determine their range. + final var values = new double[pixelCount]; + var min = Double.POSITIVE_INFINITY; + var max = Double.NEGATIVE_INFINITY; + final var c = Views.flatIterable(input).cursor(); + for (var i = 0; i < pixelCount; i++) { + final var v = c.next().getRealDouble(); + if (Double.isNaN(v)) { + throw new IllegalArgumentException("NaN values are not supported"); + } + values[i] = v; + if (v < min) min = v; + if (v > max) max = v; + } + final var scale = max > min ? (g - 1) / (max - min) : 0; + + // Initialize one region per sample, with intensities scaled to [0, g-1]. + final var average = new double[pixelCount]; + final var count = new int[pixelCount]; + // Note: a negative regionIndex value encodes -1 - parent. + final var regionIndex = new int[pixelCount]; + for (var i = 0; i < pixelCount; i++) { + average[i] = (values[i] - min) * scale; + count[i] = 1; + regionIndex[i] = i; + } + + // Bucket-sort all neighbor pairs by their intensity difference. + // Note: iterating backwards and prepending to each bucket's linked list + // leaves each list in ascending order of pair id. + final var nextNeighbor = new int[n * pixelCount]; + final var neighborBucket = new int[g]; + Arrays.fill(neighborBucket, -1); + final var pos = new long[n]; + for (var d = 0; d < n; d++) + pos[d] = dims[d] - 1; + for (var index = pixelCount - 1; index >= 0; index--) { + for (var d = n - 1; d >= 0; d--) { + if (pos[d] >= dims[d] - 1) continue; + final var diff = Math.abs(values[index] - values[index + + strides[d]]) * scale; + final var bucket = (int) Math.min(g - 1, diff); + final var pair = n * index + d; + nextNeighbor[pair] = neighborBucket[bucket]; + neighborBucket[bucket] = pair; + } + // Move to the previous position in flat iteration order. + for (var d = 0; d < n; d++) { + if (--pos[d] >= 0) break; + pos[d] = dims[d] - 1; + } + } + + // Merge regions, visiting pairs in ascending order of intensity + // difference. As in the original, delta = 1 / (6 |I|) and the relaxed + // predicate is used, since the paper's exact bound is more prone to + // oversegmenting. + final var factor = (double) g * g / 2 / q; + final var logDelta = 2 * Math.log(6.0 * pixelCount); + for (final var head : neighborBucket) { + var pair = head; + while (pair >= 0) { + final var p1 = pair / n; + final var p2 = p1 + strides[pair % n]; + final var r1 = root(regionIndex, p1); + final var r2 = root(regionIndex, p2); + if (r1 != r2 && predicate(average, count, r1, r2, g, factor, + logDelta)) + { + merge(average, count, regionIndex, r1, r2); + } + pair = nextNeighbor[pair]; + } + } + + // Consolidate: number the regions in order of their first sample. + // Note: a negative regionIndex always points to a smaller index, which + // has therefore already been consolidated. + final var means = new double[pixelCount]; + var regionCount = 0; + for (var i = 0; i < pixelCount; i++) { + if (regionIndex[i] < 0) { + regionIndex[i] = regionIndex[-1 - regionIndex[i]]; + } + else { + means[regionCount] = scale == 0 ? min : min + average[i] / scale; + regionIndex[i] = regionCount++; + } + } + return new StatisticalRegionMerging(regionIndex, Arrays.copyOf(means, + regionCount)); + } + + /** Finds the root region of sample {@code i}, compressing the path. */ + private static int root(final int[] regionIndex, final int i) { + var r = i; + while (regionIndex[r] < 0) + r = -1 - regionIndex[r]; + // Note: path compression speeds up lookups and leaves roots unchanged. + var j = i; + while (regionIndex[j] < 0) { + final var next = -1 - regionIndex[j]; + regionIndex[j] = -1 - r; + j = next; + } + return r; + } + + /** + * The relaxed merging predicate of the original plugin: should regions + * {@code r1} and {@code r2} be merged? + */ + private static boolean predicate(final double[] average, final int[] count, + final int r1, final int r2, final int g, final double factor, + final double logDelta) + { + final var difference = average[r1] - average[r2]; + final var c1 = count[r1]; + final var c2 = count[r2]; + final var log1 = Math.log(1 + c1) * Math.min(g, c1); + final var log2 = Math.log(1 + c2) * Math.min(g, c2); + return difference * difference < .1 * factor * ((log1 + logDelta) / c1 + + (log2 + logDelta) / c2); + } + + /** Merges two root regions, keeping the smaller index as the new root. */ + private static void merge(final double[] average, final int[] count, + final int[] regionIndex, final int r1, final int r2) + { + final var mergedCount = count[r1] + count[r2]; + final var mergedAverage = (average[r1] * count[r1] + average[r2] * + count[r2]) / mergedCount; + final var lo = Math.min(r1, r2); + final var hi = Math.max(r1, r2); + average[lo] = mergedAverage; + count[lo] = mergedCount; + regionIndex[hi] = -1 - lo; + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/segment/srm/StatisticalRegionMergingMeans.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/segment/srm/StatisticalRegionMergingMeans.java new file mode 100644 index 000000000..7f5022eac --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/segment/srm/StatisticalRegionMergingMeans.java @@ -0,0 +1,144 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.segment.srm; + +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.type.numeric.RealType; +import net.imglib2.util.Intervals; +import net.imglib2.view.Views; + +import org.scijava.function.Computers; +import org.scijava.ops.spi.Nullable; + +/** + * Segments an n-dimensional image into homogeneous regions using Statistical + * Region Merging (SRM), replacing each sample by the mean intensity of its + * region. This corresponds to the "showAverages" output of the original + * plugin; use the {@code ImgLabeling}-producing + * {@code segment.statisticalRegionMerging} op ({@link + * DefaultStatisticalRegionMerging}) to obtain the regions themselves. + *

+ * SRM is described in R. Nock and F. Nielsen, "Statistical Region Merging", + * IEEE Transactions on Pattern Analysis and Machine Intelligence + * 26(11):1452–1458 (2004), + * doi:10.1109/TPAMI.2004.110. + *

+ *

+ * This op is an n-dimensional translation of the {@code SRM_} plugin of the + * Fiji + * Statistical_Region_Merging project, Copyright (C) 2009 - 2013 Johannes + * Schindelin, distributed under the Simplified (2-clause) BSD License. Unlike + * the original, which requires 8-bit images, it accepts any {@link RealType}: + * intensities are mapped linearly from the value range of the image + * {@code [min, max]} onto {@code [0, g - 1]}, and only the ordering of the + * neighbor pairs is quantized (into g buckets). The region means are given in + * the units of the input. For an 8-bit image spanning the full range + * 0–255, the result is identical to that of the plugin. + *

+ *

+ * The original Statistical_Region_Merging license notice follows. + *

+ * + *
+ * Copyright (C) 2009 - 2013 Johannes Schindelin.
+ *
+ * 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.
+ *
+ * The views and conclusions contained in the software and documentation are
+ * those of the authors and should not be interpreted as representing official
+ * policies, either expressed or implied, of any organization.
+ * 
+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @author Johannes Schindelin + * @param input type + * @param output type + * @implNote op names='segment.statisticalRegionMerging' + */ +public class StatisticalRegionMergingMeans, O extends RealType> + implements + Computers.Arity3, Double, Integer, RandomAccessibleInterval> +{ + + /** + * Segments the input image using Statistical Region Merging, writing the + * mean intensity of the region of each sample into the output. + * + * @param input the image to segment, without NaN + * @param q the complexity parameter Q, which must be positive (default 25); + * larger values yield more, smaller regions + * @param g the number of intensity levels g onto which the input + * values are mapped (default 256); it determines the scale of the + * merging predicate and the number of buckets used to sort the + * neighbor pairs + * @param output the image of region means, of the same dimensions as the + * input; a floating-point type is recommended + */ + @Override + public void compute( // + final RandomAccessibleInterval input, // + @Nullable Double q, // + @Nullable Integer g, // + final RandomAccessibleInterval output // + ) { + if (!Intervals.equalDimensions(input, output)) { + throw new IllegalArgumentException( + "Input and output must have the same dimensions"); + } + if (q == null) q = StatisticalRegionMerging.DEFAULT_Q; + if (g == null) g = StatisticalRegionMerging.DEFAULT_G; + final var srm = StatisticalRegionMerging.run(input, q, g); + final var c = Views.flatIterable(output).cursor(); + for (final var label : srm.labels) { + c.next().setReal(srm.means[label]); + } + } + +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/segment/watershed/CompactWatershed.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/segment/watershed/CompactWatershed.java new file mode 100644 index 000000000..6adf029a1 --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/segment/watershed/CompactWatershed.java @@ -0,0 +1,136 @@ +/* #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.segment.watershed; + +import java.util.ArrayList; +import java.util.function.BiFunction; + +import net.imglib2.Dimensions; +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.algorithm.neighborhood.Shape; +import net.imglib2.roi.labeling.ImgLabeling; +import net.imglib2.type.BooleanType; +import net.imglib2.type.numeric.RealType; +import net.imglib2.type.numeric.integer.IntType; + +import org.scijava.function.Functions; +import org.scijava.ops.spi.Nullable; +import org.scijava.ops.spi.OpDependency; + +/** + * Compact watershed: a marker-controlled watershed whose regions are pushed + * towards compact shapes, in any number of dimensions. + *

+ * The image is flooded from the markers as in the classical marker-controlled + * watershed (F. Meyer, "Un algorithme optimal pour la ligne de partage des + * eaux", 8e congres AFCET, 1991), except that the flooding priority of a pixel + * is its value plus {@code compactness} times its Euclidean distance (in + * pixels) to the marker pixel its flooding path started from, as proposed by P. + * Neubert and P. Protzel, "Compact watershed and preemptive SLIC: On improving + * trade-offs of superpixel segmentation algorithms", ICPR 2014. A compactness + * of 0 gives the classical watershed; as it grows, the result tends to the + * Voronoi partition of the markers. Pixels of equal priority are flooded in the + * order in which they were reached. + *

+ *

+ * Unlike {@code image.watershed}, which decides the label of a pixel only when + * it is flooded, each queued pixel here carries the label (and marker pixel) of + * the region which reached it, which is what makes a distance-dependent + * priority possible. Also unlike {@code image.watershed}, which labels its + * watershed lines -1 when drawing them and otherwise merges them into a + * neighboring region, the lines are left unlabeled unless a + * {@code watershedLabel} is given; the same holds for + * {@code segment.extendedMinimaWatershed} and + * {@code segment.distanceTransformWatershed}. + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @param element type of the input image + * @param element type of the mask + * @implNote op names='segment.compactWatershed' + */ +public class CompactWatershed, B extends BooleanType> + implements + Functions.Arity7, ImgLabeling, Double, Shape, Boolean, RandomAccessibleInterval, Integer, ImgLabeling> +{ + + @OpDependency(name = "create.imgLabeling") + private BiFunction> labelingCreator; + + /** + * Computes the compact watershed of an image. + * + * @param input the relief to flood, typically a gradient image, without NaN + * @param markers the markers, of the same dimensions as {@code input}, with + * at most one label per pixel; each label defines one region + * @param compactness the (non-negative) weight of the distance to the marker + * in the flooding priority, in intensity units per pixel; 0 gives + * the classical marker-controlled watershed + * @param connectivity the neighborhood defining which pixels are adjacent, + * such as a {@code DiamondShape} of radius 1 for face connectivity + * (4-connectivity in 2D, 6-connectivity in 3D) or a + * {@code RectangleShape} of span 1 for full connectivity + * (8-connectivity in 2D, 26-connectivity in 3D). Defaults to full + * connectivity. + * @param drawWatersheds if true, pixels where two regions meet form + * watershed lines, which get the {@code watershedLabel} (or no + * label); otherwise every reachable pixel gets the label of a + * region. Defaults to true. + * @param mask a binary image, of the same dimensions as {@code input}, + * restricting the flooding to its true pixels; markers outside it + * are ignored. Defaults to the whole image. + * @param watershedLabel the label to give the watershed line pixels when + * {@code drawWatersheds} is true, such as 0 or -1; it must not be + * the label of a region. Ignored when {@code drawWatersheds} is + * false. Defaults to none: watershed lines are left unlabeled. + * @return a labeling with the label of the marker of each region; + * watershed lines have the {@code watershedLabel} (or no label), and + * pixels outside the mask have no label + */ + @Override + public ImgLabeling apply( // + final RandomAccessibleInterval input, // + final ImgLabeling markers, // + final Double compactness, // + @Nullable final Shape connectivity, // + @Nullable final Boolean drawWatersheds, // + @Nullable final RandomAccessibleInterval mask, // + @Nullable final Integer watershedLabel // + ) { + final var grid = new Flooding(input, connectivity); + final var ids = new ArrayList(); + final var labels = grid.flood(grid.values(input), grid.markers(markers, + ids), grid.flags(mask), compactness, drawWatersheds == null || + drawWatersheds); + final var output = labelingCreator.apply(input, new IntType()); + grid.write(labels, ids, watershedLabel, output); + return output; + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/segment/watershed/DistanceTransformWatershed.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/segment/watershed/DistanceTransformWatershed.java new file mode 100644 index 000000000..cd37c8ab1 --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/segment/watershed/DistanceTransformWatershed.java @@ -0,0 +1,133 @@ +/* #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.segment.watershed; + +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.algorithm.neighborhood.Shape; +import net.imglib2.img.array.ArrayImgs; +import net.imglib2.roi.labeling.ImgLabeling; +import net.imglib2.type.BooleanType; +import net.imglib2.type.numeric.integer.IntType; +import net.imglib2.type.numeric.real.FloatType; +import net.imglib2.view.Views; + +import org.scijava.function.Computers; +import org.scijava.function.Functions; +import org.scijava.ops.spi.Nullable; +import org.scijava.ops.spi.OpDependency; + +/** + * Distance transform watershed: splits touching objects of a binary image, in + * any number of dimensions. + *

+ * The exact Euclidean distance transform of the foreground (see + * {@code image.distanceTransform}) is negated, so that the centers of the + * objects become basins, and segmented by an extended-minima watershed (see + * {@code segment.extendedMinimaWatershed}) restricted to the foreground. Two + * touching objects are separated if the distance at the neck between them is + * lower than the distance at the center of the smaller object by more than + * {@code dynamic}. + *

+ *

+ * This differs from the binary variant of {@code image.watershed}, which + * smooths the inverted distance transform with a Gaussian filter and floods + * from all of its regional minima: there, the amount of splitting is controlled + * only indirectly by the Gaussian sigma (which also blurs the shape of the + * relief and thus moves the separating lines), whereas here it is controlled + * directly by the depth of the necks in distance units, and the distances + * themselves are not altered. See P. Soille, Morphological + * Image Analysis, 2nd ed., Springer, 2003, section 9.4. + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @param element type of the input image + * @implNote op names='segment.distanceTransformWatershed' + */ +public class DistanceTransformWatershed> implements + Functions.Arity6, Double, Shape, Boolean, double[], Integer, ImgLabeling> +{ + + @OpDependency(name = "image.distanceTransform") + private Computers.Arity1, RandomAccessibleInterval> distanceTransform; + + @OpDependency(name = "image.distanceTransform") + private Computers.Arity2, double[], RandomAccessibleInterval> calibratedDistanceTransform; + + @OpDependency(name = "segment.extendedMinimaWatershed") + private Functions.Arity7, Double, Shape, Boolean, RandomAccessibleInterval, Double, Integer, ImgLabeling> watershed; + + /** + * Computes the distance transform watershed of a binary image. + * + * @param input the binary image whose foreground objects to split + * @param dynamic the (non-negative) minimal depth, in distance units, of the + * distance basin of an object for it to be split from its + * neighbors. Defaults to 1. + * @param connectivity the neighborhood defining which pixels are adjacent, + * such as a {@code DiamondShape} of radius 1 for face connectivity + * (4-connectivity in 2D, 6-connectivity in 3D) or a + * {@code RectangleShape} of span 1 for full connectivity + * (8-connectivity in 2D, 26-connectivity in 3D). Defaults to full + * connectivity. + * @param drawWatersheds if true, pixels where two objects meet form + * watershed lines separating them, which get the + * {@code watershedLabel} (or no label); otherwise every foreground + * pixel gets the label of an object. Defaults to true. + * @param calibration the pixel spacing along each dimension, used by the + * distance transform. Defaults to 1 along every dimension. + * @param watershedLabel the label to give the watershed line pixels when + * {@code drawWatersheds} is true, such as 0 or -1; it must not be + * the label of a region. Ignored when {@code drawWatersheds} is + * false. Defaults to none: watershed lines are left unlabeled. + * @return a labeling with one label per object, numbered from 1; watershed + * lines have the {@code watershedLabel} (or no label), and the + * background has no label + */ + @Override + public ImgLabeling apply( // + final RandomAccessibleInterval input, // + @Nullable final Double dynamic, // + @Nullable final Shape connectivity, // + @Nullable final Boolean drawWatersheds, // + @Nullable final double[] calibration, // + @Nullable final Integer watershedLabel // + ) { + // Note: The distances share the input's interval, so that the watershed + // output does too. + final RandomAccessibleInterval distances = Views.translate( + ArrayImgs.floats(input.dimensionsAsLongArray()), input.minAsLongArray()); + if (calibration == null) distanceTransform.compute(input, distances); + else calibratedDistanceTransform.compute(input, calibration, distances); + for (final var t : Views.iterable(distances)) + t.set(-t.get()); + return watershed.apply(distances, dynamic == null ? 1.0 : dynamic, + connectivity, drawWatersheds, input, 0.0, watershedLabel); + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/segment/watershed/ExtendedMinimaWatershed.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/segment/watershed/ExtendedMinimaWatershed.java new file mode 100644 index 000000000..1a528e7a5 --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/segment/watershed/ExtendedMinimaWatershed.java @@ -0,0 +1,167 @@ +/* #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.segment.watershed; + +import java.util.function.BiFunction; + +import net.imglib2.Dimensions; +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.algorithm.neighborhood.RectangleShape; +import net.imglib2.algorithm.neighborhood.Shape; +import net.imglib2.img.array.ArrayImgs; +import net.imglib2.roi.labeling.ImgLabeling; +import net.imglib2.type.BooleanType; +import net.imglib2.type.logic.BitType; +import net.imglib2.type.numeric.RealType; +import net.imglib2.type.numeric.integer.IntType; +import net.imglib2.type.numeric.real.DoubleType; + +import org.scijava.function.Computers; +import org.scijava.function.Functions; +import org.scijava.ops.spi.Nullable; +import org.scijava.ops.spi.OpDependency; + +/** + * Morphological segmentation: a marker-controlled watershed seeded with the + * extended minima of the image, in any number of dimensions. + *

+ * The extended minima of the relief (the minima whose dynamic exceeds + * {@code dynamic}, see {@code morphology.extendedMinima}) are labeled as + * connected components, imposed as the only minima of the relief (see + * {@code morphology.imposeMinima}), and the result is flooded from them (see + * {@code segment.compactWatershed}). The dynamic thus controls which basins are + * deep enough to become regions of their own, which is a much more robust + * criterion than the depth of single minima. This is the classical + * marker-controlled watershed pipeline of F. Meyer and S. Beucher, + * "Morphological segmentation", Journal of Visual Communication and Image + * Representation 1(1), 21-46, 1990; see also P. Soille, Morphological + * Image Analysis, 2nd ed., Springer, 2003, chapter 9. It is typically + * applied to a gradient image (see {@code morphology.gradient}) to segment + * regions of homogeneous intensity, or to an image whose objects are dark. + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @param element type of the input image + * @param element type of the mask + * @implNote op names='segment.extendedMinimaWatershed' + */ +public class ExtendedMinimaWatershed, B extends BooleanType> + implements + Functions.Arity7, Double, Shape, Boolean, RandomAccessibleInterval, Double, Integer, ImgLabeling> +{ + + @OpDependency(name = "morphology.extendedMinima") + private Computers.Arity3, Double, Shape, RandomAccessibleInterval> extendedMinima; + + @OpDependency(name = "morphology.imposeMinima") + private Computers.Arity3, RandomAccessibleInterval, Shape, RandomAccessibleInterval> imposeMinima; + + @OpDependency(name = "create.imgLabeling") + private BiFunction> labelingCreator; + + /** + * Computes the extended-minima watershed of an image. + * + * @param input the relief to segment, typically a gradient image, without + * NaN + * @param dynamic the (non-negative) minimal dynamic of the minima seeding a + * region: the depth of a basin below the lowest saddle leading to a + * deeper basin, in intensity units + * @param connectivity the neighborhood defining which pixels are adjacent, + * such as a {@code DiamondShape} of radius 1 for face connectivity + * (4-connectivity in 2D, 6-connectivity in 3D) or a + * {@code RectangleShape} of span 1 for full connectivity + * (8-connectivity in 2D, 26-connectivity in 3D). Defaults to full + * connectivity. + * @param drawWatersheds if true, pixels where two regions meet form + * watershed lines, which get the {@code watershedLabel} (or no + * label); otherwise every reachable pixel gets the label of a + * region. Defaults to true. + * @param mask a binary image, of the same dimensions as {@code input}, + * restricting the segmentation to its true pixels. Defaults to the + * whole image. + * @param compactness the (non-negative) weight of the distance to the + * marker in the flooding priority (see + * {@code segment.compactWatershed}). Defaults to 0, the classical + * watershed. + * @param watershedLabel the label to give the watershed line pixels when + * {@code drawWatersheds} is true, such as 0 or -1; it must not be + * the label of a region. Ignored when {@code drawWatersheds} is + * false. Defaults to none: watershed lines are left unlabeled. + * @return a labeling with one label per region, numbered from 1 in raster + * order of the extended minima; watershed lines have the + * {@code watershedLabel} (or no label), and pixels outside the mask + * have no label + */ + @Override + public ImgLabeling apply( // + final RandomAccessibleInterval input, // + final Double dynamic, // + @Nullable Shape connectivity, // + @Nullable final Boolean drawWatersheds, // + @Nullable final RandomAccessibleInterval mask, // + @Nullable final Double compactness, // + @Nullable final Integer watershedLabel // + ) { + if (connectivity == null) connectivity = new RectangleShape(1, false); + final var grid = new Flooding(input, connectivity); + final var inside = grid.flags(mask); + final var values = grid.values(input); + + // Note: Raising the pixels outside the mask to the highest value inside + // it keeps them from creating minima or connecting basins. + if (inside != null) { + var max = Double.NEGATIVE_INFINITY; + for (var i = 0; i < grid.size; i++) + if (inside[i]) max = Math.max(max, values[i]); + for (var i = 0; i < grid.size; i++) + if (!inside[i]) values[i] = max; + } + final var relief = ArrayImgs.doubles(values, input + .dimensionsAsLongArray()); + + final var minima = ArrayImgs.bits(input.dimensionsAsLongArray()); + extendedMinima.compute(relief, dynamic, connectivity, minima); + final var imposed = ArrayImgs.doubles(input.dimensionsAsLongArray()); + imposeMinima.compute(relief, minima, connectivity, imposed); + + final var markerFlags = grid.flags(minima); + if (inside != null) { + for (var i = 0; i < grid.size; i++) + markerFlags[i] &= inside[i]; + } + final var labels = grid.flood(grid.values(imposed), grid.components( + markerFlags), inside, compactness == null ? 0 : compactness, + drawWatersheds == null || drawWatersheds); + final var output = labelingCreator.apply(input, new IntType()); + grid.write(labels, null, watershedLabel, output); + return output; + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/segment/watershed/Flooding.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/segment/watershed/Flooding.java new file mode 100644 index 000000000..6145b36dd --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/segment/watershed/Flooding.java @@ -0,0 +1,467 @@ +/* #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.segment.watershed; + +import java.util.ArrayDeque; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; + +import net.imglib2.Dimensions; +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.algorithm.neighborhood.Shape; +import net.imglib2.roi.labeling.ImgLabeling; +import net.imglib2.roi.labeling.LabelingType; +import net.imglib2.type.BooleanType; +import net.imglib2.type.numeric.RealType; +import net.imglib2.type.numeric.integer.IntType; +import net.imglib2.util.Intervals; +import net.imglib2.view.Views; + +import org.scijava.ops.image.util.Neighborhoods; + +/** + * Marker-controlled watershed by priority flooding on a flattened + * n-dimensional pixel grid (first dimension varying fastest), shared by the + * ops of this package. + *

+ * The flooding follows F. Meyer, "Un algorithme optimal pour la ligne de + * partage des eaux", 8e congres AFCET, 1991: starting from the markers, the + * queued pixel of lowest priority is repeatedly given the label of the region + * which queued it, and queues its unlabeled neighbors in turn. Each queue entry + * carries its label and the marker pixel its flooding path started from, so + * that the priority can include the compactness term of P. Neubert and P. + * Protzel, "Compact watershed and preemptive SLIC: On improving trade-offs of + * superpixel segmentation algorithms", ICPR 2014: the value of the pixel plus + * the compactness times its Euclidean distance (in pixels) to that marker + * pixel. Entries of equal priority are processed in the order they were + * queued. + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +final class Flooding { + + /** Internal label of watershed line pixels. */ + static final int LINE = -1; + + /** Internal label of pixels outside the mask. */ + private static final int OUTSIDE = -2; + + /** Number of dimensions. */ + private final int n; + + /** Size of each dimension. */ + private final int[] dims; + + /** Total number of pixels. */ + final int size; + + /** Neighbor offsets (symmetric, excluding the origin). */ + private final int[][] offsets; + + /** Linear index step of each neighbor offset. */ + private final int[] steps; + + /** + * Creates a grid with the dimensions of the given interval. + * + * @param interval the image whose dimensions to use + * @param connectivity the neighborhood defining which pixels are adjacent, + * or null for full connectivity + */ + Flooding(final Dimensions interval, final Shape connectivity) { + n = interval.numDimensions(); + if (Intervals.numElements(interval) > Integer.MAX_VALUE - 8) { + throw new IllegalArgumentException( + "Images with more than 2^31 pixels are not supported"); + } + dims = new int[n]; + final var strides = new int[n]; + var s = 1; + for (var d = 0; d < n; d++) { + dims[d] = (int) interval.dimension(d); + strides[d] = s; + s *= dims[d]; + } + size = s; + offsets = Neighborhoods.neighborOffsets(connectivity, n); + steps = new int[offsets.length]; + for (var k = 0; k < offsets.length; k++) { + for (var d = 0; d < n; d++) + steps[k] += offsets[k][d] * strides[d]; + } + } + + /** + * Floods an image from markers. + * + * @param values the priority of each pixel (typically its gray level) + * @param markers the internal marker label of each pixel, positive on + * markers and 0 elsewhere + * @param mask whether each pixel may be labeled, or null for all pixels + * @param compactness the weight of the distance to the marker in the + * priority; 0 for the classical watershed + * @param lines whether to separate the regions by watershed lines + * @return the label of each pixel: the marker label, {@link #LINE} on + * watershed lines, or a non-positive value on pixels which were not + * reached (outside the mask, or cut off by watershed lines) + */ + int[] flood(final double[] values, final int[] markers, final boolean[] mask, + final double compactness, final boolean lines) + { + if (!(compactness >= 0)) { + throw new IllegalArgumentException( + "The compactness must be non-negative"); + } + final var labels = new int[size]; + final var queue = new Queue(); + final var pos = new int[n]; + final var src = new int[n]; + for (var i = 0; i < size; i++) { + if (mask != null && !mask[i]) labels[i] = OUTSIDE; + else if (markers[i] > 0) labels[i] = markers[i]; + } + for (var i = 0; i < size; i++) { + if (labels[i] <= 0) continue; + position(i, pos); + for (var k = 0; k < steps.length; k++) { + if (!contains(pos, k)) continue; + final var j = i + steps[k]; + if (labels[j] != 0) continue; + queue.push(priority(values, j, i, src, compactness), j, labels[i], i); + } + } + while (queue.size > 0) { + queue.pop(); + final var i = queue.index; + if (labels[i] != 0) continue; + final var label = queue.label; + final var source = queue.source; + position(i, pos); + if (lines && touchesOtherLabel(labels, i, pos, label)) { + labels[i] = LINE; + continue; + } + labels[i] = label; + for (var k = 0; k < steps.length; k++) { + if (!contains(pos, k)) continue; + final var j = i + steps[k]; + if (labels[j] != 0) continue; + queue.push(priority(values, j, source, src, compactness), j, label, + source); + } + } + return labels; + } + + /** + * Labels the connected components of a binary image. + * + * @param foreground whether each pixel belongs to the foreground + * @return the label of each pixel: 1, 2, ... on the components (numbered in + * raster order of their first pixel), 0 on the background + */ + int[] components(final boolean[] foreground) { + final var labels = new int[size]; + final var pos = new int[n]; + final var stack = new ArrayDeque(); + var count = 0; + for (var start = 0; start < size; start++) { + if (!foreground[start] || labels[start] != 0) continue; + labels[start] = ++count; + stack.push(start); + while (!stack.isEmpty()) { + final int i = stack.pop(); + position(i, pos); + for (var k = 0; k < steps.length; k++) { + if (!contains(pos, k)) continue; + final var j = i + steps[k]; + if (!foreground[j] || labels[j] != 0) continue; + labels[j] = count; + stack.push(j); + } + } + } + return labels; + } + + /** + * Copies the values of an image into a new array, in flat iteration order. + * + * @param image the image to copy + * @return the values of the image + * @throws IllegalArgumentException if the image contains NaN, which would + * corrupt the order of the flooding + */ + double[] values(final RandomAccessibleInterval> image) { + checkDimensions(image); + final var values = new double[size]; + var i = 0; + for (final var t : Views.flatIterable(image)) { + final var v = t.getRealDouble(); + if (Double.isNaN(v)) { + throw new IllegalArgumentException("NaN values are not supported"); + } + values[i++] = v; + } + return values; + } + + /** + * Copies the values of a binary image into a new array, in flat iteration + * order. + * + * @param image the image to copy, or null + * @return the flags of the image, or null if {@code image} is null + */ + boolean[] flags( + final RandomAccessibleInterval> image) + { + if (image == null) return null; + checkDimensions(image); + final var flags = new boolean[size]; + var i = 0; + for (final var t : Views.flatIterable(image)) + flags[i++] = t.get(); + return flags; + } + + /** + * Copies the labels of a labeling into internal marker labels. + * + * @param labeling the labeling to copy, with at most one label per pixel + * @param ids filled with the label corresponding to each internal marker + * label (the internal label {@code k} standing for + * {@code ids.get(k - 1)}) + * @return the internal marker label of each pixel, 1, 2, ... on labeled + * pixels and 0 elsewhere + */ + int[] markers(final RandomAccessibleInterval> labeling, + final List ids) + { + checkDimensions(labeling); + final var markers = new int[size]; + final var internal = new HashMap(); + var i = 0; + for (final var t : Views.flatIterable(labeling)) { + if (t.size() > 1) { + throw new IllegalArgumentException( + "Markers must have at most one label per pixel"); + } + if (t.size() == 1) { + markers[i] = internal.computeIfAbsent(t.iterator().next(), l -> { + ids.add(l); + return ids.size(); + }); + } + i++; + } + return markers; + } + + /** + * Writes labels into an empty labeling, in flat iteration order. + * + * @param labels the internal label of each pixel; only positive labels are + * written + * @param ids the label corresponding to each internal label, or null to + * write the internal labels themselves + * @param lineLabel the label to write on watershed line pixels, or null to + * leave them unlabeled + * @param labeling the labeling to write into + * @throws IllegalArgumentException if {@code lineLabel} is also the label + * of a region + */ + void write(final int[] labels, final List ids, + final Integer lineLabel, final ImgLabeling labeling) + { + checkDimensions(labeling); + if (lineLabel != null) { + final boolean clash; + if (ids != null) clash = ids.contains(lineLabel); + else clash = lineLabel > 0 && Arrays.stream(labels).anyMatch( + l -> l == lineLabel); + if (clash) { + throw new IllegalArgumentException("The watershed label " + + lineLabel + " is also the label of a region"); + } + } + var i = 0; + for (final var t : Views.flatIterable(labeling)) { + final var label = labels[i++]; + if (label > 0) t.add(ids == null ? label : ids.get(label - 1)); + else if (label == LINE && lineLabel != null) t.add(lineLabel); + } + } + + private double priority(final double[] values, final int j, final int source, + final int[] src, final double compactness) + { + if (compactness == 0) return values[j]; + position(source, src); + var sum = 0.0; + var rem = j; + for (var d = 0; d < n; d++) { + final double diff = rem % dims[d] - src[d]; + rem /= dims[d]; + sum += diff * diff; + } + return values[j] + compactness * Math.sqrt(sum); + } + + private boolean touchesOtherLabel(final int[] labels, final int i, + final int[] pos, final int label) + { + for (var k = 0; k < steps.length; k++) { + if (!contains(pos, k)) continue; + final var other = labels[i + steps[k]]; + if (other > 0 && other != label) return true; + } + return false; + } + + private boolean contains(final int[] pos, final int k) { + final var o = offsets[k]; + for (var d = 0; d < n; d++) { + final var c = pos[d] + o[d]; + if (c < 0 || c >= dims[d]) return false; + } + return true; + } + + private void position(int index, final int[] pos) { + for (var d = 0; d < n; d++) { + pos[d] = index % dims[d]; + index /= dims[d]; + } + } + + private void checkDimensions(final RandomAccessibleInterval image) { + if (image.numDimensions() != n) { + throw new IllegalArgumentException("Expected " + n + + " dimensions but got " + image.numDimensions()); + } + for (var d = 0; d < n; d++) { + if (image.dimension(d) != dims[d]) { + throw new IllegalArgumentException("Image dimensions " + Arrays + .toString(image.dimensionsAsLongArray()) + " do not match " + Arrays + .toString(dims)); + } + } + } + + /** + * A binary min-heap of flooding entries, ordered by priority and then by + * insertion order. {@link #pop()} stores the removed entry in the + * {@code index}, {@code label} and {@code source} fields. + */ + private static final class Queue { + + private double[] priorities = new double[1024]; + private long[] order = new long[1024]; + private int[] indices = new int[1024]; + private int[] labels = new int[1024]; + private int[] sources = new int[1024]; + private long counter; + int size; + int index; + int label; + int source; + + void push(final double priority, final int i, final int l, final int s) { + if (size == priorities.length) { + final var capacity = (int) Math.min(Integer.MAX_VALUE - 8, 2L * size); + if (capacity <= size) { + throw new IllegalStateException("Watershed queue overflow"); + } + priorities = Arrays.copyOf(priorities, capacity); + order = Arrays.copyOf(order, capacity); + indices = Arrays.copyOf(indices, capacity); + labels = Arrays.copyOf(labels, capacity); + sources = Arrays.copyOf(sources, capacity); + } + final var o = counter++; + var c = size++; + while (c > 0) { + final var p = (c - 1) >>> 1; + if (!before(priority, o, priorities[p], order[p])) break; + move(p, c); + c = p; + } + set(c, priority, o, i, l, s); + } + + void pop() { + index = indices[0]; + label = labels[0]; + source = sources[0]; + final var last = --size; + if (last == 0) return; + final var priority = priorities[last]; + final var o = order[last]; + final var i = indices[last]; + final var l = labels[last]; + final var s = sources[last]; + var c = 0; + while (true) { + var child = 2 * c + 1; + if (child >= last) break; + if (child + 1 < last && before(priorities[child + 1], order[child + 1], + priorities[child], order[child])) child++; + if (!before(priorities[child], order[child], priority, o)) break; + move(child, c); + c = child; + } + set(c, priority, o, i, l, s); + } + + private static boolean before(final double p, final long o, final double q, + final long r) + { + return p < q || (p == q && o < r); + } + + private void set(final int c, final double priority, final long o, + final int i, final int l, final int s) + { + priorities[c] = priority; + order[c] = o; + indices[c] = i; + labels[c] = l; + sources[c] = s; + } + + private void move(final int from, final int to) { + set(to, priorities[from], order[from], indices[from], labels[from], + sources[from]); + } + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/stats/entropy/DefaultEntropy.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/stats/entropy/DefaultEntropy.java new file mode 100644 index 000000000..ab8f1b8df --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/stats/entropy/DefaultEntropy.java @@ -0,0 +1,93 @@ +/*- + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.stats.entropy; + +import java.util.function.BiFunction; + +import net.imglib2.histogram.Histogram1d; +import net.imglib2.type.numeric.RealType; + +import org.scijava.ops.spi.Nullable; +import org.scijava.ops.spi.OpDependency; + +/** + * Computes the Shannon entropy, in bits, of the intensity distribution of an + * image: {@code H = -sum_i p_i log2(p_i)}, where {@code p_i} is the fraction + * of samples in bin {@code i} of the histogram computed by + * {@code image.histogram} (bins spaced uniformly between the minimum and the + * maximum value). See Shannon (1948), "A Mathematical Theory of + * Communication", Bell System Technical Journal 27:379-423. + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @param type of the image + * @implNote op names='stats.entropy' + */ +public class DefaultEntropy> implements + BiFunction, Integer, Double> +{ + + @OpDependency(name = "image.histogram") + private BiFunction, Integer, Histogram1d> histogramOp; + + /** + * Computes the Shannon entropy of an image. + * + * @param input the image + * @param bins the number of histogram bins (default 256) + * @return the entropy in bits, between 0 and {@code log2(bins)} + */ + @Override + public Double apply(final Iterable input, @Nullable Integer bins) { + if (bins == null) bins = 256; + final var histogram = histogramOp.apply(input, bins); + return entropy(histogram.toLongArray()); + } + + /** + * Computes the Shannon entropy, in bits, of the given counts. + * + * @param counts the counts of each bin + * @return the entropy of the normalized counts + */ + public static double entropy(final long[] counts) { + long total = 0; + for (final var c : counts) + total += c; + if (total == 0) return 0; + double h = 0; + for (final var c : counts) { + if (c == 0) continue; + final double p = (double) c / total; + h -= p * Math.log(p); + } + return h / Math.log(2); + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/AbstractApplyThresholdImg.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/AbstractApplyThresholdImg.java index b54418109..581212b62 100644 --- a/scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/AbstractApplyThresholdImg.java +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/AbstractApplyThresholdImg.java @@ -29,8 +29,6 @@ package org.scijava.ops.image.threshold; -import java.util.function.Function; - import net.imglib2.RandomAccessibleInterval; import net.imglib2.histogram.Histogram1d; import net.imglib2.type.logic.BitType; @@ -38,12 +36,21 @@ import net.imglib2.util.Util; import org.scijava.function.Computers; +import org.scijava.ops.spi.Nullable; import org.scijava.ops.spi.OpDependency; /** * An abstract base class for Ops using a {@link Histogram1d} to compute a * threshold across an {@link Iterable}. *

+ * The histogram is created by {@link Thresholds#histogram}: integer images get + * one bin per grey level between their minimum and maximum, as in ImageJ's + * Auto_Threshold plugin. A histogram with a single grey level yields that + * level (no foreground). Images with exactly two grey levels are split between + * them (threshold = upper level - 1), whatever the method, as in + * Auto_Threshold. + *

+ *

* Note the use of type parameters {@code I} and {@code J}, allowing * dependencies to be matched on the concrete input types instead of just on * {@link Iterable} @@ -60,28 +67,50 @@ public abstract class AbstractApplyThresholdImg< // T extends RealType, // I extends Iterable, // J extends Iterable // - > implements Computers.Arity1 { - - @OpDependency(name = "image.histogram") - private Function> createHistogramOp; + > implements Computers.Arity3 { @OpDependency(name = "threshold.apply") private Computers.Arity2 applyThresholdOp; /** - * Thresholds {@code input}, storing the result in {@code output}. + * Thresholds {@code input}, storing the result in {@code output}. Pixels + * strictly above the computed threshold are foreground. * * @param input the input dataset + * @param ignoreBlack whether to exclude pixels with the minimum value of the + * type (e.g. 0 for unsigned types) from the histogram, like the + * "Ignore black" option of ImageJ Auto_Threshold (default false) + * @param ignoreWhite whether to exclude pixels with the maximum value of the + * type (e.g. 255 for 8-bit) from the histogram, like the "Ignore + * white" option of ImageJ Auto_Threshold (default false) * @param output the output dataset */ @Override - public void compute(final I input, final J output) { - // Compute the histogram - final var inputHistogram = createHistogramOp.apply(input); - // Compute the threshold value from the histogram + public void compute(final I input, @Nullable Boolean ignoreBlack, + @Nullable Boolean ignoreWhite, final J output) + { + if (ignoreBlack == null) ignoreBlack = false; + if (ignoreWhite == null) ignoreWhite = false; final var threshold = input.iterator().next().createVariable(); - getComputeThresholdOp().compute(inputHistogram, threshold); - // Threshold the image against the computed value + var histogram = Thresholds.histogram(input, null); + // Note: Like Auto_Threshold, detect bilevel images before ignoring. + final var bilevel = Thresholds.bilevelBin(histogram); + if (bilevel >= 0) histogram.getCenterValue(bilevel, threshold); + else { + if (ignoreBlack || ignoreWhite) { + final var black = threshold.getMinValue(); + final var white = threshold.getMaxValue(); + final boolean noBlack = ignoreBlack, noWhite = ignoreWhite; + histogram = Thresholds.histogram(input, v -> { + final var d = v.getRealDouble(); + return !(noBlack && d == black || noWhite && d == white); + }); + } + if (histogram == null) threshold.setReal(threshold.getMaxValue()); + else if (histogram.getBinCount() < 2) histogram.getCenterValue(0, + threshold); + else getComputeThresholdOp().compute(histogram, threshold); + } applyThresholdOp.compute(input, threshold, output); } diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/ApplyLocalThresholdIntegral.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/ApplyLocalThresholdIntegral.java index 4df9b1c99..301bd1bfe 100644 --- a/scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/ApplyLocalThresholdIntegral.java +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/ApplyLocalThresholdIntegral.java @@ -191,7 +191,29 @@ private RandomAccessibleInterval getIntegralImage( final var offsetInterval2 = Views.offsetInterval(extendedInput, expandedInterval); final var img = integralOp.apply(offsetInterval2); - return addLeadingZeros(img); + return addLeadingZeros(cropLeadingZeros(img, offsetInterval2)); + } + + /** + * Crops an integral image to the size of its input. Some integral image ops + * (such as ImgLib2's {@code IntegralImg}) prepend a row of zeros in each + * dimension, while others produce an image of the size of their input. + * + * @param img Integral image + * @param input The input of the integral image + * @return The last pixels of the integral image in each dimension, as many + * as the input has + */ + private static RandomAccessibleInterval cropLeadingZeros( + final RandomAccessibleInterval img, final RandomAccessibleInterval input) + { + if (Intervals.equalDimensions(img, input)) return img; + final var min = Intervals.maxAsLongArray(img); + for (var d = 0; d < min.length; d++) { + min[d] -= input.dimension(d) - 1; + } + return Views.zeroMin(Views.offsetInterval(img, min, input + .dimensionsAsLongArray())); } /** diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/ApplyThresholdMethod.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/ApplyThresholdMethod.java index 16d1991c2..32793f3aa 100644 --- a/scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/ApplyThresholdMethod.java +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/ApplyThresholdMethod.java @@ -67,6 +67,24 @@ protected Computers.Arity1, T> getComputeThresholdOp() { } } + /** + * @implNote op names='threshold.huang2' + */ + public static class Huang2 < // + T extends RealType, // + I extends Iterable, // + J extends Iterable // + > extends AbstractApplyThresholdImg { // + + @OpDependency(name = "threshold.huang2") + private Computers.Arity1, T> computeThresholdOp; + + @Override + protected Computers.Arity1, T> getComputeThresholdOp() { + return computeThresholdOp; + } + } + /** * @implNote op names='threshold.ij1' */ diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/ApplyThresholdMethodLocal.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/ApplyThresholdMethodLocal.java index 2e373d352..f18fa4e45 100644 --- a/scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/ApplyThresholdMethodLocal.java +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/ApplyThresholdMethodLocal.java @@ -29,7 +29,6 @@ package org.scijava.ops.image.threshold; -import java.util.function.Function; import net.imglib2.RandomAccessibleInterval; import net.imglib2.algorithm.neighborhood.Shape; @@ -45,6 +44,16 @@ /** * Ops which compute and apply a local threshold to an image. + *

+ * Each pixel is compared to the threshold of the histogram of its + * neighborhood. The histogram spans the range of the neighborhood. If the + * image has an integer type and the neighborhood spans at most 256 grey + * levels (as on any 8-bit image), the histogram has one bin per grey level, + * as the global threshold methods do; otherwise, it has 256 equally sized + * bins, so the threshold is only resolved to 1/256 of the local range. A + * histogram per grey level would cost time and memory proportional to the + * local range for every pixel (up to 65536 bins per pixel on 16-bit images). + *

* * @author Stefan Helfrich (University of Konstanz) */ @@ -70,6 +79,22 @@ protected Computers.Arity1, T> getComputeThresholdOp() { } } + /** + * @implNote op names='threshold.huang2' + */ + public static class LocalHuang2> extends + AbstractApplyLocalHistogramBasedThreshold + { + + @OpDependency(name = "threshold.huang2") + private Computers.Arity1, T> computeThresholdOp; + + @Override + protected Computers.Arity1, T> getComputeThresholdOp() { + return computeThresholdOp; + } + } + /** * @implNote op names='threshold.ij1' */ @@ -347,9 +372,6 @@ private abstract static class AbstractApplyLocalHistogramBasedThreshold, Shape, OutOfBoundsFactory>, // RandomAccessibleInterval> { - @OpDependency(name = "image.histogram") - private Function, Histogram1d> createHistogramOp; - // TODO: Would be cool if Computers.Arity2 would be matched. @OpDependency(name = "threshold.apply") private Computers.Arity2, T, BitType> applyThresholdOp; @@ -389,11 +411,15 @@ private Computers.Arity2, T, BitType> getThresholdOp() { public void compute(final Iterable inputNeighborhood, final T inputCenterPixel, final BitType output) { - final var histogram = createHistogramOp.apply( - inputNeighborhood); + // Note: one bin per grey level, as for global thresholds, would + // cost up to 65536 bins per pixel on 16-bit images. + final var histogram = Thresholds.histogram(inputNeighborhood, + null, Thresholds.MAX_LOCAL_LEVEL_BINS); final var threshold = inputNeighborhood.iterator().next() .createVariable(); - computeThresholdOp.compute(histogram, threshold); + if (histogram.getBinCount() < 2) histogram.getCenterValue(0, + threshold); + else computeThresholdOp.compute(histogram, threshold); applyThresholdOp.compute(inputCenterPixel, threshold, output); } }; diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/Thresholds.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/Thresholds.java index f1de923a6..2d02fb0b8 100644 --- a/scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/Thresholds.java +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/Thresholds.java @@ -29,6 +29,13 @@ package org.scijava.ops.image.threshold; +import java.util.function.Predicate; + +import net.imglib2.histogram.Histogram1d; +import net.imglib2.histogram.Real1dBinMapper; +import net.imglib2.type.numeric.IntegerType; +import net.imglib2.type.numeric.RealType; + // Ported from Antti Niemisto's HistThresh Matlab toolbox // Relicensed BSD 2-12-13 @@ -104,4 +111,150 @@ public static double D(long[] y, int j) { x += y[i] * i * i * i; return x; } + + /** + * Maximum number of grey levels for which {@link #histogram} uses one bin + * per grey level. + */ + public static final long MAX_LEVEL_BINS = 65536; + + /** + * Number of bins {@link #histogram} uses for non-integer data and for + * integer data with more than {@link #MAX_LEVEL_BINS} grey levels. + */ + public static final int DEFAULT_NUM_BINS = 256; + + /** + * Creates the histogram used by the histogram-based threshold methods. + *

+ * The histogram spans the range {@code [min, max]} of the included values. + * For {@link IntegerType} data with at most {@link #MAX_LEVEL_BINS} grey + * levels in that range, it has exactly one bin per grey level, centered on + * that grey level, as in ImageJ's Auto_Threshold plugin. Otherwise, it has + * {@link #DEFAULT_NUM_BINS} equally sized bins. + *

+ * + * @param values the values to count + * @param include which values to count, or {@code null} to count all values + * @return the histogram, or {@code null} if no value is included + */ + public static > Histogram1d histogram( + final Iterable values, final Predicate include) + { + return histogram(values, include, MAX_LEVEL_BINS); + } + + /** + * Maximum number of grey levels for which the local histogram-based + * threshold methods use one histogram bin per grey level (see + * {@link #histogram(Iterable, Predicate, long)}). + */ + public static final long MAX_LOCAL_LEVEL_BINS = 256; + + /** + * Creates the histogram used by the histogram-based threshold methods, with + * a limit on the number of bins per grey level. + *

+ * The histogram spans the range {@code [min, max]} of the included values. + * For {@link IntegerType} data with at most {@code maxLevelBins} grey levels + * in that range, it has exactly one bin per grey level, centered on that + * grey level. Otherwise, it has {@link #DEFAULT_NUM_BINS} equally sized + * bins. + *

+ * + * @param values the values to count + * @param include which values to count, or {@code null} to count all values + * @param maxLevelBins the maximum number of grey levels with one bin each + * @return the histogram, or {@code null} if no value is included + */ + public static > Histogram1d histogram( + final Iterable values, final Predicate include, + final long maxLevelBins) + { + var min = Double.POSITIVE_INFINITY; + var max = Double.NEGATIVE_INFINITY; + var integer = true; + for (final var v : values) { + if (include != null && !include.test(v)) continue; + integer &= v instanceof IntegerType; + final var d = v.getRealDouble(); + if (d < min) min = d; + if (d > max) max = d; + } + if (min > max) return null; + final Real1dBinMapper mapper; + final var levels = (long) (max - min) + 1; + if (min == max || integer && levels <= maxLevelBins) { + mapper = new Real1dBinMapper<>(min - 0.5, max + 0.5, levels, false); + } + else { + mapper = new Real1dBinMapper<>(min, max, DEFAULT_NUM_BINS, false); + } + final var histogram = new Histogram1d<>(mapper); + for (final var v : values) { + if (include == null || include.test(v)) histogram.increment(v); + } + return histogram; + } + + /** + * Detects a bilevel histogram, i.e. one with exactly two non-empty bins. + * Mirroring ImageJ's Auto_Threshold plugin, the threshold of such a + * histogram is the bin just below the upper non-empty bin, so that exactly + * the pixels of the upper level are foreground. + * + * @param histogram the histogram to check + * @return the threshold bin, or -1 if the histogram is not bilevel + */ + public static long bilevelBin(final Histogram1d histogram) { + var nonEmpty = 0; + var upper = -1L; + for (var i = 0L; i < histogram.getBinCount(); i++) { + if (histogram.frequency(i) == 0) continue; + if (++nonEmpty > 2) return -1; + upper = i; + } + return nonEmpty == 2 ? upper - 1 : -1; + } + + /** + * Computes the mean and the population standard deviation (normalized by + * the number of values {@code n}, not {@code n - 1}) of some values, as + * ImageJ's {@code RankFilters} do for the local thresholds of Fiji's + * Auto_Local_Threshold plugin. + * + * @param values the values + * @return the mean and the population standard deviation + */ + public static double[] meanAndPopulationStdDev( + final Iterable> values) + { + var n = 0L; + var sum = 0.0; + for (final var v : values) { + sum += v.getRealDouble(); + n++; + } + final var mean = sum / n; + var sum2 = 0.0; + for (final var v : values) { + final var d = v.getRealDouble() - mean; + sum2 += d * d; + } + return new double[] { mean, Math.sqrt(sum2 / n) }; + } + + /** + * Converts the sample variance of {@code n} values, as computed by + * {@code stats.integralVariance}, to their population variance. + * + * @param sampleVariance the sample variance + * @param n the number of values + * @return the population variance + */ + public static double populationVariance(final double sampleVariance, + final long n) + { + return n > 1 ? Math.max(0, sampleVariance) * (n - 1) / n : 0; + } } diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/huang/ComputeHuangThreshold.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/huang/ComputeHuangThreshold.java index 51cc38675..979c1855c 100644 --- a/scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/huang/ComputeHuangThreshold.java +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/huang/ComputeHuangThreshold.java @@ -29,18 +29,39 @@ package org.scijava.ops.image.threshold.huang; -import org.scijava.ops.image.threshold.AbstractComputeThresholdHistogram; import net.imglib2.histogram.Histogram1d; import net.imglib2.type.numeric.RealType; -//NB - this plugin adapted from Gabriel Landini's code of his AutoThreshold -//plugin found in Fiji (version 1.14). +import org.scijava.ops.image.threshold.AbstractComputeThresholdHistogram; /** - * Implements Huang's threshold method by Huang {@literal &} Wang. + * Implements Huang's fuzzy thresholding method, which picks the threshold + * minimizing the fuzziness of the image, measured with Shannon's entropy + * function. + *

+ * For a threshold {@code t}, each grey level {@code g} belongs to its class + * (background if {@code g <= t}, foreground otherwise) with the membership + * {@code 1 / (1 + |g - m| / C)}, where {@code m} is the mean grey level of the + * class and {@code C} the difference between the highest and lowest grey level + * of the image. The fuzziness is the histogram-weighted sum of the Shannon + * entropy of the memberships. Ties are resolved in favor of the lowest + * threshold. + *

+ *

+ * This corresponds to the "Huang" method of ImageJ Auto_Threshold. Its cost is + * quadratic in the number of grey levels; {@code threshold.huang2} is a faster + * variant. It was implemented from the paper below, not translated from + * Auto_Threshold; that plugin was only run to confirm the results, including + * the tie behavior. + *

+ *

+ * L.-K. Huang and M.-J. J. Wang, "Image thresholding by minimizing the + * measures of fuzziness," Pattern Recognition, vol. 28, no. 1, pp. 41-51, + * 1995. + *

* - * @author Barry DeZonia - * @author Gabriel Landini + * @author Curtis Rueden + * @author Claude Opus 5.5 * @implNote op names='threshold.huang' */ public class ComputeHuangThreshold> extends @@ -48,77 +69,66 @@ public class ComputeHuangThreshold> extends { /** - * TODO + * Computes the Huang threshold bin of a histogram. * - * @param hist the {@link Histogram1d}. - * @return the Huang threshold value + * @param hist the {@link Histogram1d} + * @return the Huang threshold bin */ @Override public long computeBin(final Histogram1d hist) { - final var histogram = hist.toLongArray(); - return computeBin(histogram); + return computeBin(hist.toLongArray()); } /** - * Implements Huang's fuzzy thresholding method
- * Uses Shannon's entropy function (one can also use Yager's entropy function) - * Huang L.-K. and Wang M.-J.J. (1995) "Image Thresholding by Minimizing the - * Measures of Fuzziness" Pattern Recognition, 28(1): 41-51
- * Reimplemented (to handle 16-bit efficiently) by Johannes Schindelin Jan 31, - * 2011 + * Computes the Huang threshold bin of a histogram. + * + * @param histogram the bin counts + * @return the threshold bin; pixels in higher bins are foreground */ public static long computeBin(final long[] histogram) { - // find first and last non-empty bin - int first, last; - for (first = 0; first < histogram.length && - histogram[first] == 0; first++) - { - // do nothing - } - for (last = histogram.length - 1; last > first && - histogram[last] == 0; last--) - { - // do nothing - } - if (first == last) return 0; + var lo = 0; + while (lo < histogram.length && histogram[lo] == 0) + lo++; + var hi = histogram.length - 1; + while (hi > lo && histogram[hi] == 0) + hi--; + if (lo >= hi) return lo < histogram.length ? lo : 0; - // calculate the cumulative density and the weighted cumulative density - final double[] S = new double[last + 1], W = new double[last + 1]; - S[0] = histogram[0]; - for (var i = Math.max(1, first); i <= last; i++) { - S[i] = S[i - 1] + histogram[i]; - W[i] = W[i - 1] + i * histogram[i]; + final double c = hi - lo; + double count0 = 0, sum0 = 0, count1 = 0, sum1 = 0; + for (var g = lo; g <= hi; g++) { + count1 += histogram[g]; + sum1 += (double) g * histogram[g]; } - - // precalculate the summands of the entropy given the absolute difference - // x - mu (integral) - final double C = last - first; - final var Smu = new double[last + 1 - first]; - for (var i = 1; i < Smu.length; i++) { - final var mu = 1 / (1 + Math.abs(i) / C); - Smu[i] = -mu * Math.log(mu) - (1 - mu) * Math.log(1 - mu); - } - - // calculate the threshold - var bestThreshold = 0; - var bestEntropy = Double.POSITIVE_INFINITY; - for (var threshold = first; threshold <= last; threshold++) { - double entropy = 0; - var mu = (int) Math.round(W[threshold] / S[threshold]); - for (var i = first; i <= threshold; i++) - entropy += Smu[Math.abs(i - mu)] * histogram[i]; - mu = (int) Math.round((W[last] - W[threshold]) / (S[last] - - S[threshold])); - for (var i = threshold + 1; i <= last; i++) - entropy += Smu[Math.abs(i - mu)] * histogram[i]; - - if (bestEntropy > entropy) { - bestEntropy = entropy; - bestThreshold = threshold; + var best = Double.POSITIVE_INFINITY; + var bestBin = lo; + for (var t = lo; t < hi; t++) { + count0 += histogram[t]; + sum0 += (double) t * histogram[t]; + count1 -= histogram[t]; + sum1 -= (double) t * histogram[t]; + // Note: Empty bins split the levels like the bin below them. + if (histogram[t] == 0) continue; + final var mean0 = sum0 / count0; + final var mean1 = sum1 / count1; + double fuzziness = 0; + for (var g = lo; g <= hi; g++) { + if (histogram[g] == 0) continue; + final var m = g <= t ? mean0 : mean1; + fuzziness += histogram[g] * entropy(1 / (1 + Math.abs(g - m) / c)); + } + if (fuzziness < best) { + best = fuzziness; + bestBin = t; } } + return bestBin; + } - return bestThreshold; + /** Shannon's entropy function of a membership value. */ + private static double entropy(final double mu) { + if (mu <= 0 || mu >= 1) return 0; + return -mu * Math.log(mu) - (1 - mu) * Math.log(1 - mu); } } diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/huang2/ComputeHuang2Threshold.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/huang2/ComputeHuang2Threshold.java new file mode 100644 index 000000000..96006f8c6 --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/huang2/ComputeHuang2Threshold.java @@ -0,0 +1,131 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.threshold.huang2; + +import org.scijava.ops.image.threshold.AbstractComputeThresholdHistogram; +import net.imglib2.histogram.Histogram1d; +import net.imglib2.type.numeric.RealType; + +/** + * Implements the fast variant of Huang's threshold method by Huang {@literal &} + * Wang, rewritten by Johannes Schindelin to handle 16-bit histograms + * efficiently. It corresponds to the "Huang2" method of ImageJ Auto_Threshold; + * see {@code threshold.huang} for the original "Huang" method. The two can + * differ by a few grey levels. + *

+ * Adapted from the Auto_Threshold plugin (version 1.14) by Gabriel Landini, as + * found in Fiji. This code was previously the {@code threshold.huang} op. + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @author Barry DeZonia + * @author Gabriel Landini + * @implNote op names='threshold.huang2' + */ +public class ComputeHuang2Threshold> extends + AbstractComputeThresholdHistogram +{ + + /** + * Computes the Huang2 threshold bin of a histogram. + * + * @param hist the {@link Histogram1d}. + * @return the Huang2 threshold value + */ + @Override + public long computeBin(final Histogram1d hist) { + final var histogram = hist.toLongArray(); + return computeBin(histogram); + } + + /** + * Implements Huang's fuzzy thresholding method
+ * Uses Shannon's entropy function (one can also use Yager's entropy function) + * Huang L.-K. and Wang M.-J.J. (1995) "Image Thresholding by Minimizing the + * Measures of Fuzziness" Pattern Recognition, 28(1): 41-51
+ * Reimplemented (to handle 16-bit efficiently) by Johannes Schindelin Jan 31, + * 2011 + */ + public static long computeBin(final long[] histogram) { + // find first and last non-empty bin + int first, last; + for (first = 0; first < histogram.length && + histogram[first] == 0; first++) + { + // do nothing + } + for (last = histogram.length - 1; last > first && + histogram[last] == 0; last--) + { + // do nothing + } + if (first == last) return 0; + + // calculate the cumulative density and the weighted cumulative density + final double[] S = new double[last + 1], W = new double[last + 1]; + S[0] = histogram[0]; + for (var i = Math.max(1, first); i <= last; i++) { + S[i] = S[i - 1] + histogram[i]; + W[i] = W[i - 1] + i * histogram[i]; + } + + // precalculate the summands of the entropy given the absolute difference + // x - mu (integral) + final double C = last - first; + final var Smu = new double[last + 1 - first]; + for (var i = 1; i < Smu.length; i++) { + final var mu = 1 / (1 + Math.abs(i) / C); + Smu[i] = -mu * Math.log(mu) - (1 - mu) * Math.log(1 - mu); + } + + // calculate the threshold + var bestThreshold = 0; + var bestEntropy = Double.POSITIVE_INFINITY; + for (var threshold = first; threshold <= last; threshold++) { + double entropy = 0; + var mu = (int) Math.round(W[threshold] / S[threshold]); + for (var i = first; i <= threshold; i++) + entropy += Smu[Math.abs(i - mu)] * histogram[i]; + mu = (int) Math.round((W[last] - W[threshold]) / (S[last] - + S[threshold])); + for (var i = threshold + 1; i <= last; i++) + entropy += Smu[Math.abs(i - mu)] * histogram[i]; + + if (bestEntropy > entropy) { + bestEntropy = entropy; + bestThreshold = threshold; + } + } + + return bestThreshold; + } + +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/hysteresis/DefaultHysteresisThreshold.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/hysteresis/DefaultHysteresisThreshold.java new file mode 100644 index 000000000..22d6298c7 --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/hysteresis/DefaultHysteresisThreshold.java @@ -0,0 +1,170 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.threshold.hysteresis; + +import java.util.BitSet; +import java.util.function.BiFunction; + +import net.imglib2.Dimensions; +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.algorithm.neighborhood.RectangleShape; +import net.imglib2.algorithm.neighborhood.Shape; +import net.imglib2.type.logic.BitType; +import net.imglib2.type.numeric.RealType; +import net.imglib2.util.Intervals; +import net.imglib2.view.Views; + +import org.scijava.function.Functions; +import org.scijava.ops.image.util.Neighborhoods; +import org.scijava.ops.spi.Nullable; +import org.scijava.ops.spi.OpDependency; + +/** + * Hysteresis thresholding, as introduced by Canny (1986) for edge linking. + *

+ * A pixel belongs to the foreground if its value is at least {@code high}, or + * if its value is at least {@code low} and it is connected to such a pixel by + * a path of pixels whose values are all at least {@code low}. Connectivity is + * given by a {@link Shape}: the neighbors of a pixel are the pixels of the + * shape's neighborhood around it (made symmetric, so that connectivity is + * symmetric even for an asymmetric shape). The result therefore keeps every + * connected component of {@code input >= low} that contains at least one + * pixel with {@code input >= high}, and removes all others. NaN pixels are never + * foreground, and do not connect their neighbors. + *

+ *

+ * Implemented from the description in the paper, as a breadth-first flood + * fill seeded at the strong pixels. + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @param input pixel type + * @see J. Canny, "A + * Computational Approach to Edge Detection", IEEE Trans. Pattern Anal. + * Mach. Intell. 8(6):679-698, 1986 + * @implNote op names='threshold.hysteresis' + */ +public class DefaultHysteresisThreshold> implements + Functions.Arity4, Double, Double, Shape, RandomAccessibleInterval> +{ + + @OpDependency(name = "create.img") + private BiFunction> createOp; + + /** + * Applies hysteresis thresholding. + * + * @param input the image to threshold + * @param low the low threshold: weak pixels have values of at least this + * @param high the high threshold: strong (seed) pixels have values of at + * least this; it must not be less than {@code low} + * @param connectivity the neighborhood defining which pixels are connected + * (default: all pixels within distance 1 in every dimension, i.e. + * 8-connectivity in 2D and 26-connectivity in 3D) + * @return a binary image of the same dimensions as the input, true for + * foreground pixels + */ + @Override + public RandomAccessibleInterval apply( + final RandomAccessibleInterval input, final Double low, + final Double high, @Nullable final Shape connectivity) + { + if (low > high) { + throw new IllegalArgumentException("Low threshold " + low + + " exceeds high threshold " + high); + } + final var shape = connectivity == null ? new RectangleShape(1, true) + : connectivity; + final var n = input.numDimensions(); + final var size = Intervals.numElements(input); + if (size > Integer.MAX_VALUE) { + throw new IllegalArgumentException("Image is too large: " + size + + " pixels"); + } + final var count = (int) size; + final var dims = input.dimensionsAsLongArray(); + final var strides = new long[n]; + for (var d = 0; d < n; d++) + strides[d] = d == 0 ? 1 : strides[d - 1] * dims[d - 1]; + final var offsets = Neighborhoods.neighborOffsets(shape, n); + + final var weak = new BitSet(count); + final var strong = new BitSet(count); + var i = 0; + for (final var t : Views.flatIterable(input)) { + final var v = t.getRealDouble(); + if (v >= low) weak.set(i); + if (v >= high) strong.set(i); + i++; + } + + final var result = new BitSet(count); + final var queue = new int[count]; + final var pos = new long[n]; + var tail = 0; + for (var s = strong.nextSetBit(0); s >= 0; s = strong.nextSetBit(s + 1)) { + result.set(s); + queue[tail++] = s; + } + for (var head = 0; head < tail; head++) { + final var index = queue[head]; + var rem = (long) index; + for (var d = n - 1; d >= 0; d--) { + pos[d] = rem / strides[d]; + rem %= strides[d]; + } + for (final var offset : offsets) { + long neighbor = 0; + var inside = true; + for (var d = 0; d < n; d++) { + final var p = pos[d] + offset[d]; + if (p < 0 || p >= dims[d]) { + inside = false; + break; + } + neighbor += p * strides[d]; + } + if (!inside) continue; + final var ni = (int) neighbor; + if (weak.get(ni) && !result.get(ni)) { + result.set(ni); + queue[tail++] = ni; + } + } + } + + final var output = createOp.apply(input, new BitType()); + i = 0; + for (final var t : Views.flatIterable(output)) + t.set(result.get(i++)); + return output; + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/localBernsen/ComputeLocalBernsenThreshold.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/localBernsen/ComputeLocalBernsenThreshold.java index eb9bbadf1..dc918ddac 100644 --- a/scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/localBernsen/ComputeLocalBernsenThreshold.java +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/localBernsen/ComputeLocalBernsenThreshold.java @@ -33,6 +33,7 @@ import org.scijava.ops.image.threshold.localMidGrey.ComputeLocalMidGreyThreshold; import net.imglib2.type.logic.BitType; +import net.imglib2.type.numeric.IntegerType; import net.imglib2.type.numeric.RealType; import net.imglib2.util.Pair; @@ -44,6 +45,14 @@ * {@link ComputeLocalMidGreyThreshold}, but uses a constant value rather than * the value of the input pixel when the contrast in the neighborhood of that * pixel is too small. + *

+ * The local mid grey is {@code (min + max) / 2} of the neighborhood; for + * {@link IntegerType} images, it is rounded down to an integer grey level, as + * in Fiji's Auto_Local_Threshold plugin. If the local contrast + * {@code max - min} is below the contrast threshold, a pixel is foreground if + * the mid grey is at least {@code halfMaxValue}; otherwise, it is foreground + * if it is at least the mid grey. + *

* * @author Jonathan Hale * @author Stefan Helfrich (University of Konstanz) @@ -58,13 +67,15 @@ public class ComputeLocalBernsenThreshold> implements private Function, Pair> minMaxOp; /** - * TODO + * Applies Bernsen's rule to one pixel. * - * @param inputNeighborhood - * @param inputCenterPixel - * @param contrastThreshold - * @param halfMaxValue - * @param output + * @param inputNeighborhood the neighborhood of the pixel + * @param inputCenterPixel the pixel + * @param contrastThreshold the local contrast below which a neighborhood + * counts as uniform (Fiji default: 15) + * @param halfMaxValue the mid grey at or above which a uniform + * neighborhood is foreground (Fiji default: 128 for 8-bit images) + * @param output true if the pixel is foreground */ @Override public void compute(final Iterable inputNeighborhood, @@ -83,7 +94,9 @@ public static > void compute( final var outputs = minMaxOp.apply(inputNeighborhood); final var minValue = outputs.getA().getRealDouble(); final var maxValue = outputs.getB().getRealDouble(); - final var midGrey = (maxValue + minValue) / 2.0; + final var mid = (maxValue + minValue) / 2.0; + final var midGrey = inputCenterPixel instanceof IntegerType ? Math.floor( + mid) : mid; if ((maxValue - minValue) < contrastThreshold) { output.set(midGrey >= halfMaxValue); diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/localBernsen/LocalBernsenThreshold.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/localBernsen/LocalBernsenThreshold.java index e13421c21..f591066a1 100644 --- a/scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/localBernsen/LocalBernsenThreshold.java +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/localBernsen/LocalBernsenThreshold.java @@ -41,6 +41,18 @@ import org.scijava.ops.spi.Nullable; /** + * Bernsen's local threshold: a pixel is foreground if it is at least the mid + * grey {@code (min + max) / 2} of its neighborhood (rounded down to an integer + * for {@link net.imglib2.type.numeric.IntegerType} images, as in Fiji's + * Auto_Local_Threshold plugin), or, in neighborhoods with a contrast + * {@code max - min} below the contrast threshold, if that mid grey is at least + * {@code halfMaxValue}. + *

+ * Bernsen J. (1986) "Dynamic Thresholding of Grey-Level Images", Proc. of the + * 8th Int. Conf. on Pattern Recognition, pp. 1251-1255. + *

+ * + * @see ComputeLocalBernsenThreshold * @author Jonathan Hale * @author Stefan Helfrich (University of Konstanz) * @param input type @@ -57,14 +69,16 @@ public class LocalBernsenThreshold> implements private Computers.Arity4, Computers.Arity2, T, BitType>, Shape, OutOfBoundsFactory>, RandomAccessibleInterval> applyFilterOp; /** - * TODO + * Computes Bernsen's local threshold of an image. * - * @param input - * @param inputNeighborhoodShape - * @param contrastThreshold - * @param halfMaxValue - * @param outOfBoundsFactory - * @param output + * @param input the image to threshold + * @param inputNeighborhoodShape the shape of the local neighborhood + * @param contrastThreshold the local contrast below which a neighborhood + * counts as uniform (Fiji default: 15) + * @param halfMaxValue the mid grey at or above which a uniform + * neighborhood is foreground (Fiji default: 128 for 8-bit images) + * @param outOfBoundsFactory the out-of-bounds strategy (default border) + * @param output the mask, true where a pixel is foreground */ @Override public void compute(final RandomAccessibleInterval input, diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/localContrast/ComputeLocalContrastThreshold.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/localContrast/ComputeLocalContrastThreshold.java index 72360a78d..cb67203f6 100644 --- a/scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/localContrast/ComputeLocalContrastThreshold.java +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/localContrast/ComputeLocalContrastThreshold.java @@ -40,7 +40,10 @@ /** * Local threshold method which determines whether a pixel is closer to the - * maximum or minimum pixel of a neighborhood. + * maximum or minimum pixel of a neighborhood. Pixels closer to the maximum (or + * equally close to both) are foreground, except pixels with the minimum value + * of the type (black), which are always background. This matches the Contrast + * method of ImageJ Auto_Local_Threshold (v1.10+) by G. Landini. * * @author Jonathan Hale * @author Stefan Helfrich (University of Konstanz) @@ -77,10 +80,10 @@ public static > void compute( final var diffMin = centerValue - outputs.getA().getRealDouble(); final var diffMax = outputs.getB().getRealDouble() - centerValue; - // set to background (false) if pixel closer to min value, - // and to foreground (true) if pixel closer to max value. - // If diffMin and diffMax are equal, output will be set to fg. - output.set(diffMin <= diffMax); + // Note: Like ImageJ, ties go to the foreground, except for black pixels, + // which would otherwise make flat black regions foreground. + output.set(diffMax <= diffMin && centerValue != inputCenterPixel + .getMinValue()); } } diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/localNiblack/ComputeLocalNiblackThreshold.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/localNiblack/ComputeLocalNiblackThreshold.java index 0e2882caf..4e209f164 100644 --- a/scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/localNiblack/ComputeLocalNiblackThreshold.java +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/localNiblack/ComputeLocalNiblackThreshold.java @@ -31,13 +31,19 @@ import net.imglib2.type.logic.BitType; import net.imglib2.type.numeric.RealType; -import net.imglib2.type.numeric.real.DoubleType; import org.scijava.function.Computers; -import org.scijava.ops.spi.OpDependency; +import org.scijava.ops.image.threshold.Thresholds; /** - * LocalThresholdMethod using Niblack's thresholding method. + * Niblack's local threshold: a pixel is foreground if it is strictly above + * {@code mean + k * sd - c}, with {@code mean} and {@code sd} the mean and the + * population standard deviation (as in Fiji's Auto_Local_Threshold plugin) of + * its neighborhood. + *

+ * Niblack W. (1986) "An Introduction to Digital Image Processing", + * Prentice-Hall. + *

* * @author Jonathan Hale * @author Stefan Helfrich (University of Konstanz) @@ -47,45 +53,24 @@ public class ComputeLocalNiblackThreshold> implements Computers.Arity4, T, Double, Double, BitType> { - @OpDependency(name = "stats.mean") - private Computers.Arity1, DoubleType> meanOp; - - @OpDependency(name = "stats.stdDev") - private Computers.Arity1, DoubleType> stdDeviationOp; - /** - * TODO + * Applies Niblack's rule to one pixel. * - * @param inputNeighborhood - * @param inputCenterPixel - * @param c - * @param k - * @param output + * @param inputNeighborhood the neighborhood of the pixel + * @param inputCenterPixel the pixel + * @param c the constant subtracted from the threshold (Fiji default: 0) + * @param k the weight of the standard deviation (Fiji default: 0.2 for + * bright objects) + * @param output true if the pixel is foreground */ @Override public void compute(final Iterable inputNeighborhood, final T inputCenterPixel, final Double c, final Double k, final BitType output) { - compute(inputNeighborhood, inputCenterPixel, c, k, meanOp, stdDeviationOp, - output); - } - - public static > void compute( - final Iterable inputNeighborhood, final T inputCenterPixel, - final Double c, final Double k, - final Computers.Arity1, DoubleType> meanOp, - final Computers.Arity1, DoubleType> stdDeviationOp, - final BitType output) - { - final var m = new DoubleType(); - meanOp.compute(inputNeighborhood, m); - - final var stdDev = new DoubleType(); - stdDeviationOp.compute(inputNeighborhood, stdDev); - - output.set(inputCenterPixel.getRealDouble() > m.getRealDouble() + k * stdDev - .getRealDouble() - c); + final var stats = Thresholds.meanAndPopulationStdDev(inputNeighborhood); + output.set(inputCenterPixel.getRealDouble() > stats[0] + k * stats[1] - + c); } } diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/localNiblack/ComputeLocalNiblackThresholdIntegral.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/localNiblack/ComputeLocalNiblackThresholdIntegral.java index 170477edb..ad183d02f 100644 --- a/scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/localNiblack/ComputeLocalNiblackThresholdIntegral.java +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/localNiblack/ComputeLocalNiblackThresholdIntegral.java @@ -36,14 +36,17 @@ import net.imglib2.type.logic.BitType; import net.imglib2.type.numeric.RealType; import net.imglib2.type.numeric.real.DoubleType; +import net.imglib2.util.Intervals; import net.imglib2.view.composite.Composite; import org.scijava.function.Computers; +import org.scijava.ops.image.threshold.Thresholds; import org.scijava.ops.spi.OpDependency; /** *

- * Niblack's local thresholding algorithm. + * Niblack's local thresholding algorithm, with the population standard + * deviation (see {@link ComputeLocalNiblackThreshold}). *

*

* This implementation improves execution speed by using integral images for the @@ -70,13 +73,15 @@ public class ComputeLocalNiblackThresholdIntegral, U exten private Computers.Arity1>, DoubleType> integralVarianceOp; /** - * TODO + * Applies Niblack's rule to one pixel. * - * @param inputNeighborhood - * @param inputCenterPixel - * @param c - * @param k - * @param output + * @param inputNeighborhood the neighborhood of the pixel in the integral + * images of order 1 and 2 + * @param inputCenterPixel the pixel + * @param c the constant subtracted from the threshold (Fiji default: 0) + * @param k the weight of the standard deviation (Fiji default: 0.2 for + * bright objects) + * @param output true if the pixel is foreground */ @Override public void compute( @@ -94,7 +99,10 @@ public void compute( final var variance = new DoubleType(); integralVarianceOp.compute(inputNeighborhood, variance); - final var stdDev = new DoubleType(Math.sqrt(variance.get())); + final var n = Intervals.numElements(Intervals.expand(inputNeighborhood, + -1)); + final var stdDev = new DoubleType(Math.sqrt(Thresholds.populationVariance( + variance.get(), n))); stdDev.mul(k); threshold.add(stdDev); diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/localNiblack/LocalNiblackThreshold.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/localNiblack/LocalNiblackThreshold.java index 85116cdcd..e9f5b16c3 100644 --- a/scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/localNiblack/LocalNiblackThreshold.java +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/localNiblack/LocalNiblackThreshold.java @@ -48,6 +48,12 @@ import org.scijava.ops.spi.OpDependency; /** + * Niblack's local threshold: a pixel is foreground if it is strictly above + * {@code mean + k * sd - c}, with {@code mean} and {@code sd} the mean and the + * population standard deviation (as in Fiji's Auto_Local_Threshold plugin) of + * its neighborhood. + * + * @see ComputeLocalNiblackThreshold * @implNote op names='threshold.localNiblack', priority='-100.' */ public class LocalNiblackThreshold> extends @@ -68,14 +74,15 @@ public class LocalNiblackThreshold> extends private Computers.Arity4, Computers.Arity2, T, BitType>, Shape, OutOfBoundsFactory>, RandomAccessibleInterval> applyFilterOp; /** - * TODO + * Computes Niblack's local threshold of an image. * - * @param input - * @param inputNeighborhoodShape - * @param c - * @param k - * @param outOfBoundsFactory - * @param output + * @param input the image to threshold + * @param inputNeighborhoodShape the shape of the local neighborhood + * @param c the constant subtracted from the threshold (Fiji default: 0) + * @param k the weight of the standard deviation (Fiji default: 0.2 for + * bright objects) + * @param outOfBoundsFactory the out-of-bounds strategy (default border) + * @param output the mask, true where a pixel is above its local threshold */ @Override public void compute(final RandomAccessibleInterval input, diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/localPhansalkar/ComputeLocalPhansalkarThreshold.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/localPhansalkar/ComputeLocalPhansalkarThreshold.java index cc9dd8f07..bb6a7cd63 100644 --- a/scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/localPhansalkar/ComputeLocalPhansalkarThreshold.java +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/localPhansalkar/ComputeLocalPhansalkarThreshold.java @@ -31,18 +31,20 @@ import net.imglib2.type.logic.BitType; import net.imglib2.type.numeric.RealType; -import net.imglib2.type.numeric.real.DoubleType; import org.scijava.function.Computers; +import org.scijava.ops.image.threshold.Thresholds; import org.scijava.ops.spi.Nullable; -import org.scijava.ops.spi.OpDependency; /** *

* This is a modification of Sauvola's thresholding method to deal with low * contrast images. In this algorithm the threshold is computed as t = * mean*(1+p*exp(-q*mean)+k*((stdev/r)-1)) for an image that is normalized to - * [0, 1]. + * [0, 1], with the mean and the population standard deviation (as in Fiji's + * Auto_Local_Threshold plugin) of the neighborhood. This op applies the rule + * to the raw pixel values; {@code threshold.localPhansalkar} on images + * normalizes them first. *

*

* Phansalkar recommends k = 0.25, r = 0.5, p = 2 and q = 10. In the current @@ -74,20 +76,32 @@ public class ComputeLocalPhansalkarThreshold> implements private static final double P = 2.0; private static final double Q = 10.0; - @OpDependency(name = "stats.mean") - private Computers.Arity1, DoubleType> meanOp; - - @OpDependency(name = "stats.stdDev") - private Computers.Arity1, DoubleType> stdDeviationOp; + /** + * Applies Phansalkar's rule to a pixel value of an image normalized to [0, + * 1]. + * + * @param value the pixel value + * @param mean the mean of its neighborhood + * @param stdDev the population standard deviation of its neighborhood + * @param k the k parameter + * @param r the r parameter + * @return whether the pixel is foreground, i.e. strictly above the threshold + */ + public static boolean isForeground(final double value, final double mean, + final double stdDev, final double k, final double r) + { + return value > mean * (1.0 + P * Math.exp(-Q * mean) + k * ((stdDev / r) - + 1.0)); + } /** - * TODO + * Applies Phansalkar's rule to one pixel of an image normalized to [0, 1]. * - * @param inputNeighborhood - * @param inputCenterPixel - * @param k - * @param r - * @param output + * @param inputNeighborhood the neighborhood of the pixel + * @param inputCenterPixel the pixel + * @param k the k parameter (default: 0.25) + * @param r the r parameter (default: 0.5) + * @param output true if the pixel is foreground */ @Override public void compute(final Iterable inputNeighborhood, @@ -96,16 +110,8 @@ public void compute(final Iterable inputNeighborhood, { if (k == null) k = DEFAULT_K; if (r == null) r = DEFAULT_R; - - final var meanValue = new DoubleType(); - meanOp.compute(inputNeighborhood, meanValue); - - final var stdDevValue = new DoubleType(); - stdDeviationOp.compute(inputNeighborhood, stdDevValue); - - final var threshold = meanValue.get() * (1.0d + P * Math.exp(-Q * - meanValue.get()) + k * ((stdDevValue.get() / r) - 1.0)); - - output.set(inputCenterPixel.getRealDouble() >= threshold); + final var stats = Thresholds.meanAndPopulationStdDev(inputNeighborhood); + output.set(isForeground(inputCenterPixel.getRealDouble(), stats[0], + stats[1], k, r)); } } diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/localPhansalkar/ComputeLocalPhansalkarThresholdIntegral.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/localPhansalkar/ComputeLocalPhansalkarThresholdIntegral.java index b8796726f..87fb15069 100644 --- a/scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/localPhansalkar/ComputeLocalPhansalkarThresholdIntegral.java +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/localPhansalkar/ComputeLocalPhansalkarThresholdIntegral.java @@ -31,20 +31,22 @@ import net.imglib2.RandomAccessibleInterval; import net.imglib2.algorithm.neighborhood.RectangleNeighborhood; -import net.imglib2.converter.Converter; -import net.imglib2.converter.RealDoubleConverter; import net.imglib2.type.logic.BitType; import net.imglib2.type.numeric.RealType; import net.imglib2.type.numeric.real.DoubleType; +import net.imglib2.util.Intervals; import net.imglib2.view.composite.Composite; import org.scijava.function.Computers; +import org.scijava.ops.image.threshold.Thresholds; import org.scijava.ops.spi.Nullable; import org.scijava.ops.spi.OpDependency; /** *

- * Local thresholding algorithm as proposed by Phansalkar et al. + * Local thresholding algorithm as proposed by Phansalkar et al., for an image + * normalized to [0, 1], with the population standard deviation (see + * {@link ComputeLocalPhansalkarThreshold}). *

*

* This implementation improves execution speed by using integral images for the @@ -64,12 +66,6 @@ public class ComputeLocalPhansalkarThresholdIntegral> Computers.Arity4>, T, Double, Double, BitType> { - public static final double DEFAULT_K = 0.25; - public static final double DEFAULT_R = 0.5; - - private static final double P = 2.0; - private static final double Q = 10.0; - @OpDependency(name = "stats.integralMean") private Computers.Arity1>, DoubleType> integralMeanOp; @@ -77,13 +73,14 @@ public class ComputeLocalPhansalkarThresholdIntegral> private Computers.Arity1>, DoubleType> integralVarianceOp; /** - * TODO + * Applies Phansalkar's rule to one pixel of an image normalized to [0, 1]. * - * @param inputNeighborhood - * @param inputCenterPixel - * @param k - * @param r - * @param output + * @param inputNeighborhood the neighborhood of the pixel in the integral + * images of order 1 and 2 + * @param inputCenterPixel the pixel + * @param k the k parameter (default: 0.25) + * @param r the r parameter (default: 0.5) + * @param output true if the pixel is foreground */ @Override public void compute( @@ -91,28 +88,20 @@ public void compute( final T inputCenterPixel, @Nullable Double k, @Nullable Double r, final BitType output) { - if (k == null) k = DEFAULT_K; - if (r == null) r = DEFAULT_R; + if (k == null) k = ComputeLocalPhansalkarThreshold.DEFAULT_K; + if (r == null) r = ComputeLocalPhansalkarThreshold.DEFAULT_R; final var mean = new DoubleType(); integralMeanOp.compute(inputNeighborhood, mean); final var variance = new DoubleType(); integralVarianceOp.compute(inputNeighborhood, variance); + final var n = Intervals.numElements(Intervals.expand(inputNeighborhood, + -1)); - final var stdDev = new DoubleType(Math.sqrt(variance.get())); - - final var threshold = new DoubleType(mean.getRealDouble() * (1.0d + - P * Math.exp(-Q * mean.getRealDouble()) + k * ((stdDev.getRealDouble() / - r) - 1.0))); - - // Set value - final Converter conv = new RealDoubleConverter<>(); - final var centerPixelAsDoubleType = variance; // NB: Reuse - // DoubleType - conv.convert(inputCenterPixel, centerPixelAsDoubleType); - - output.set(centerPixelAsDoubleType.compareTo(threshold) > 0); + output.set(ComputeLocalPhansalkarThreshold.isForeground(inputCenterPixel + .getRealDouble(), mean.get(), Math.sqrt(Thresholds.populationVariance( + variance.get(), n)), k, r)); } } diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/localPhansalkar/LocalPhansalkarThreshold.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/localPhansalkar/LocalPhansalkarThreshold.java index 4ba48f805..e1606216b 100644 --- a/scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/localPhansalkar/LocalPhansalkarThreshold.java +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/localPhansalkar/LocalPhansalkarThreshold.java @@ -33,14 +33,19 @@ import java.util.function.Function; import org.scijava.ops.image.threshold.ApplyLocalThresholdIntegral; +import org.scijava.ops.image.threshold.Thresholds; import net.imglib2.RandomAccessibleInterval; +import net.imglib2.converter.Converters; import net.imglib2.algorithm.neighborhood.RectangleNeighborhood; import net.imglib2.algorithm.neighborhood.RectangleShape; import net.imglib2.algorithm.neighborhood.Shape; import net.imglib2.outofbounds.OutOfBoundsFactory; import net.imglib2.type.logic.BitType; +import net.imglib2.type.numeric.IntegerType; import net.imglib2.type.numeric.RealType; import net.imglib2.type.numeric.real.DoubleType; +import net.imglib2.util.Intervals; +import net.imglib2.util.Pair; import net.imglib2.view.composite.Composite; import org.scijava.function.Computers; @@ -48,84 +53,155 @@ import org.scijava.ops.spi.OpDependency; /** + * Phansalkar's local threshold: a pixel is foreground if it is strictly above + * {@code mean * (1 + p * exp(-q * mean) + k * (sd / r - 1))}, with p = 2 and + * q = 10, and {@code mean} and {@code sd} the mean and the population standard + * deviation (as in Fiji's Auto_Local_Threshold plugin) of its neighborhood. + *

+ * Phansalkar's fixed parameters p and q are intended for an image normalized + * to [0, 1]. Therefore, like the Phansalkar method of Fiji, this op first + * stretches the input linearly from its minimum and maximum values to [0, 1], + * unless {@code normalize} is false (in which case the input should already be + * in [0, 1]). Images of an {@link IntegerType} are stretched as ImageJ's + * "Enhance Contrast" normalization does: to the grey levels of their type, + * rounding down (a pixel {@code v} becomes + * {@code floor((v - min) / (max - min) * L)} grey levels above the type + * minimum, with {@code L} the number of grey levels minus one, e.g. 255 for + * 8-bit images), and then divided by {@code L}. So on 8-bit images, the mask + * is that of Fiji. Floating-point images are stretched exactly. An image whose + * values are all equal is not stretched. + *

+ * + * @see ComputeLocalPhansalkarThreshold * @implNote op names='threshold.localPhansalkar', priority='-100.' */ public class LocalPhansalkarThreshold> extends ApplyLocalThresholdIntegral implements - Computers.Arity5, Shape, Double, Double, OutOfBoundsFactory>, // + Computers.Arity6, Shape, Double, Double, OutOfBoundsFactory>, Boolean, // RandomAccessibleInterval> { private static final int INTEGRAL_IMAGE_ORDER_1 = 1; private static final int INTEGRAL_IMAGE_ORDER_2 = 2; - @OpDependency(name = "threshold.localPhansalkar") - private Computers.Arity4, T, Double, Double, BitType> computeThresholdNonIntegralOp; + @OpDependency(name = "stats.minMax") + private Function, Pair> minMaxOp; - @OpDependency(name = "threshold.localPhansalkar") - private Computers.Arity4>, T, Double, Double, BitType> computeThresholdIntegralOp; + @OpDependency(name = "stats.integralMean") + private Computers.Arity1>, DoubleType> integralMeanOp; + + @OpDependency(name = "stats.integralVariance") + private Computers.Arity1>, DoubleType> integralVarianceOp; @OpDependency(name = "filter.applyCenterAware") private Computers.Arity4, Computers.Arity2, T, BitType>, Shape, OutOfBoundsFactory>, RandomAccessibleInterval> applyFilterOp; /** - * TODO + * Computes Phansalkar's local threshold of an image. * - * @param input - * @param inputNeighborhoodShape - * @param k - * @param r - * @param outOfBoundsFactory - * @param output + * @param input the image to threshold + * @param inputNeighborhoodShape the shape of the local neighborhood + * @param k the k parameter (default 0.25) + * @param r the r parameter (default 0.5) + * @param outOfBoundsFactory the out-of-bounds strategy (default border) + * @param normalize whether to stretch the input from its minimum and maximum + * to [0, 1] first (default true); if false, the input values are + * used as they are and should be in [0, 1] + * @param output the mask, true where a pixel is above its local threshold */ @Override public void compute(final RandomAccessibleInterval input, - final Shape inputNeighborhoodShape, @Nullable final Double k, - @Nullable final Double r, + final Shape inputNeighborhoodShape, @Nullable Double k, + @Nullable Double r, @Nullable OutOfBoundsFactory> outOfBoundsFactory, - final RandomAccessibleInterval output) + @Nullable Boolean normalize, final RandomAccessibleInterval output) { + if (k == null) k = ComputeLocalPhansalkarThreshold.DEFAULT_K; + if (r == null) r = ComputeLocalPhansalkarThreshold.DEFAULT_R; + if (normalize == null) normalize = true; + + // The image whose neighborhoods are analyzed, and the linear map from its + // values to [0, 1]. + RandomAccessibleInterval image = input; + var offset = 0.0; + var scale = 1.0; + if (normalize) { + final var minMax = minMaxOp.apply(input); + final var min = minMax.getA().getRealDouble(); + final var range = minMax.getB().getRealDouble() - min; + final var type = minMax.getA(); + if (type instanceof IntegerType) { + final var typeMin = type.getMinValue(); + final var levels = type.getMaxValue() - typeMin; + if (range > 0) { + image = Converters.convert(input, (in, out) -> out.setReal(typeMin + + Math.floor((in.getRealDouble() - min) / range * levels)), type + .createVariable()); + } + offset = typeMin; + scale = 1 / levels; + } + else { + offset = min; + scale = range > 0 ? 1 / range : 1; + } + } + // Use integral images for sufficiently large windows. - var rShape = inputNeighborhoodShape instanceof RectangleShape + var rShape = inputNeighborhoodShape instanceof RectangleShape ? (RectangleShape) inputNeighborhoodShape : null; if (rShape != null && rShape.getSpan() > 2 && !rShape.isSkippingCenter()) { // NB: under these conditions, the RectangleShape will produce // RectangleNeighborhoods (which is needed to perform the computations via // an IntegralImg). - computeIntegral(input, rShape, k, r, outOfBoundsFactory, - getIntegralImageOp(INTEGRAL_IMAGE_ORDER_1), getIntegralImageOp( - INTEGRAL_IMAGE_ORDER_2), computeThresholdIntegralOp, output); + computeIntegral(image, rShape, k, r, offset, scale, outOfBoundsFactory, + output); } else { - computeNonIntegral(input, inputNeighborhoodShape, k, r, - outOfBoundsFactory, computeThresholdNonIntegralOp, output); + computeNonIntegral(image, inputNeighborhoodShape, k, r, offset, scale, + outOfBoundsFactory, output); } } - public void computeNonIntegral(final RandomAccessibleInterval input, - final Shape inputNeighborhoodShape, final Double k, final Double r, + private void computeNonIntegral(final RandomAccessibleInterval input, + final Shape inputNeighborhoodShape, final double k, final double r, + final double offset, final double scale, final OutOfBoundsFactory> outOfBoundsFactory, - final Computers.Arity4, T, Double, Double, BitType> computeThresholdOp, final RandomAccessibleInterval output) { - final Computers.Arity2, T, BitType> parameterizedComputeThresholdOp = // - (i1, i2, o) -> computeThresholdOp.compute(i1, i2, k, r, o); - applyFilterOp.compute(input, parameterizedComputeThresholdOp, - inputNeighborhoodShape, outOfBoundsFactory, output); + final Computers.Arity2, T, BitType> thresholdOp = // + (neighborhood, center, o) -> { + final var stats = Thresholds.meanAndPopulationStdDev(neighborhood); + o.set(ComputeLocalPhansalkarThreshold.isForeground( // + (center.getRealDouble() - offset) * scale, (stats[0] - offset) * + scale, stats[1] * scale, k, r)); + }; + applyFilterOp.compute(input, thresholdOp, inputNeighborhoodShape, + outOfBoundsFactory, output); } - public void computeIntegral(final RandomAccessibleInterval input, - final RectangleShape inputNeighborhoodShape, final Double k, final Double r, + private void computeIntegral(final RandomAccessibleInterval input, + final RectangleShape inputNeighborhoodShape, final double k, + final double r, final double offset, final double scale, final OutOfBoundsFactory> outOfBoundsFactory, - final Function, RandomAccessibleInterval> integralImageOp, - final Function, RandomAccessibleInterval> squareIntegralImageOp, - final Computers.Arity4>, T, Double, Double, BitType> computeThresholdOp, final RandomAccessibleInterval output) { - final Computers.Arity2>, T, BitType> parameterizedComputeThresholdOp = // - (i1, i2, o) -> computeThresholdOp.compute(i1, i2, k, r, o); + final Computers.Arity2>, T, BitType> thresholdOp = // + (neighborhood, center, o) -> { + final var mean = new DoubleType(); + integralMeanOp.compute(neighborhood, mean); + final var variance = new DoubleType(); + integralVarianceOp.compute(neighborhood, variance); + final var n = Intervals.numElements(Intervals.expand(neighborhood, + -1)); + final var sd = Math.sqrt(Thresholds.populationVariance(variance.get(), + n)); + o.set(ComputeLocalPhansalkarThreshold.isForeground( // + (center.getRealDouble() - offset) * scale, (mean.get() - offset) * + scale, sd * scale, k, r)); + }; compute(input, inputNeighborhoodShape, outOfBoundsFactory, Arrays.asList( - integralImageOp, squareIntegralImageOp), parameterizedComputeThresholdOp, - output); + getIntegralImageOp(INTEGRAL_IMAGE_ORDER_1), getIntegralImageOp( + INTEGRAL_IMAGE_ORDER_2)), thresholdOp, output); } } diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/localSauvola/ComputeLocalSauvolaThreshold.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/localSauvola/ComputeLocalSauvolaThreshold.java index 04e6c3d68..a18b5e8cb 100644 --- a/scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/localSauvola/ComputeLocalSauvolaThreshold.java +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/localSauvola/ComputeLocalSauvolaThreshold.java @@ -30,24 +30,42 @@ package org.scijava.ops.image.threshold.localSauvola; import net.imglib2.type.logic.BitType; +import net.imglib2.type.numeric.IntegerType; import net.imglib2.type.numeric.RealType; -import net.imglib2.type.numeric.real.DoubleType; import org.scijava.function.Computers; +import org.scijava.ops.image.threshold.Thresholds; import org.scijava.ops.spi.Nullable; -import org.scijava.ops.spi.OpDependency; /** + * Sauvola's local threshold, a modification of Niblack's method: a pixel is + * foreground if it is strictly above + * {@code mean * (1 + k * (sd / r - 1))}, with {@code mean} and {@code sd} the + * mean and the population standard deviation (as in Fiji's + * Auto_Local_Threshold plugin) of its neighborhood. *

- * This is a modification of Niblack's thresholding method. In contrast to the - * recommendation on parameters in the publication, this implementation operates - * on normalized images (to the [0, 1] range). Hence, the r parameter defaults - * to half the possible standard deviation in a normalized image, namely 0.5. + * The parameter {@code r} is the dynamic range of the standard deviation, in + * the units of the image. Unlike Phansalkar's rule, Sauvola's rule is + * scale-equivariant (scaling the image and {@code r} scales the threshold + * alike), so the image is not normalized; instead, {@code r} defaults to half + * the nominal range of the pixel type, which is the largest possible standard + * deviation of values in that range, and corresponds to the default + * {@code r = 0.5} of {@code threshold.localPhansalkar} on its [0, 1] scale: *

+ *
    + *
  • for {@link IntegerType} images, half the number of grey levels, e.g. + * 128 for 8-bit images (the value recommended by Sauvola and Pietikäinen + * and used by Fiji) and 32768 for 16-bit images;
  • + *
  • for floating-point images, 0.5, i.e. the image is assumed to be + * normalized to [0, 1].
  • + *
*

- * Sauvola J. and Pietaksinen M. (2000) "Adaptive Document Image Binarization" - * Pattern Recognition, 33(2): 225-236. - * PDF + * Images that use only part of their type's range (e.g. 12-bit data in a + * 16-bit image) need an explicit {@code r}. + *

+ *

+ * Sauvola J. and Pietikäinen M. (2000) "Adaptive Document Image + * Binarization", Pattern Recognition, 33(2): 225-236. *

*

* Original ImageJ implementation by Gabriel Landini. @@ -61,51 +79,57 @@ public class ComputeLocalSauvolaThreshold> implements { public static final double DEFAULT_K = 0.5; - public static final double DEFAULT_R = 0.5; - @OpDependency(name = "stats.mean") - private Computers.Arity1, DoubleType> meanOp; + /** + * Gets the default {@code r} of Sauvola's rule for images of a type: half + * the number of grey levels for {@link IntegerType}s, and 0.5 for other + * types. + * + * @param type a pixel of the image + * @return the default {@code r} + */ + public static double defaultR(final RealType type) { + if (type instanceof IntegerType) { + return (type.getMaxValue() - type.getMinValue() + 1) / 2; + } + return 0.5; + } - @OpDependency(name = "stats.stdDev") - private Computers.Arity1, DoubleType> stdDeviationOp; + /** + * Applies Sauvola's rule to a pixel value. + * + * @param value the pixel value + * @param mean the mean of its neighborhood + * @param stdDev the population standard deviation of its neighborhood + * @param k the k parameter + * @param r the r parameter + * @return whether the pixel is foreground, i.e. strictly above the threshold + */ + public static boolean isForeground(final double value, final double mean, + final double stdDev, final double k, final double r) + { + return value > mean * (1.0 + k * (stdDev / r - 1.0)); + } /** - * TODO + * Applies Sauvola's rule to one pixel. * - * @param inputNeighborhood - * @param inputCenterPixel - * @param k - * @param r - * @param output + * @param inputNeighborhood the neighborhood of the pixel + * @param inputCenterPixel the pixel + * @param k the k parameter (default: 0.5) + * @param r the dynamic range of the standard deviation (default: half the + * nominal range of the pixel type, e.g. 128 for 8-bit images) + * @param output true if the pixel is foreground */ @Override public void compute(final Iterable inputNeighborhood, final T inputCenterPixel, @Nullable final Double k, @Nullable final Double r, final BitType output) { - compute(inputNeighborhood, inputCenterPixel, k, r, meanOp, stdDeviationOp, - output); - } - - public static > void compute( - final Iterable inputNeighborhood, final T inputCenterPixel, Double k, - Double r, final Computers.Arity1, DoubleType> meanOp, - final Computers.Arity1, DoubleType> stdDeviationOp, - final BitType output) - { - if (k == null) k = DEFAULT_K; - if (r == null) r = DEFAULT_R; - - final var meanValue = new DoubleType(); - meanOp.compute(inputNeighborhood, meanValue); - - final var stdDevValue = new DoubleType(); - stdDeviationOp.compute(inputNeighborhood, stdDevValue); - - final var threshold = meanValue.get() * (1.0d + k * ((Math.sqrt( - stdDevValue.get()) / r) - 1.0)); - - output.set(inputCenterPixel.getRealDouble() >= threshold); + final var stats = Thresholds.meanAndPopulationStdDev(inputNeighborhood); + output.set(isForeground(inputCenterPixel.getRealDouble(), stats[0], + stats[1], k == null ? DEFAULT_K : k, r == null ? defaultR( + inputCenterPixel) : r)); } } diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/localSauvola/ComputeLocalSauvolaThresholdIntegral.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/localSauvola/ComputeLocalSauvolaThresholdIntegral.java index 12d9947b2..68dd5a51c 100644 --- a/scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/localSauvola/ComputeLocalSauvolaThresholdIntegral.java +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/localSauvola/ComputeLocalSauvolaThresholdIntegral.java @@ -31,20 +31,22 @@ import net.imglib2.RandomAccessibleInterval; import net.imglib2.algorithm.neighborhood.RectangleNeighborhood; -import net.imglib2.converter.Converter; -import net.imglib2.converter.RealDoubleConverter; import net.imglib2.type.logic.BitType; import net.imglib2.type.numeric.RealType; import net.imglib2.type.numeric.real.DoubleType; +import net.imglib2.util.Intervals; import net.imglib2.view.composite.Composite; import org.scijava.function.Computers; +import org.scijava.ops.image.threshold.Thresholds; import org.scijava.ops.spi.OpDependency; import org.scijava.ops.spi.Nullable; /** *

- * Local thresholding algorithm as proposed by Sauvola et al. + * Local thresholding algorithm as proposed by Sauvola et al., with the + * population standard deviation and the defaults of + * {@link ComputeLocalSauvolaThreshold}. *

*

* This implementation improves execution speed by using integral images for the @@ -64,9 +66,6 @@ public class ComputeLocalSauvolaThresholdIntegral> Computers.Arity4>, T, Double, Double, BitType> { - public static final double DEFAULT_K = 0.5; - public static final double DEFAULT_R = 0.5; - @OpDependency(name = "stats.integralMean") private Computers.Arity1>, DoubleType> integralMeanOp; @@ -74,41 +73,34 @@ public class ComputeLocalSauvolaThresholdIntegral> private Computers.Arity1>, DoubleType> integralVarianceOp; /** - * TODO + * Applies Sauvola's rule to one pixel. * - * @param inputNeighborhood - * @param inputCenterPixel - * @param k - * @param r - * @param output + * @param inputNeighborhood the neighborhood of the pixel in the integral + * images of order 1 and 2 + * @param inputCenterPixel the pixel + * @param k the k parameter (default: 0.5) + * @param r the dynamic range of the standard deviation (default: half the + * nominal range of the pixel type, e.g. 128 for 8-bit images) + * @param output true if the pixel is foreground */ @Override public void compute( final RectangleNeighborhood> inputNeighborhood, - final T inputCenterPixel, @Nullable Double k, @Nullable Double r, - final BitType output) + final T inputCenterPixel, @Nullable final Double k, + @Nullable final Double r, final BitType output) { - if (k == null) k = DEFAULT_K; - if (r == null) r = DEFAULT_R; - final var mean = new DoubleType(); integralMeanOp.compute(inputNeighborhood, mean); - final var variance = new DoubleType(); integralVarianceOp.compute(inputNeighborhood, variance); - - final var stdDev = new DoubleType(Math.sqrt(variance.get())); - - final var threshold = new DoubleType(mean.getRealDouble() * (1.0d + - k * ((Math.sqrt(stdDev.getRealDouble()) / r) - 1.0))); - - // Set value - final Converter conv = new RealDoubleConverter<>(); - final var centerPixelAsDoubleType = variance; // NB: Reuse - // DoubleType - conv.convert(inputCenterPixel, centerPixelAsDoubleType); - - output.set(centerPixelAsDoubleType.compareTo(threshold) > 0); + final var n = Intervals.numElements(Intervals.expand(inputNeighborhood, + -1)); + final var stdDev = Math.sqrt(Thresholds.populationVariance(variance.get(), + n)); + output.set(ComputeLocalSauvolaThreshold.isForeground(inputCenterPixel + .getRealDouble(), mean.get(), stdDev, k == null + ? ComputeLocalSauvolaThreshold.DEFAULT_K : k, r == null + ? ComputeLocalSauvolaThreshold.defaultR(inputCenterPixel) : r)); } } diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/localSauvola/LocalSauvolaThreshold.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/localSauvola/LocalSauvolaThreshold.java index 87b659c17..6558d0ae9 100644 --- a/scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/localSauvola/LocalSauvolaThreshold.java +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/localSauvola/LocalSauvolaThreshold.java @@ -48,6 +48,14 @@ import org.scijava.ops.spi.OpDependency; /** + * Sauvola's local threshold: a pixel is foreground if it is strictly above + * {@code mean * (1 + k * (sd / r - 1))}, with {@code mean} and {@code sd} the + * mean and the population standard deviation (as in Fiji's + * Auto_Local_Threshold plugin) of its neighborhood. With the default + * parameters, the result on 8-bit images is that of Fiji (k = 0.5, r = 128); + * see {@link ComputeLocalSauvolaThreshold} for the defaults of other types. + * + * @see ComputeLocalSauvolaThreshold * @implNote op names='threshold.localSauvola', priority='-100.' */ public class LocalSauvolaThreshold> extends @@ -68,14 +76,17 @@ public class LocalSauvolaThreshold> extends private Computers.Arity4, Computers.Arity2, T, BitType>, Shape, OutOfBoundsFactory>, RandomAccessibleInterval> applyFilterOp; /** - * TODO + * Computes Sauvola's local threshold of an image. * - * @param input - * @param inputNeighborhoodShape - * @param k - * @param r - * @param outOfBoundsFactory - * @param output + * @param input the image to threshold + * @param inputNeighborhoodShape the shape of the local neighborhood + * @param k the k parameter (default: 0.5) + * @param r the dynamic range of the standard deviation, in the units of the + * image (default: half the nominal range of the pixel type, i.e. + * 128 for 8-bit images, 32768 for 16-bit images, and 0.5 for + * floating-point images, which are assumed to be in [0, 1]) + * @param outOfBoundsFactory the out-of-bounds strategy (default border) + * @param output the mask, true where a pixel is above its local threshold */ @Override public void compute(final RandomAccessibleInterval input, @@ -86,7 +97,8 @@ public void compute(final RandomAccessibleInterval input, { // Use integral images for sufficiently large windows. if (inputNeighborhoodShape instanceof RectangleShape && - ((RectangleShape) inputNeighborhoodShape).getSpan() > 2) + ((RectangleShape) inputNeighborhoodShape).getSpan() > 2 && + !((RectangleShape) inputNeighborhoodShape).isSkippingCenter()) { computeIntegral(input, (RectangleShape) inputNeighborhoodShape, k, r, outOfBoundsFactory, getIntegralImageOp(INTEGRAL_IMAGE_ORDER_1), diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/minError/ComputeMinErrorThreshold.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/minError/ComputeMinErrorThreshold.java index 713c83aa5..5a080a9ed 100644 --- a/scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/minError/ComputeMinErrorThreshold.java +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/minError/ComputeMinErrorThreshold.java @@ -35,7 +35,6 @@ import net.imglib2.histogram.Histogram1d; import net.imglib2.type.numeric.RealType; -import org.scijava.ops.spi.OpExecutionException; // NB - this plugin adapted from Gabriel Landini's code of his AutoThreshold // plugin found in Fiji (version 1.14). @@ -110,23 +109,16 @@ public static long computeBin(final long[] histogram) { // If the next threshold would be imaginary, return with the current // one. sqterm = (w1 * w1) - w0 * w2; - if (sqterm < 0) { - throw new OpExecutionException( - "MinError(I): not converging. Try \'Ignore black/white\' options"); - } + if (sqterm < 0) return threshold; // The updated threshold is the integer part of the solution of the // quadratic equation. Tprev = threshold; temp = (w1 + Math.sqrt(sqterm)) / w0; - if (Double.isNaN(temp)) { - // FIXME: log, do not throw exception. -// throw new OpExecutionException( -// "MinError(I): NaN, not converging. Try \'Ignore black/white\' options"); - threshold = Tprev; - } - threshold = (int) Math.floor(temp); + // Note: Like ImageJ, keep the current threshold if the solution is NaN. + if (Double.isNaN(temp)) threshold = Tprev; + else threshold = (int) Math.floor(temp); // IJ.log("Iter: "+ counter+++" t:"+threshold); } return threshold; diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/otsu/ComputeOtsuThreshold.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/otsu/ComputeOtsuThreshold.java index 1a5678b72..3b7732d6e 100644 --- a/scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/otsu/ComputeOtsuThreshold.java +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/otsu/ComputeOtsuThreshold.java @@ -33,9 +33,6 @@ import net.imglib2.histogram.Histogram1d; import net.imglib2.type.numeric.RealType; -// NB - this plugin adapted from Gabriel Landini's code of his AutoThreshold -// plugin found in Fiji (version 1.14). - /** * Implements Otsu's threshold method. * @@ -60,67 +57,43 @@ public long computeBin(final Histogram1d hist) { } /** - * Otsu's threshold algorithm
- * C++ code by Jordan Bevik <Jordan.Bevic@qtiworld.com>
- * ported to ImageJ plugin by G.Landini + * Computes the bin maximizing the between-class variance + * {@code (mu_T * omega(k) - mu(k))^2 / (omega(k) * (1 - omega(k)))}, where + * {@code omega(k)} and {@code mu(k)} are the zeroth and first cumulative + * moments of the normalized histogram up to bin {@code k}, and {@code mu_T} + * is the mean (Otsu 1979, eq. 18). All bins are candidates, and ties are + * resolved in favor of the lowest bin, as in ImageJ Auto_Threshold since + * 2017. + *

+ * N. Otsu, "A threshold selection method from gray-level histograms," IEEE + * Trans. Systems, Man, and Cybernetics, vol. 9, no. 1, pp. 62-66, 1979. + *

*/ public static long computeBin(final long[] histogram) { - int k, kStar; // k = the current threshold; kStar = optimal threshold - final var L = histogram.length; // The total intensity of the image - long N1, N; // N1 = # points with intensity <=k; N = total number of - // points - long Sk; // The total intensity for all histogram points <=k - long S; - double BCV, BCVmax; // The current Between Class Variance and maximum - // BCV - double num, denom; // temporary bookkeeping - - // Initialize values: - S = 0; - N = 0; - for (k = 0; k < L; k++) { - S += k * histogram[k]; // Total histogram intensity - N += histogram[k]; // Total number of data points + long total = 0; + double sum = 0; + for (var i = 0; i < histogram.length; i++) { + total += histogram[i]; + sum += (double) i * histogram[i]; } - - Sk = 0; - N1 = histogram[0]; // The entry for zero intensity - BCV = 0; - BCVmax = 0; - kStar = 0; - - // Look at each possible threshold value, - // calculate the between-class variance, and decide if it's a max - for (k = 1; k < L - 1; k++) { // No need to check endpoints k = 0 or k = - // L-1 - Sk += k * histogram[k]; - N1 += histogram[k]; - - // The float casting here is to avoid compiler warning about loss of - // precision and - // will prevent overflow in the case of large saturated images - denom = (double) (N1) * (N - N1); // Maximum value of denom is - // (N^2)/4 = - // approx. 3E10 - - if (denom != 0) { - // Float here is to avoid loss of precision when dividing - num = ((double) N1 / N) * S - Sk; // Maximum value of num = - // 255*N = - // approx 8E7 - BCV = (num * num) / denom; - } - else BCV = 0; - - if (BCV >= BCVmax) { // Assign the best threshold found so far - BCVmax = BCV; - kStar = k; + final var meanTotal = sum / total; + // Note: Using counts rather than probabilities scales all variances by + // the same factor, and lets empty classes be detected exactly. + long count = 0; + double moment = 0, best = Double.NEGATIVE_INFINITY; + var bestBin = 0; + for (var k = 0; k < histogram.length; k++) { + count += histogram[k]; + moment += (double) k * histogram[k]; + if (count == 0 || count == total) continue; + final var d = meanTotal * count - moment; + final var bcv = d * d / ((double) count * (total - count)); + if (bcv > best) { + best = bcv; + bestBin = k; } } - // kStar += 1; // Use QTI convention that intensity -> 1 if intensity >= - // k - // (the algorithm was developed for I-> 1 if I <= k.) - return kStar; + return bestBin; } } diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/rats/Rats.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/rats/Rats.java new file mode 100644 index 000000000..fd17c2ad6 --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/rats/Rats.java @@ -0,0 +1,97 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.threshold.rats; + +import java.util.function.BiFunction; + +import net.imglib2.Dimensions; +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.type.logic.BitType; +import net.imglib2.type.numeric.RealType; +import net.imglib2.view.Views; + +import org.scijava.function.Functions; +import org.scijava.ops.spi.Nullable; +import org.scijava.ops.spi.OpDependency; + +/** + * Robust Automatic Threshold Selection (RATS): thresholds an image against + * the local, gradient-weighted threshold surface computed by + * {@code threshold.ratsSurface} (see there for the precise algorithm). A + * pixel is foreground if its value exceeds the threshold at its position. + *

+ * Based on the method of Kittler, Illingworth and Föglein (1985) and + * Wilkinson (1998). + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @param input pixel type + * @see "J. Kittler, J. Illingworth and J. Föglein, Threshold selection + * based on a simple image statistic, Comput. Vis. Graph. Image Process. + * 30:125-147, 1985" + * @see "M. H. F. Wilkinson, Optimizing edge detectors for robust automatic + * threshold selection: coping with edge curvature and noise, Graph. + * Models Image Process. 60(5):385-401, 1998" + * @implNote op names='threshold.rats' + */ +public class Rats> implements + Functions.Arity4, Double, Double, Integer, RandomAccessibleInterval> +{ + + @OpDependency(name = "create.img") + private BiFunction> createOp; + + /** + * Thresholds an image with RATS. + * + * @param input the image to threshold + * @param sigma the standard deviation of the image noise (default: estimated + * from the image) + * @param lambda the noise gate, in multiples of sigma (default: 3) + * @param minLeafSize the side length, in pixels, of the smallest regions of + * the tree (default: 32) + * @return a binary image, true where the input exceeds the local threshold + */ + @Override + public RandomAccessibleInterval apply( + final RandomAccessibleInterval input, @Nullable final Double sigma, + @Nullable final Double lambda, @Nullable final Integer minLeafSize) + { + final var surface = RatsSurface.surface(input, sigma, lambda, + minLeafSize); + final var output = createOp.apply(input, new BitType()); + final var c = Views.flatIterable(input).cursor(); + var i = 0; + for (final var t : Views.flatIterable(output)) + t.set(c.next().getRealDouble() > surface[i++]); + return output; + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/rats/RatsSurface.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/rats/RatsSurface.java new file mode 100644 index 000000000..a321adba6 --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/rats/RatsSurface.java @@ -0,0 +1,390 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.threshold.rats; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.function.BiFunction; + +import net.imglib2.Dimensions; +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.type.numeric.RealType; +import net.imglib2.type.numeric.real.FloatType; +import net.imglib2.util.Intervals; +import net.imglib2.view.Views; + +import org.scijava.function.Functions; +import org.scijava.ops.spi.Nullable; +import org.scijava.ops.spi.OpDependency; + +/** + * Computes the local threshold surface of Robust Automatic Threshold + * Selection (RATS), following Kittler, Illingworth and Föglein (1985) + * and its hierarchical, noise-robust extension by Wilkinson (1998). + *

+ * The idea of RATS is that pixels on object boundaries, where the gradient is + * strong, have intensities halfway between object and background, so the + * gradient-weighted mean intensity {@code sum(e I) / sum(e)} is a good + * threshold. To adapt to uneven illumination, this threshold is computed in + * regions of a 2^n-tree (a quadtree in 2D) and interpolated. Precisely, this + * op implements the following: + *

+ *
    + *
  1. The edge strength {@code e} of each pixel is the magnitude of the + * gradient {@code g} computed by central differences + * {@code g_d = (I(x + 1) - I(x - 1)) / 2} along every dimension {@code d}, + * with border pixels replicated outside the image. The intensity paired with + * {@code e} is not {@code I(x)} itself but the mean + * {@code m_d = (I(x + 1) + I(x - 1)) / 2} of the two pixels of each + * difference, averaged over the dimensions with weights {@code g_d^2}. For + * an ideal step edge, this is exactly the mid-level on both sides of the + * step, so a region containing only one side of a boundary still gets an + * unbiased threshold.
  2. + *
  3. Noise gate: {@code e} is set to zero where {@code e < lambda * sigma}, + * with {@code sigma} the standard deviation of the image noise.
  4. + *
  5. The image is divided into leaf regions of {@code minLeafSize} pixels in + * every dimension (smaller at the far borders). Each level of the tree above + * merges up to 2^n neighboring regions (2 along each dimension) until a + * single root region covers the image. Each region accumulates + * {@code sum(e)} and {@code sum(e m)}.
  6. + *
  7. Minimum edge strength: a region is reliable if + * {@code sum(e) > lambda * sigma * minLeafSize^(n - 1)} in n dimensions, which + * is roughly the edge strength of a single boundary, with a step just above + * the noise gate, crossing a leaf region (a boundary of length + * {@code minLeafSize} in 2D, of area {@code minLeafSize^2} in 3D). A reliable + * region gets the threshold {@code sum(e m) / sum(e)}; an + * unreliable region inherits the threshold of its parent. If the root is not + * reliable either (which means that the image has no edges at all), it gets + * the maximum intensity of the image, so that no pixel is foreground.
  8. + *
  9. The leaf thresholds are assigned to the centers of the leaf regions and + * interpolated n-linearly in between; beyond the outermost centers, the + * nearest value is used.
  10. + *
+ *

+ * If {@code sigma} is not given, it is estimated robustly from the + * differences {@code d} of horizontally neighboring pixels (along dimension + * 0) as {@code 1.4826 * MAD(d) / sqrt(2)}, which is insensitive to edges + * and to smooth illumination gradients. On quantized images with little + * noise (such as an 8-bit background where most neighbors are equal), more + * than half of the differences are 0, so this estimate is 0, and any + * fluctuation by a single grey level would count as an edge. Therefore, if + * all (non-NaN) pixel values are integers, the estimate is never less than + * {@code 1 / sqrt(12)}, the standard deviation of the noise caused by + * rounding to integers, so that by default (lambda = 3) a fluctuation by one + * grey level is below the noise gate. Images with non-integer values get no + * such floor. + *

+ *

+ * NaN pixels are ignored: they contribute no edges and no noise samples, and + * are never foreground. The image is processed single-threaded in flat + * arrays, so it is limited to 231 pixels. + *

+ *

+ * This implementation was written from the general description of the + * method, without reference to any existing RATS code. The gradient + * operator, the use of {@code m}, the reliability criterion, the fallback for + * the root and the noise estimate are choices of this implementation, so + * results differ in detail from the papers and from other RATS + * implementations (such as the RATS plugin of Fiji). + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @param input pixel type + * @see "J. Kittler, J. Illingworth and J. Föglein, Threshold selection + * based on a simple image statistic, Comput. Vis. Graph. Image Process. + * 30:125-147, 1985" + * @see "M. H. F. Wilkinson, Optimizing edge detectors for robust automatic + * threshold selection: coping with edge curvature and noise, Graph. + * Models Image Process. 60(5):385-401, 1998" + * @implNote op names='threshold.ratsSurface' + */ +public class RatsSurface> implements + Functions.Arity4, Double, Double, Integer, RandomAccessibleInterval> +{ + + @OpDependency(name = "create.img") + private BiFunction> createOp; + + /** + * Computes the RATS threshold surface. + * + * @param input the image to threshold + * @param sigma the standard deviation of the image noise (default: estimated + * from the image) + * @param lambda the noise gate, in multiples of sigma (default: 3) + * @param minLeafSize the side length, in pixels, of the smallest regions of + * the tree (default: 32) + * @return the threshold of every pixel + */ + @Override + public RandomAccessibleInterval apply( + final RandomAccessibleInterval input, @Nullable final Double sigma, + @Nullable final Double lambda, @Nullable final Integer minLeafSize) + { + final var surface = surface(input, sigma, lambda, minLeafSize); + final var output = createOp.apply(input, new FloatType()); + var i = 0; + for (final var t : Views.flatIterable(output)) + t.setReal(surface[i++]); + return output; + } + + /** Computes the threshold surface, in flat iteration order. */ + static > double[] surface( + final RandomAccessibleInterval input, final Double sigma, + final Double lambda, final Integer minLeafSize) + { + final var n = input.numDimensions(); + final var size = Intervals.numElements(input); + if (size > Integer.MAX_VALUE) { + throw new IllegalArgumentException("Image is too large: " + size + + " pixels"); + } + final var count = (int) size; + final double lam = lambda == null ? 3 : lambda; + final int leaf = minLeafSize == null ? 32 : minLeafSize; + if (leaf < 1) { + throw new IllegalArgumentException("Invalid leaf size: " + leaf); + } + final var dims = new int[n]; + for (var d = 0; d < n; d++) + dims[d] = (int) input.dimension(d); + final var strides = new int[n]; + for (var d = 0; d < n; d++) + strides[d] = d == 0 ? 1 : strides[d - 1] * dims[d - 1]; + + final var img = new double[count]; + var i = 0; + for (final var t : Views.flatIterable(input)) + img[i++] = t.getRealDouble(); + final var noise = sigma == null ? estimateNoise(img, dims) : sigma; + final var gate = lam * noise; + + // Leaf grid and tree levels. + final var levels = new ArrayList(); + var cells = new int[n]; + for (var d = 0; d < n; d++) + cells[d] = (dims[d] + leaf - 1) / leaf; + levels.add(cells); + while (Arrays.stream(cells).anyMatch(c -> c > 1)) { + final var parent = new int[n]; + for (var d = 0; d < n; d++) + parent[d] = (cells[d] + 1) / 2; + levels.add(parent); + cells = parent; + } + final var nLevels = levels.size(); + final var sumE = new double[nLevels][]; + final var sumEI = new double[nLevels][]; + for (var l = 0; l < nLevels; l++) { + sumE[l] = new double[product(levels.get(l))]; + sumEI[l] = new double[sumE[l].length]; + } + + // Edge strengths, accumulated into the leaves. + final var leafCells = levels.get(0); + final var pos = new int[n]; + var max = Double.NEGATIVE_INFINITY; + for (i = 0; i < count; i++) { + var g2 = 0.0; + var gm = 0.0; + var cell = 0; + var cellStride = 1; + for (var d = 0; d < n; d++) { + final var lo = pos[d] > 0 ? i - strides[d] : i; + final var hi = pos[d] < dims[d] - 1 ? i + strides[d] : i; + final var g = (img[hi] - img[lo]) / 2; + g2 += g * g; + gm += g * g * (img[hi] + img[lo]) / 2; + cell += pos[d] / leaf * cellStride; + cellStride *= leafCells[d]; + } + final var e = Math.sqrt(g2); + if (e >= gate && e > 0) { + sumE[0][cell] += e; + sumEI[0][cell] += e * gm / g2; + } + if (img[i] > max) max = img[i]; + increment(pos, dims); + } + + // Sums of the upper levels. + for (var l = 1; l < nLevels; l++) { + final var child = levels.get(l - 1); + final var parent = levels.get(l); + final var cpos = new int[n]; + for (var c = 0; c < sumE[l - 1].length; c++) { + var p = 0; + var stride = 1; + for (var d = 0; d < n; d++) { + p += cpos[d] / 2 * stride; + stride *= parent[d]; + } + sumE[l][p] += sumE[l - 1][c]; + sumEI[l][p] += sumEI[l - 1][c]; + increment(cpos, child); + } + } + + // Thresholds, from the root down to the leaves. + final var minEdge = gate * Math.pow(leaf, n - 1); + var thresholds = new double[] { sumE[nLevels - 1][0] > minEdge + ? sumEI[nLevels - 1][0] / sumE[nLevels - 1][0] : max }; + for (var l = nLevels - 2; l >= 0; l--) { + final var child = levels.get(l); + final var parent = levels.get(l + 1); + final var next = new double[sumE[l].length]; + final var cpos = new int[n]; + for (var c = 0; c < next.length; c++) { + var p = 0; + var stride = 1; + for (var d = 0; d < n; d++) { + p += cpos[d] / 2 * stride; + stride *= parent[d]; + } + next[c] = sumE[l][c] > minEdge ? sumEI[l][c] / sumE[l][c] + : thresholds[p]; + increment(cpos, child); + } + thresholds = next; + } + + return interpolate(thresholds, leafCells, dims, leaf); + } + + /** Interpolates leaf thresholds n-linearly between leaf centers. */ + private static double[] interpolate(final double[] leafValues, + final int[] cells, final int[] dims, final int leaf) + { + final var n = dims.length; + // Per dimension and coordinate: lower cell index and weight of the upper. + final var lower = new int[n][]; + final var weight = new double[n][]; + for (var d = 0; d < n; d++) { + final var centers = new double[cells[d]]; + for (var c = 0; c < cells[d]; c++) { + final var end = Math.min((c + 1) * leaf, dims[d]) - 1; + centers[c] = (c * leaf + end) / 2.0; + } + lower[d] = new int[dims[d]]; + weight[d] = new double[dims[d]]; + var c = 0; + for (var x = 0; x < dims[d]; x++) { + while (c < cells[d] - 2 && x >= centers[c + 1]) + c++; + lower[d][x] = c; + if (cells[d] == 1) weight[d][x] = 0; + else { + final var w = (x - centers[c]) / (centers[c + 1] - centers[c]); + weight[d][x] = Math.max(0, Math.min(1, w)); + } + } + } + final var cellStrides = new int[n]; + for (var d = 0; d < n; d++) + cellStrides[d] = d == 0 ? 1 : cellStrides[d - 1] * cells[d - 1]; + final var result = new double[product(dims)]; + final var pos = new int[n]; + final var corners = 1 << n; + for (var i = 0; i < result.length; i++) { + // Note: values are interpolated relative to the first corner, so that + // equal corner values are reproduced exactly. + var baseCell = 0; + for (var d = 0; d < n; d++) + baseCell += lower[d][pos[d]] * cellStrides[d]; + final var base = leafValues[baseCell]; + var value = 0.0; + for (var k = 1; k < corners; k++) { + var w = 1.0; + var cell = 0; + for (var d = 0; d < n && w > 0; d++) { + final var up = (k >> d & 1) == 1; + final var wd = weight[d][pos[d]]; + w *= up ? wd : 1 - wd; + cell += (lower[d][pos[d]] + (up ? 1 : 0)) * cellStrides[d]; + } + if (w > 0) value += w * (leafValues[cell] - base); + } + result[i] = base + value; + increment(pos, dims); + } + return result; + } + + /** + * Estimates the noise standard deviation from the differences of + * neighbors along dimension 0, floored at the rounding noise of integers. + */ + static double estimateNoise(final double[] img, final int[] dims) { + if (dims[0] < 2) return 0; + final var diffs = new double[img.length / dims[0] * (dims[0] - 1)]; + var k = 0; + var integers = true; + for (final var v : img) + if (!Double.isNaN(v) && v != Math.rint(v)) integers = false; + for (var i = 0; i < img.length; i++) { + if (i % dims[0] < dims[0] - 1) { + final var d = img[i + 1] - img[i]; + if (!Double.isNaN(d)) diffs[k++] = d; + } + } + if (k == 0) return 0; + final var valid = Arrays.copyOf(diffs, k); + final var median = median(valid); + for (var j = 0; j < valid.length; j++) + valid[j] = Math.abs(valid[j] - median); + final var mad = 1.4826 * median(valid) / Math.sqrt(2); + return integers ? Math.max(mad, 1 / Math.sqrt(12)) : mad; + } + + private static double median(final double[] values) { + final var sorted = values.clone(); + Arrays.sort(sorted); + final var m = sorted.length / 2; + return sorted.length % 2 == 1 ? sorted[m] : (sorted[m - 1] + sorted[m]) / + 2; + } + + private static int product(final int[] dims) { + var p = 1; + for (final var d : dims) + p *= d; + return p; + } + + /** Advances a position in flat iteration order. */ + private static void increment(final int[] pos, final int[] dims) { + for (var d = 0; d < pos.length; d++) { + if (++pos[d] < dims[d]) return; + pos[d] = 0; + } + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/transform/bin/Bins.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/transform/bin/Bins.java new file mode 100644 index 000000000..e62f8b0ad --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/transform/bin/Bins.java @@ -0,0 +1,237 @@ +/*- + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.transform.bin; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; + +import net.imglib2.Dimensions; +import net.imglib2.FinalDimensions; +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.iterator.LocalizingZeroMinIntervalIterator; +import net.imglib2.type.numeric.IntegerType; +import net.imglib2.type.numeric.RealType; +import net.imglib2.view.Views; + +/** + * Utility methods backing the {@code transform.bin} ops. + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +final class Bins { + + private Bins() { + // NB: Prevent instantiation of utility class. + } + + /** The supported block reduction methods. */ + enum Method { + MEAN, SUM, MIN, MAX, MEDIAN, MODE + } + + /** + * Parses the given reduction method name (case-insensitive). A {@code null} + * name yields {@link Method#MEAN}. + */ + static Method method(final String name) { + if (name == null) return Method.MEAN; + switch (name.trim().toLowerCase(Locale.ROOT)) { + case "mean": + case "average": + return Method.MEAN; + case "sum": + return Method.SUM; + case "min": + return Method.MIN; + case "max": + return Method.MAX; + case "median": + return Method.MEDIAN; + case "mode": + return Method.MODE; + default: + throw new IllegalArgumentException("Unknown bin method: '" + name + + "'. Expected one of: mean, sum, min, max, median, mode."); + } + } + + /** + * Validates the given bin factors against an image of dimensionality + * {@code numDimensions}, padding missing trailing factors with 1. + */ + static long[] factors(final long[] factors, final int numDimensions) { + if (factors == null) throw new NullPointerException( + "Bin factors must be given"); + if (factors.length > numDimensions) throw new IllegalArgumentException( + "Too many bin factors (" + factors.length + ") for a " + numDimensions + + "-dimensional image"); + final var f = new long[numDimensions]; + Arrays.fill(f, 1); + for (var d = 0; d < factors.length; d++) { + if (factors[d] < 1) throw new IllegalArgumentException( + "Bin factors must be positive, but factor " + d + " is " + factors[d]); + f[d] = factors[d]; + } + return f; + } + + /** + * Computes the dimensions of the binned image: each input dimension divided + * by its bin factor, rounded down (incomplete trailing blocks are dropped). + */ + static Dimensions outputDimensions(final Dimensions input, + final long[] factors) + { + final var dims = new long[input.numDimensions()]; + for (var d = 0; d < dims.length; d++) { + dims[d] = input.dimension(d) / factors[d]; + if (dims[d] < 1) throw new IllegalArgumentException("Bin factor " + + factors[d] + " exceeds size " + input.dimension(d) + " of dimension " + + d); + } + return new FinalDimensions(dims); + } + + /** + * Bins {@code input} into {@code output}, whose dimensions must equal + * {@link #outputDimensions(Dimensions, long[])}. + */ + static , O extends RealType> void bin( + final RandomAccessibleInterval input, final long[] factors, + final Method method, final RandomAccessibleInterval output) + { + final var n = input.numDimensions(); + final var expected = outputDimensions(input, factors); + if (output.numDimensions() != n) throw new IllegalArgumentException( + "Output must have " + n + " dimensions"); + for (var d = 0; d < n; d++) { + if (output.dimension(d) != expected.dimension(d)) { + throw new IllegalArgumentException("Output dimension " + d + " is " + + output.dimension(d) + " but should be " + expected.dimension(d)); + } + } + + long blockSize = 1; + for (final var f : factors) + blockSize *= f; + if (blockSize > Integer.MAX_VALUE) throw new IllegalArgumentException( + "Bin block too large: " + blockSize + " elements"); + final var values = new double[(int) blockSize]; + + final var outType = output.getType(); + final var round = outType instanceof IntegerType; + final var outMin = outType.getMinValue(); + final var outMax = outType.getMaxValue(); + + final var inRA = input.randomAccess(); + final var outCursor = Views.flatIterable(output).localizingCursor(); + final var block = new LocalizingZeroMinIntervalIterator(factors); + final var blockMin = new long[n]; + final Map counts = method == Method.MODE ? new HashMap<>() + : null; + + while (outCursor.hasNext()) { + outCursor.fwd(); + for (var d = 0; d < n; d++) { + blockMin[d] = input.min(d) + // + (outCursor.getLongPosition(d) - output.min(d)) * factors[d]; + } + block.reset(); + var i = 0; + while (block.hasNext()) { + block.fwd(); + for (var d = 0; d < n; d++) { + inRA.setPosition(blockMin[d] + block.getLongPosition(d), d); + } + values[i++] = inRA.get().getRealDouble(); + } + + var v = reduce(values, method, counts); + // Note: Values are rounded half up, as ImageJ's Image > Transform > Bin does. + if (round) v = Math.floor(v + 0.5); + outCursor.get().setReal(Math.max(outMin, Math.min(outMax, v))); + } + } + + private static double reduce(final double[] values, final Method method, + final Map counts) + { + switch (method) { + case SUM: { + double sum = 0; + for (final var v : values) + sum += v; + return sum; + } + case MIN: { + var min = Double.POSITIVE_INFINITY; + for (final var v : values) + if (v < min) min = v; + return min; + } + case MAX: { + var max = Double.NEGATIVE_INFINITY; + for (final var v : values) + if (v > max) max = v; + return max; + } + case MEDIAN: { + Arrays.sort(values); + final var k = values.length / 2; + return values.length % 2 == 0 ? 0.5 * (values[k - 1] + values[k]) + : values[k]; + } + case MODE: { + counts.clear(); + var mode = values[0]; + var best = 0; + for (final var v : values) { + final int c = counts.merge(v, 1, Integer::sum); + // Note: Ties are resolved in favor of the smallest value. + if (c > best || (c == best && v < mode)) { + best = c; + mode = v; + } + } + return mode; + } + case MEAN: + default: { + double sum = 0; + for (final var v : values) + sum += v; + return sum / values.length; + } + } + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/transform/bin/DefaultBin.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/transform/bin/DefaultBin.java new file mode 100644 index 000000000..490959c4d --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/transform/bin/DefaultBin.java @@ -0,0 +1,89 @@ +/*- + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.transform.bin; + +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.type.numeric.RealType; + +import org.scijava.function.Computers; +import org.scijava.ops.spi.Nullable; + +/** + * Bins an image: downsamples it by an integer factor along each dimension, + * reducing each block of {@code factors[0] × factors[1] × ...} input samples + * to a single output sample. + *

+ * Supported reduction methods are {@code "mean"} (the default), {@code "sum"}, + * {@code "min"}, {@code "max"}, {@code "median"} and {@code "mode"} (the most + * frequent value, ties resolved toward the smallest value; the right choice + * for label images). Each output dimension is the corresponding input + * dimension divided by its factor, rounded down: incomplete blocks at the + * upper edges are discarded, as in ImageJ's Image > Transform > + * Bin. Results are clamped to the range of the output type, and rounded + * (half up) for integer output types. + *

+ *

+ * Unlike {@code transform.subsampleView}, which simply decimates, this op + * aggregates every input sample. + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @param input type + * @param output type + * @implNote op names='transform.bin' + */ +public class DefaultBin, O extends RealType> + implements + Computers.Arity3, long[], String, RandomAccessibleInterval> +{ + + /** + * Bins {@code input} into {@code output}. + * + * @param input the image to bin + * @param factors the bin factor for each dimension; must be positive. If + * fewer factors than dimensions are given, the remaining + * dimensions are not binned (factor 1). + * @param method the block reduction method: {@code "mean"} (default), + * {@code "sum"}, {@code "min"}, {@code "max"}, {@code "median"} or + * {@code "mode"} + * @param output the binned image, with each dimension {@code d} of size + * {@code floor(input.dimension(d) / factors[d])} + */ + @Override + public void compute(final RandomAccessibleInterval input, + final long[] factors, @Nullable final String method, + final RandomAccessibleInterval output) + { + final var f = Bins.factors(factors, input.numDimensions()); + Bins.bin(input, f, Bins.method(method), output); + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/transform/bin/DefaultBinFunction.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/transform/bin/DefaultBinFunction.java new file mode 100644 index 000000000..d8ebcbe81 --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/transform/bin/DefaultBinFunction.java @@ -0,0 +1,91 @@ +/*- + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.transform.bin; + +import java.util.function.BiFunction; + +import net.imglib2.Dimensions; +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.type.NativeType; +import net.imglib2.type.numeric.RealType; + +import org.scijava.function.Functions; +import org.scijava.ops.spi.Nullable; +import org.scijava.ops.spi.OpDependency; + +/** + * Bins an image into a new image of the same type: downsamples it by an + * integer factor along each dimension, reducing each block of input samples + * to a single output sample. See {@link DefaultBin} for details. + *

+ * Note that results are clamped to the range of the input type, so + * {@code "sum"} binning of integer images can saturate; to avoid that, call + * the computer version of {@code transform.bin} with a wider output type. + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @param image type + * @implNote op names='transform.bin' + */ +public class DefaultBinFunction & NativeType> + implements + Functions.Arity3, long[], String, RandomAccessibleInterval> +{ + + @OpDependency(name = "create.img") + private BiFunction> createOp; + + /** + * Bins {@code input} into a newly created image. + * + * @param input the image to bin + * @param factors the bin factor for each dimension; must be positive. If + * fewer factors than dimensions are given, the remaining + * dimensions are not binned (factor 1). + * @param method the block reduction method: {@code "mean"} (default), + * {@code "sum"}, {@code "min"}, {@code "max"}, {@code "median"} or + * {@code "mode"} + * @return the binned image, with each dimension {@code d} of size + * {@code floor(input.dimension(d) / factors[d])}, and zero min + */ + @Override + public RandomAccessibleInterval apply( + final RandomAccessibleInterval input, final long[] factors, + @Nullable final String method) + { + final var f = Bins.factors(factors, input.numDimensions()); + final var m = Bins.method(method); + final var output = createOp.apply(Bins.outputDimensions(input, f), input + .getType().createVariable()); + Bins.bin(input, f, m, output); + return output; + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/transform/focus/FocusStack.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/transform/focus/FocusStack.java new file mode 100644 index 000000000..23f69ec7b --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/transform/focus/FocusStack.java @@ -0,0 +1,122 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.transform.focus; + +import java.util.function.BiFunction; + +import net.imglib2.Dimensions; +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.outofbounds.OutOfBoundsFactory; +import net.imglib2.type.numeric.RealType; +import net.imglib2.type.numeric.real.FloatType; +import net.imglib2.view.Views; + +import org.scijava.function.Computers; +import org.scijava.function.Functions; +import org.scijava.ops.spi.Nullable; +import org.scijava.ops.spi.OpDependency; + +/** + * Extended depth of field (focus stacking): combines the slices of a focal + * series into a single image that is in focus everywhere. + *

+ * For every slice {@code z} along {@code focusDim}, a per-pixel focus measure + * {@code M_z = G_sigma * (Lap(G_1 * I_z))^2} is computed: the squared + * Laplacian of the slice after light Gaussian smoothing (sigma 1, to suppress + * pixel noise), averaged over a Gaussian window of the given sigma. This is + * a local variance of the Laplacian, a standard sharpness measure + * (Pech-Pacheco et al. 2000). Smoothing and the Laplacian are computed within + * each slice, with border extension. The slices are then combined per pixel: + *

+ *
    + *
  • {@code "max"}: the value of the slice with the highest focus measure + * (the first such slice in case of ties). The slice indices are available + * from {@code transform.focusStackIndex}.
  • + *
  • {@code "weighted"}: the mean of all slices weighted by their focus + * measure, which gives smoother transitions between slices. Pixels where all + * weights are zero get the unweighted mean.
  • + *
+ *

+ * Implemented from the published descriptions of focus measures; the + * weighted mode is similar in spirit to the Gaussian_Stack_Focuser plugin of + * Fiji, which weights slices by the local magnitude of the difference from a + * Gaussian-blurred copy. + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @param input pixel type + * @see J. L. Pech-Pacheco + * et al., "Diatom autofocusing in brightfield microscopy: a comparative + * study", Proc. ICPR 2000, 3:314-317 + * @implNote op names='transform.focusStack' + */ +public class FocusStack> implements + Functions.Arity4, Integer, Double, String, RandomAccessibleInterval> +{ + + @OpDependency(name = "create.img") + private BiFunction> createOp; + + @OpDependency(name = "filter.gauss") + private Computers.Arity3, double[], OutOfBoundsFactory>, RandomAccessibleInterval> gaussOp; + + @OpDependency(name = "filter.gauss") + private Computers.Arity3, double[], OutOfBoundsFactory>, RandomAccessibleInterval> gaussFloatOp; + + /** + * Computes the extended depth of field image. + * + * @param input the focal series + * @param focusDim the index of the focus (z) dimension + * @param sigma the standard deviation, in pixels, of the Gaussian window + * over which the focus measure is averaged (default: 2) + * @param method how to combine the slices: max or weighted (default: max) + * @return the composite image, with the focus dimension removed + */ + @Override + public RandomAccessibleInterval apply( + final RandomAccessibleInterval input, final Integer focusDim, + @Nullable final Double sigma, @Nullable final String method) + { + final var m = method == null ? "max" : method; + if (!m.equals("max") && !m.equals("weighted")) { + throw new IllegalArgumentException("Unknown method: " + m); + } + final var stacker = new FocusStacker<>(input, focusDim, sigma, m.equals( + "weighted"), createOp, gaussOp, gaussFloatOp); + final var output = createOp.apply(Views.hyperSlice(input, focusDim, input + .min(focusDim)), new FloatType()); + var i = 0; + for (final var t : Views.flatIterable(output)) + t.setReal(stacker.composite[i++]); + return output; + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/transform/focus/FocusStackIndex.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/transform/focus/FocusStackIndex.java new file mode 100644 index 000000000..e9bbdf40a --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/transform/focus/FocusStackIndex.java @@ -0,0 +1,97 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.transform.focus; + +import java.util.function.BiFunction; + +import net.imglib2.Dimensions; +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.outofbounds.OutOfBoundsFactory; +import net.imglib2.type.numeric.RealType; +import net.imglib2.type.numeric.integer.IntType; +import net.imglib2.type.numeric.real.FloatType; +import net.imglib2.view.Views; + +import org.scijava.function.Computers; +import org.scijava.function.Functions; +import org.scijava.ops.spi.Nullable; +import org.scijava.ops.spi.OpDependency; + +/** + * Computes the index map (height map) of a focal series: for every pixel, the + * index of the slice with the highest focus measure, as used by the max + * method of {@code transform.focusStack} (see there for the focus measure). + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @param input pixel type + * @implNote op names='transform.focusStackIndex' + */ +public class FocusStackIndex> implements + Functions.Arity3, Integer, Double, RandomAccessibleInterval> +{ + + @OpDependency(name = "create.img") + private BiFunction> createOp; + + @OpDependency(name = "filter.gauss") + private Computers.Arity3, double[], OutOfBoundsFactory>, RandomAccessibleInterval> gaussOp; + + @OpDependency(name = "filter.gauss") + private Computers.Arity3, double[], OutOfBoundsFactory>, RandomAccessibleInterval> gaussFloatOp; + + @OpDependency(name = "create.img") + private BiFunction> createIntOp; + + /** + * Computes the index of the sharpest slice per pixel. + * + * @param input the focal series + * @param focusDim the index of the focus (z) dimension + * @param sigma the standard deviation, in pixels, of the Gaussian window + * over which the focus measure is averaged (default: 2) + * @return the slice indices (starting at 0), with the focus dimension + * removed + */ + @Override + public RandomAccessibleInterval apply( + final RandomAccessibleInterval input, final Integer focusDim, + @Nullable final Double sigma) + { + final var stacker = new FocusStacker<>(input, focusDim, sigma, false, + createOp, gaussOp, gaussFloatOp); + final var output = createIntOp.apply(Views.hyperSlice(input, focusDim, + input.min(focusDim)), new IntType()); + var i = 0; + for (final var t : Views.flatIterable(output)) + t.set(stacker.index[i++]); + return output; + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/transform/focus/FocusStacker.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/transform/focus/FocusStacker.java new file mode 100644 index 000000000..ae755f54a --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/transform/focus/FocusStacker.java @@ -0,0 +1,163 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.transform.focus; + +import java.util.Arrays; +import java.util.function.BiFunction; + +import net.imglib2.Dimensions; +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.outofbounds.OutOfBoundsBorderFactory; +import net.imglib2.outofbounds.OutOfBoundsFactory; +import net.imglib2.type.numeric.RealType; +import net.imglib2.type.numeric.real.FloatType; +import net.imglib2.util.Intervals; +import net.imglib2.view.Views; + +import org.scijava.function.Computers; + +/** + * Computes a per-pixel focus measure for every slice of a stack and combines + * the slices into an extended depth of field image. + *

+ * The focus measure of slice {@code z} is {@code M_z = G_sigma * (Lap(G_1 * + * I_z))^2}: the local energy of the Laplacian of the slice (after Gaussian + * smoothing with sigma 1 to suppress pixel noise), averaged with a Gaussian + * window of the given sigma. Since the Laplacian has zero mean over a + * neighborhood, this is a local variance of the Laplacian, a standard + * sharpness measure (Pech-Pacheco et al. 2000). + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +final class FocusStacker> { + + /** The index of the sharpest slice per pixel, in flat iteration order. */ + final int[] index; + + /** The composite value per pixel, in flat iteration order. */ + final double[] composite; + + FocusStacker(final RandomAccessibleInterval input, final int focusDim, + final Double sigma, final boolean weighted, + final BiFunction> createOp, + final Computers.Arity3, double[], OutOfBoundsFactory>, RandomAccessibleInterval> gaussOp, + final Computers.Arity3, double[], OutOfBoundsFactory>, RandomAccessibleInterval> gaussFloatOp) + { + final var n = input.numDimensions(); + if (n < 2) { + throw new IllegalArgumentException( + "Need at least two dimensions, but got " + n); + } + if (focusDim < 0 || focusDim >= n) { + throw new IllegalArgumentException("Invalid focus dimension: " + + focusDim); + } + final var s = sigma == null ? 2.0 : sigma; + final var first = Views.hyperSlice(input, focusDim, input.min(focusDim)); + final var size = Intervals.numElements(first); + if (size > Integer.MAX_VALUE) { + throw new IllegalArgumentException("Slices are too large: " + size + + " pixels"); + } + final var count = (int) size; + final var noiseSigmas = new double[n - 1]; + Arrays.fill(noiseSigmas, 1); + final var windowSigmas = new double[n - 1]; + Arrays.fill(windowSigmas, s); + + index = new int[count]; + composite = new double[count]; + final var best = new double[count]; + final var weightSum = weighted ? new double[count] : null; + final var valueSum = weighted ? new double[count] : null; + final var smooth = createOp.apply(first, new FloatType()); + final var energy = createOp.apply(first, new FloatType()); + final var measure = createOp.apply(first, new FloatType()); + final var depth = input.dimension(focusDim); + for (var z = 0; z < depth; z++) { + final var slice = Views.hyperSlice(input, focusDim, input.min( + focusDim) + z); + gaussOp.compute(slice, noiseSigmas, new OutOfBoundsBorderFactory<>(), + smooth); + squaredLaplacian(smooth, energy); + gaussFloatOp.compute(energy, windowSigmas, + new OutOfBoundsBorderFactory<>(), measure); + final var mc = Views.flatIterable(measure).cursor(); + final var ic = Views.flatIterable(slice).cursor(); + for (var i = 0; i < count; i++) { + final var m = mc.next().getRealDouble(); + final var v = ic.next().getRealDouble(); + if (z == 0 || m > best[i]) { + best[i] = m; + index[i] = z; + if (!weighted) composite[i] = v; + } + if (weighted) { + weightSum[i] += m; + composite[i] += m * v; + valueSum[i] += v; + } + } + } + if (weighted) { + for (var i = 0; i < count; i++) { + composite[i] = weightSum[i] > 0 ? composite[i] / weightSum[i] + : valueSum[i] / depth; + } + } + } + + /** Computes the squared discrete Laplacian, with border extension. */ + private static void squaredLaplacian( + final RandomAccessibleInterval in, + final RandomAccessibleInterval out) + { + final var n = in.numDimensions(); + final var ra = Views.extendBorder(in).randomAccess(); + final var c = Views.flatIterable(out).localizingCursor(); + final var pos = new long[n]; + while (c.hasNext()) { + c.fwd(); + c.localize(pos); + final var center = ra.setPositionAndGet(pos).getRealDouble(); + var lap = 0.0; + for (var d = 0; d < n; d++) { + pos[d]--; + lap += ra.setPositionAndGet(pos).getRealDouble() - center; + pos[d] += 2; + lap += ra.setPositionAndGet(pos).getRealDouble() - center; + pos[d]--; + } + c.get().setReal(lap * lap); + } + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/transform/hough/HoughCircleTransform.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/transform/hough/HoughCircleTransform.java new file mode 100644 index 000000000..fa6bba1c8 --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/transform/hough/HoughCircleTransform.java @@ -0,0 +1,217 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.transform.hough; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.BiFunction; + +import net.imglib2.Dimensions; +import net.imglib2.FinalDimensions; +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.type.BooleanType; +import net.imglib2.type.numeric.real.DoubleType; +import net.imglib2.util.Intervals; +import net.imglib2.view.Views; + +import org.scijava.function.Functions; +import org.scijava.ops.spi.Nullable; +import org.scijava.ops.spi.OpDependency; + +/** + * Hough transform for circles (2D) and, more generally, n-dimensional + * spheres. + *

+ * Every foreground pixel of the binary input casts one vote for each possible + * center lying at distance {@code r} from it, for each radius {@code r} in + * {@code minRadius, minRadius + stepRadius, ...} up to {@code maxRadius}. The + * result is an (n+1)-dimensional vote image: the first n dimensions match the + * input (including its minimum), and the last dimension indexes the radii, so + * that index {@code i} corresponds to radius {@code minRadius + i * stepRadius}. + * The value at a location is the number of foreground pixels lying on the + * sphere of that radius centered there. Use {@code segment.detectHoughCircles} + * to extract circles from the vote image. + *

+ * The discrete sphere of radius {@code r} is the set of integer offsets whose + * Euclidean norm rounds to {@code r}; in 2D this is a closed ring of + * approximately {@code 2πr} pixels. + *

+ * Ported from {@code HoughTransformOp} of CircleSkinner by Jean-Yves Tinevez + * (CircleSkinner, + * BSD-2-Clause). The original votes along 2D midpoint circles; this version + * generalizes the voting to n dimensions. + *

+ * Reference: R. O. Duda and P. E. Hart, "Use of the Hough transformation to + * detect lines and curves in pictures", Communications of the ACM 15(1), + * 11-15, 1972. + *

+ * The original CircleSkinner license notice follows, with the copyright line + * as it appears in the CircleSkinner sources. + *

+ * + *
+ * Copyright (C) 2016 - 2022 My Company, Inc.
+ *
+ * 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.
+ * 
+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + * @author Jean-Yves Tinevez + * @param the type of the binary input pixels + * @implNote op names='transform.houghCircle' + */ +public class HoughCircleTransform> implements + Functions.Arity4, Integer, Integer, Integer, RandomAccessibleInterval> +{ + + @OpDependency(name = "create.img") + private BiFunction> createOp; + + /** + * Computes the Hough vote image of a binary image. + * + * @param input the binary image whose foreground pixels vote + * @param minRadius the smallest radius to test, in pixels (at least 1) + * @param maxRadius the largest radius to test, in pixels (inclusive, at + * least minRadius) + * @param stepRadius the increment between tested radii, in pixels (defaults + * to 1) + * @return the (n+1)-dimensional vote image, whose last dimension indexes the + * tested radii + */ + @Override + public RandomAccessibleInterval apply( // + final RandomAccessibleInterval input, // + final Integer minRadius, // + final Integer maxRadius, // + @Nullable Integer stepRadius // + ) { + if (stepRadius == null) stepRadius = 1; + if (minRadius < 1) throw new IllegalArgumentException( + "Minimum radius must be at least 1: " + minRadius); + if (maxRadius < minRadius) throw new IllegalArgumentException( + "Maximum radius " + maxRadius + " is smaller than minimum radius " + + minRadius); + if (stepRadius < 1) throw new IllegalArgumentException( + "Radius step must be at least 1: " + stepRadius); + + final var n = input.numDimensions(); + final var nRadii = (maxRadius - minRadius) / stepRadius + 1; + + final var dims = new long[n + 1]; + for (var d = 0; d < n; d++) + dims[d] = input.dimension(d); + dims[n] = nRadii; + final var votes = createOp.apply(new FinalDimensions(dims), + new DoubleType()); + + final var offsets = new long[nRadii][][]; + for (var i = 0; i < nRadii; i++) { + offsets[i] = shellOffsets(n, minRadius + i * stepRadius); + } + + final var min = Intervals.minAsLongArray(input); + final var pos = new long[n]; + final var target = new long[n + 1]; + final var ra = votes.randomAccess(); + final var cursor = Views.iterable(input).localizingCursor(); + while (cursor.hasNext()) { + if (!cursor.next().get()) continue; + cursor.localize(pos); + for (var d = 0; d < n; d++) + pos[d] -= min[d]; + for (var i = 0; i < nRadii; i++) { + target[n] = i; + OFFSET: for (final var offset : offsets[i]) { + for (var d = 0; d < n; d++) { + final var p = pos[d] + offset[d]; + if (p < 0 || p >= dims[d]) continue OFFSET; + target[d] = p; + } + ra.setPosition(target); + ra.get().inc(); + } + } + } + + var zeroMin = true; + for (var d = 0; d < n; d++) + zeroMin &= min[d] == 0; + if (zeroMin) return votes; + final var translation = new long[n + 1]; + System.arraycopy(min, 0, translation, 0, n); + return Views.translate(votes, translation); + } + + /** + * Gets the integer offsets whose Euclidean norm rounds to the given radius. + */ + private static long[][] shellOffsets(final int n, final int radius) { + final var lo = (radius - 0.5) * (radius - 0.5); + final var hi = (radius + 0.5) * (radius + 0.5); + final List offsets = new ArrayList<>(); + final var offset = new long[n]; + for (var d = 0; d < n; d++) + offset[d] = -radius; + while (true) { + var r2 = 0.0; + for (var d = 0; d < n; d++) + r2 += offset[d] * offset[d]; + if (r2 >= lo && r2 < hi) offsets.add(offset.clone()); + var d = 0; + while (d < n && offset[d] == radius) { + offset[d] = -radius; + d++; + } + if (d == n) break; + offset[d]++; + } + return offsets.toArray(new long[0][]); + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/util/Calibrations.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/util/Calibrations.java new file mode 100644 index 000000000..ab3a74666 --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/util/Calibrations.java @@ -0,0 +1,78 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.util; + +import java.util.Arrays; + +/** + * Utility methods for pixel spacing (calibration) parameters, shared by the + * ops of this library. + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +public final class Calibrations { + + private Calibrations() { + // Prevent instantiation of static utility class. + } + + /** + * Validates an optional pixel spacing parameter, defaulting to 1 along + * every dimension. + * + * @param spacing the pixel spacing along each dimension, or null + * @param n the number of dimensions + * @param name the name of the parameter, for error messages + * @return {@code spacing}, or an array of {@code n} ones if it is null + * @throws IllegalArgumentException if {@code spacing} does not have + * {@code n} values, or if any of them is not positive and finite + */ + public static double[] spacing(final double[] spacing, final int n, + final String name) + { + if (spacing == null) { + final var ones = new double[n]; + Arrays.fill(ones, 1); + return ones; + } + if (spacing.length != n) { + throw new IllegalArgumentException("Expected " + n + " " + name + + " values but got " + spacing.length); + } + for (final var s : spacing) { + if (!(s > 0) || Double.isInfinite(s)) { + throw new IllegalArgumentException("The " + name + + " values must be positive and finite: " + Arrays.toString(spacing)); + } + } + return spacing; + } +} diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/util/Neighborhoods.java b/scijava-ops-image/src/main/java/org/scijava/ops/image/util/Neighborhoods.java new file mode 100644 index 000000000..eda24f135 --- /dev/null +++ b/scijava-ops-image/src/main/java/org/scijava/ops/image/util/Neighborhoods.java @@ -0,0 +1,118 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.util; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashSet; +import java.util.List; + +import net.imglib2.algorithm.neighborhood.RectangleShape; +import net.imglib2.algorithm.neighborhood.Shape; +import net.imglib2.type.numeric.real.DoubleType; +import net.imglib2.util.ConstantUtils; + +/** + * Utility methods for neighborhoods on flattened pixel grids, shared by the + * morphology and segmentation ops of this library. + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +public final class Neighborhoods { + + private Neighborhoods() { + // Prevent instantiation of static utility class. + } + + /** + * Lists the offsets of a shape around the origin like + * {@link #neighborOffsets(Shape, int)}, sorted in raster order: as their + * pixels are visited by a flat iteration with dimension 0 varying fastest. + * The offsets of a symmetric neighborhood that precede the center pixel + * thus come first, in the first half of the array. + * + * @param shape the neighborhood shape, or null for full connectivity + * @param n the number of dimensions + * @return the sorted offsets + */ + public static int[][] rasterOffsets(final Shape shape, final int n) { + final var offsets = neighborOffsets(shape, n); + Arrays.sort(offsets, (a, b) -> { + for (var d = n - 1; d >= 0; d--) { + if (a[d] != b[d]) return Integer.compare(a[d], b[d]); + } + return 0; + }); + return offsets; + } + + /** + * Lists the offsets of a shape around the origin, made symmetric and + * without the origin itself. + * + * @param shape the neighborhood shape, or null for full connectivity (a + * {@link RectangleShape} of span 1, i.e. 8-connectivity in 2D and + * 26-connectivity in 3D) + * @param n the number of dimensions + * @return the offsets, as arrays of length {@code n}; the negation of each + * offset is included too + */ + public static int[][] neighborOffsets(final Shape shape, final int n) { + final var s = shape == null ? new RectangleShape(1, false) : shape; + final var source = ConstantUtils.constantRandomAccessible(new DoubleType(), + n); + final var ra = s.neighborhoodsRandomAccessible(source).randomAccess(); + ra.setPosition(new long[n]); + final var set = new LinkedHashSet>(); + final var c = ra.get().localizingCursor(); + while (c.hasNext()) { + c.fwd(); + final var o = new ArrayList(n); + final var neg = new ArrayList(n); + var zero = true; + for (var d = 0; d < n; d++) { + final var v = c.getIntPosition(d); + o.add(v); + neg.add(-v); + if (v != 0) zero = false; + } + if (zero) continue; + set.add(o); + set.add(neg); + } + final var result = new int[set.size()][]; + var k = 0; + for (final var o : set) { + result[k++] = o.stream().mapToInt(Integer::intValue).toArray(); + } + return result; + } +} diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/OpRegressionTest.java b/scijava-ops-image/src/test/java/org/scijava/ops/image/OpRegressionTest.java index 5959eefec..404386552 100644 --- a/scijava-ops-image/src/test/java/org/scijava/ops/image/OpRegressionTest.java +++ b/scijava-ops-image/src/test/java/org/scijava/ops/image/OpRegressionTest.java @@ -42,7 +42,7 @@ public class OpRegressionTest { @Test public void testOpDiscoveryRegression() { - long expected = 1967; + long expected = 2546; long actual = ops.infos().size(); assertEquals(expected, actual); } diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/coloc/ShuffledViewTest.java b/scijava-ops-image/src/test/java/org/scijava/ops/image/coloc/ShuffledViewTest.java index aa3642345..fc78e3169 100644 --- a/scijava-ops-image/src/test/java/org/scijava/ops/image/coloc/ShuffledViewTest.java +++ b/scijava-ops-image/src/test/java/org/scijava/ops/image/coloc/ShuffledViewTest.java @@ -39,6 +39,12 @@ import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; + +import java.util.Arrays; + +import net.imglib2.view.Views; + public class ShuffledViewTest extends AbstractColocalisationTest { // for(UnsignedByteType t:Views.iterable(shuffled)) System.out.print(t + ", @@ -235,4 +241,37 @@ public void testAllShuffle() { }, 6, 6); ImgLib2Assert.assertImageEquals(expected, shuffled); } + /* + * Shuffling a view with a non-zero min must permute the pixels of that + * view (and only those). + */ + @Test + public void testNonZeroMin() { + final var image = ArrayImgs.unsignedBytes(8, 8); + int i = 0; + for (final var t : image) + t.set(i++); + final var view = Views.interval(image, new long[] { 1, 1 }, new long[] { + 6, 6 }); + final int[] blockSize = { 3, 2 }; + final var shuffled = new ShuffledView<>(view, blockSize, 0xdeadbeefL); + final int[] expected = new int[36], actual = new int[36]; + final var ra = shuffled.randomAccess(); + int k = 0; + for (long y = 1; y <= 6; y++) { + for (long x = 1; x <= 6; x++, k++) { + expected[k] = (int) (8 * y + x); + actual[k] = ra.setPositionAndGet(x, y).get(); + } + } + Arrays.sort(actual); + assertArrayEquals(expected, actual); + + // Cropping a view with a non-zero min stays within that view. + final var cropped = ShuffledView.cropAtMin(Views.interval(image, + new long[] { 1, 1 }, new long[] { 7, 7 }), blockSize); + assertArrayEquals(new long[] { 1, 1 }, cropped.minAsLongArray()); + assertArrayEquals(new long[] { 6, 6 }, cropped.maxAsLongArray()); + } + } diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/coloc/costesThreshold/DefaultCostesThresholdTest.java b/scijava-ops-image/src/test/java/org/scijava/ops/image/coloc/costesThreshold/DefaultCostesThresholdTest.java new file mode 100644 index 000000000..391a9e385 --- /dev/null +++ b/scijava-ops-image/src/test/java/org/scijava/ops/image/coloc/costesThreshold/DefaultCostesThresholdTest.java @@ -0,0 +1,127 @@ +/*- + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.coloc.costesThreshold; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Random; + +import net.imglib2.img.array.ArrayImgs; + +import org.junit.jupiter.api.Test; +import org.scijava.ops.image.AbstractColocalisationTest; +import org.scijava.ops.image.coloc.manders.MandersResult; + +/** + * Tests {@link DefaultCostesThreshold}. + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +public class DefaultCostesThresholdTest extends AbstractColocalisationTest { + + /** + * Noise background with a colocalized bright population on the line + * {@code y = 2x + 1}: the thresholds must separate the two. + */ + @Test + public void testSynthetic() { + final var rnd = new Random(42); + final var n = 20000; + final var x = new double[n]; + final var y = new double[n]; + for (var i = 0; i < n; i++) { + if (i % 4 == 0) { + final var s = 50 + 50 * rnd.nextDouble(); + x[i] = s + rnd.nextGaussian(); + y[i] = 2 * s + 1 + rnd.nextGaussian(); + } + else { + x[i] = 10 + 3 * rnd.nextGaussian(); + y[i] = 21 + 3 * rnd.nextGaussian(); + } + } + final CostesResult r = ops.op("coloc.costesThreshold").input(ArrayImgs + .doubles(x, n), ArrayImgs.doubles(y, n)).outType(CostesResult.class) + .apply(); + assertEquals(2, r.getSlope(), 0.05); + assertEquals(1, r.getIntercept(), 2); + assertEquals(r.getSlope() * r.getThreshold1() + r.getIntercept(), r + .getThreshold2(), 1e-12); + assertTrue(r.getPearsonsBelow() <= 0); + assertTrue(r.getThreshold1() > 15 && r.getThreshold1() < 50, r + .toString()); + } + + /** Orthogonal regression of exactly linear data recovers the line. */ + @Test + public void testRegression() { + final var x = ArrayImgs.doubles(new double[] { 0, 1, 2, 3, 4, 5 }, 6); + final var y = ArrayImgs.doubles(new double[] { 3, 2.5, 2, 1.5, 1, 0.5 }, + 6); + final CostesResult r = ops.op("coloc.costesThreshold").input(x, y) + .outType(CostesResult.class).apply(); + assertEquals(-0.5, r.getSlope(), 1e-12); + assertEquals(3, r.getIntercept(), 1e-12); + // Anticorrelated channels have no colocalized pixels. + assertEquals(5, r.getThreshold1(), 0); + } + + /** + * Compares with Coloc 2 (Colocalisation_Analysis 3.1.0) + * {@code AutoThresholdRegression} on its sample images: slope + * 0.7039042236360126, intercept 0.7283700920021605; its linear Costes + * stepper gives the thresholds (8, 6) with a Pearson correlation below of + * -0.011639152703460071, and its bisection stepper (10, 8) with 0.0166. + * Coloc 2 counts values equal to the thresholds as above them, so its (8, 6) + * selects the same pixels as thresholds (7, 5.66) here; its tM1 and tM2 are + * 0.729066416798541 and 0.7419663476592765. + */ + @Test + public void testColoc2() { + final CostesResult r = ops.op("coloc.costesThreshold").input( + getPositiveCorrelationImageCh1(), getPositiveCorrelationImageCh2()) + .outType(CostesResult.class).apply(); + // Note: Coloc 2 accumulates its regression sums differently. + assertEquals(0.7039042236360126, r.getSlope(), 1e-7); + assertEquals(0.7283700920021605, r.getIntercept(), 1e-6); + assertEquals(7, r.getThreshold1(), 0); + assertEquals(5, Math.floor(r.getThreshold2()), 0); + assertEquals(-0.011639152703460071, r.getPearsonsBelow(), 1e-12); + + final MandersResult m = ops.op("coloc.manders").input( + getPositiveCorrelationImageCh1(), getPositiveCorrelationImageCh2(), r + .getThreshold1(), r.getThreshold2()).outType(MandersResult.class) + .apply(); + assertEquals(0.729066416798541, m.getM1(), 1e-12); + assertEquals(0.7419663476592765, m.getM2(), 1e-12); + } +} diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/coloc/manders/DefaultMandersTest.java b/scijava-ops-image/src/test/java/org/scijava/ops/image/coloc/manders/DefaultMandersTest.java new file mode 100644 index 000000000..6cf6153c2 --- /dev/null +++ b/scijava-ops-image/src/test/java/org/scijava/ops/image/coloc/manders/DefaultMandersTest.java @@ -0,0 +1,95 @@ +/*- + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.coloc.manders; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import net.imglib2.img.array.ArrayImgs; + +import org.junit.jupiter.api.Test; +import org.scijava.ops.image.AbstractColocalisationTest; + +/** + * Tests {@link DefaultManders}. + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +public class DefaultMandersTest extends AbstractColocalisationTest { + + @Test + public void testAnalytic() { + final var red = ArrayImgs.doubles(new double[] { 4, 2, 0, 1, 3 }, 5); + final var green = ArrayImgs.doubles(new double[] { 1, 0, 5, 0, 2 }, 5); + final MandersResult m = ops.op("coloc.manders").input(red, green).outType( + MandersResult.class).apply(); + // Red where green is nonzero: 4 + 3 of 10; green where red is: 1 + 2 of 8. + assertEquals(0.7, m.getM1(), 1e-12); + assertEquals(3.0 / 8, m.getM2(), 1e-12); + + final MandersResult t = ops.op("coloc.manders").input(red, green, 2.0, 1.0) + .outType(MandersResult.class).apply(); + // Red where green is above 1: 0 + 3; green where red is above 2: 1 + 2. + assertEquals(0.3, t.getM1(), 1e-12); + assertEquals(3.0 / 8, t.getM2(), 1e-12); + } + + @Test + public void testNoSignal() { + final var zeros = ArrayImgs.floats(4); + final var ones = ArrayImgs.floats(new float[] { 1, 1, 1, 1 }, 4); + final MandersResult m = ops.op("coloc.manders").input(zeros, ones).outType( + MandersResult.class).apply(); + assertTrue(Double.isNaN(m.getM1())); + assertEquals(0, m.getM2(), 0); + } + + /** + * Compares with Coloc 2 (Colocalisation_Analysis 3.1.0) on its sample images: + * {@code MandersColocalization.calculateMandersCorrelation} without + * thresholds, and with the Costes thresholds (8, 6) of its linear stepper in + * {@code ThresholdMode.Above}, which counts values equal to the thresholds + * as above them. + */ + @Test + public void testColoc2() { + final var ch1 = getPositiveCorrelationImageCh1(); + final var ch2 = getPositiveCorrelationImageCh2(); + final MandersResult m = ops.op("coloc.manders").input(ch1, ch2).outType( + MandersResult.class).apply(); + assertEquals(0.9945220990935079, m.getM1(), 1e-12); + assertEquals(0.9964314056397573, m.getM2(), 1e-12); + final MandersResult t = ops.op("coloc.manders").input(ch1, ch2, 7.0, 5.0) + .outType(MandersResult.class).apply(); + assertEquals(0.729066416798541, t.getM1(), 1e-12); + assertEquals(0.7419663476592765, t.getM2(), 1e-12); + } +} diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/coloc/maxTKendallTau/MTKTTest.java b/scijava-ops-image/src/test/java/org/scijava/ops/image/coloc/maxTKendallTau/MTKTTest.java index 61c7c4bc4..99e5a2e2f 100644 --- a/scijava-ops-image/src/test/java/org/scijava/ops/image/coloc/maxTKendallTau/MTKTTest.java +++ b/scijava-ops-image/src/test/java/org/scijava/ops/image/coloc/maxTKendallTau/MTKTTest.java @@ -170,7 +170,10 @@ public void testMTKTall() { Img vImage2 = ArrayImgs.doubles(values2, values2.length); double result = (Double) ops.op("coloc.maxTKendallTau").input(vImage1, vImage2).apply(); - assertEquals(1.0, result, 0.0); + // Note: The Otsu threshold of these ten values lies in a gap of their + // 256-bin histogram. Since threshold.otsu resolves ties toward the lowest + // bin, as Fiji does, it is about 5 rather than 6, so more ranks are kept. + assertEquals(2.0380986614602725, result, 0.0); } // Thirdly, we test random datasets. diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/coloc/mutualInformation/DefaultMutualInformationTest.java b/scijava-ops-image/src/test/java/org/scijava/ops/image/coloc/mutualInformation/DefaultMutualInformationTest.java new file mode 100644 index 000000000..a12956a56 --- /dev/null +++ b/scijava-ops-image/src/test/java/org/scijava/ops/image/coloc/mutualInformation/DefaultMutualInformationTest.java @@ -0,0 +1,111 @@ +/*- + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.coloc.mutualInformation; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Random; + +import net.imglib2.img.array.ArrayImgs; +import net.imglib2.type.numeric.real.DoubleType; + +import org.junit.jupiter.api.Test; +import org.scijava.ops.image.AbstractColocalisationTest; + +/** + * Tests {@link DefaultMutualInformation}. + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +public class DefaultMutualInformationTest extends AbstractColocalisationTest { + + /** Two binary images: identical, inverted, and independent. */ + @Test + public void testTwoLevels() { + final var x = ArrayImgs.unsignedBytes(new byte[] { 0, 1, 0, 1, 0, 1, 0, + 1 }, 8); + final var inverted = ArrayImgs.unsignedBytes(new byte[] { 1, 0, 1, 0, 1, 0, + 1, 0 }, 8); + final var independent = ArrayImgs.unsignedBytes(new byte[] { 0, 0, 1, 1, 0, + 0, 1, 1 }, 8); + assertEquals(1, mi(x, x, 2), 1e-12); + assertEquals(1, mi(x, inverted, 2), 1e-12); + assertEquals(0, mi(x, independent, 2), 1e-12); + } + + /** The mutual information of an image with itself is its entropy. */ + @Test + public void testSelfInformation() { + final var img = getPositiveCorrelationImageCh1(); + final Double h = ops.op("stats.entropy").input(img, 64).outType( + Double.class).apply(); + assertEquals(h, mi(img, img, 64), 1e-10); + assertTrue(h > 1); + } + + /** Independent random images have almost no mutual information. */ + @Test + public void testIndependentNoise() { + final var rnd = new Random(0xc0ffee); + final var a = ArrayImgs.doubles(256, 256); + final var b = ArrayImgs.doubles(256, 256); + a.forEach(t -> t.set(rnd.nextGaussian())); + b.forEach(t -> t.set(rnd.nextGaussian())); + assertTrue(mi(a, b, 16) < 0.01); + final var mixed = ArrayImgs.doubles(256, 256); + final var ca = a.cursor(); + final var cb = b.cursor(); + for (final DoubleType t : mixed) + t.set(ca.next().get() + cb.next().get()); + // Note: for jointly Gaussian channels, MI = -log2(1 - rho^2) / 2. + final double expected = -0.5 * Math.log(1 - 0.5) / Math.log(2); + assertEquals(expected, mi(a, mixed, 32), 0.05); + } + + @Test + public void testCorrelatedImages() { + final var mi = mi(getPositiveCorrelationImageCh1(), + getPositiveCorrelationImageCh2(), null); + final var miZero = mi(getZeroCorrelationImageCh1(), + getZeroCorrelationImageCh2(), null); + assertTrue(mi > miZero, mi + " <= " + miZero); + } + + private double mi(final Object a, final Object b, final Integer bins) { + if (bins == null) { + return ops.op("coloc.mutualInformation").input(a, b).outType( + Double.class).apply(); + } + return ops.op("coloc.mutualInformation").input(a, b, bins).outType( + Double.class).apply(); + } +} diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/coloc/pValue/DefaultPValueTest.java b/scijava-ops-image/src/test/java/org/scijava/ops/image/coloc/pValue/DefaultPValueTest.java index 9d2618c64..3323d805a 100644 --- a/scijava-ops-image/src/test/java/org/scijava/ops/image/coloc/pValue/DefaultPValueTest.java +++ b/scijava-ops-image/src/test/java/org/scijava/ops/image/coloc/pValue/DefaultPValueTest.java @@ -30,15 +30,18 @@ package org.scijava.ops.image.coloc.pValue; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; import java.util.Arrays; import java.util.function.BiFunction; import org.scijava.ops.image.AbstractColocalisationTest; +import net.imglib2.FinalDimensions; import net.imglib2.RandomAccessibleInterval; import net.imglib2.img.Img; import net.imglib2.img.array.ArrayImgs; import net.imglib2.type.numeric.real.FloatType; +import net.imglib2.view.Views; import org.junit.jupiter.api.Test; import org.scijava.types.Nil; @@ -71,6 +74,75 @@ public void testPValueSomeColoc() { assertColoc(0.6, 0.25, array, 0.25, 0.25, 0.25, 0.75, 0.75, 0.75); } + /** + * Runs the Costes et al. (2004) significance test (Pearson coefficient, + * PSF-sized blocks) on a channel and a noisy copy of it: the observed + * coefficient must exceed all shuffled ones (Costes P = 100%, p-value 0), + * and is not significant against an unrelated channel. + */ + @Test + public void testCostes() { + final Img ch1 = ArrayImgs.floats(60, 60); + final Img ch2 = ArrayImgs.floats(60, 60); + final Img ch3 = ArrayImgs.floats(60, 60); + final var rng = new java.util.Random(42); + final var c1 = ch1.localizingCursor(); + final var c2 = ch2.cursor(); + final var c3 = ch3.cursor(); + while (c1.hasNext()) { + c1.fwd(); + final double x = c1.getDoublePosition(0), y = c1.getDoublePosition(1); + final double v = Math.sin(x / 3) * Math.cos(y / 4); + c1.get().setReal(100 + 50 * v); + c2.next().setReal(100 + 50 * v + 20 * rng.nextGaussian()); + c3.next().setReal(100 + 20 * rng.nextGaussian()); + } + final BiFunction, RandomAccessibleInterval, Double> pearsons = + ops.op("coloc.pearsons").inType(new Nil>() {}, + new Nil>() {}).outType(Double.class) + .function(); + final PValueResult coloc = new PValueResult(); + ops.op("coloc.pValue").input(ch1, ch2, pearsons, 50, new FinalDimensions( + 5, 5)).output(coloc).compute(); + assertEquals(0.0, coloc.getPValue(), 0.0); + final PValueResult noColoc = new PValueResult(); + ops.op("coloc.pValue").input(ch1, ch3, pearsons, 50, new FinalDimensions( + 5, 5)).output(noColoc).compute(); + assertTrue(noColoc.getPValue() > 0.05); + } + + /** + * Tests that the shuffled images stay aligned with the second image when the + * images are trimmed to a multiple of the block size: with a single block, + * every shuffle is the identity, so each shuffled image must equal the + * (trimmed) first image. + */ + @Test + public void testTrimmedAlignment() { + final Img image = ArrayImgs.floats(8, 8); + int i = 0; + for (final FloatType t : image) + t.setReal(i++); + // Returns the largest absolute difference between the two images. + final BiFunction, RandomAccessibleInterval, Double> op = + ops.typeLambda(new Nil<>() {}, (a, b) -> { + final var ca = Views.flatIterable(a).cursor(); + final var cb = Views.flatIterable(b).cursor(); + double max = 0; + while (ca.hasNext()) { + max = Math.max(max, Math.abs(ca.next().get() - cb.next().get())); + } + return max; + }); + final PValueResult output = new PValueResult(); + ops.op("coloc.pValue").input(image, image, op, 4, new FinalDimensions(6, + 6)).output(output).compute(); + assertEquals(0.0, output.getColocValue(), 0.0); + for (final double v : output.getColocValuesArray()) { + assertEquals(0.0, v, 0.0); + } + } + /** * Function is called once with original images. Thereafter, each call is with * a shuffled version of the first image. diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/coloc/spearman/DefaultSpearmanTest.java b/scijava-ops-image/src/test/java/org/scijava/ops/image/coloc/spearman/DefaultSpearmanTest.java new file mode 100644 index 000000000..1bd853805 --- /dev/null +++ b/scijava-ops-image/src/test/java/org/scijava/ops/image/coloc/spearman/DefaultSpearmanTest.java @@ -0,0 +1,86 @@ +/*- + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.coloc.spearman; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import net.imglib2.img.array.ArrayImgs; + +import org.junit.jupiter.api.Test; +import org.scijava.ops.image.AbstractColocalisationTest; + +/** + * Tests {@link DefaultSpearman}. + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +public class DefaultSpearmanTest extends AbstractColocalisationTest { + + @Test + public void testMonotonic() { + final var x = ArrayImgs.doubles(new double[] { 1, 2, 3, 4, 5 }, 5); + final var y = ArrayImgs.doubles(new double[] { 1, 8, 27, 64, 125 }, 5); + final var reversed = ArrayImgs.doubles(new double[] { 9, 7, 5, 3, 1 }, 5); + assertEquals(1, rho(x, y), 1e-12); + assertEquals(-1, rho(x, reversed), 1e-12); + } + + @Test + public void testTies() { + // Ranks of x: 1, 2.5, 2.5, 4; ranks of y: 2, 1, 3.5, 3.5. + final var x = ArrayImgs.doubles(new double[] { 1, 2, 2, 3 }, 4); + final var y = ArrayImgs.doubles(new double[] { 5, 4, 6, 6 }, 4); + final double[] rx = { 1, 2.5, 2.5, 4 }; + final double[] ry = { 2, 1, 3.5, 3.5 }; + double sxy = 0, sxx = 0, syy = 0; + for (var i = 0; i < 4; i++) { + sxy += (rx[i] - 2.5) * (ry[i] - 2.5); + sxx += (rx[i] - 2.5) * (rx[i] - 2.5); + syy += (ry[i] - 2.5) * (ry[i] - 2.5); + } + assertEquals(sxy / Math.sqrt(sxx * syy), rho(x, y), 1e-12); + } + + /** + * Compares with Coloc 2 (Colocalisation_Analysis 3.1.0) + * {@code SpearmanRankCorrelation.calculateSpearmanRank} on its sample + * images. + */ + @Test + public void testColoc2() { + assertEquals(0.5463428429415399, rho(getPositiveCorrelationImageCh1(), + getPositiveCorrelationImageCh2()), 1e-10); + } + + private double rho(final Object a, final Object b) { + return ops.op("coloc.spearman").input(a, b).outType(Double.class).apply(); + } +} diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/color/convert/ColorConvertTest.java b/scijava-ops-image/src/test/java/org/scijava/ops/image/color/convert/ColorConvertTest.java new file mode 100644 index 000000000..256f72fe5 --- /dev/null +++ b/scijava-ops-image/src/test/java/org/scijava/ops/image/color/convert/ColorConvertTest.java @@ -0,0 +1,215 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.color.convert; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.Random; + +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.img.array.ArrayImgs; +import net.imglib2.type.numeric.ARGBType; +import net.imglib2.type.numeric.real.DoubleType; + +import org.junit.jupiter.api.Test; +import org.scijava.ops.image.AbstractOpTest; +import org.scijava.types.Nil; + +/** + * Tests {@code color.convert}. + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +public class ColorConvertTest extends AbstractOpTest { + + private static final String[] SPACES = { "RGB", "linearRGB", "HSB", "XYZ", + "Lab", "Luv", "YCbCr" }; + + private RandomAccessibleInterval convert( + final RandomAccessibleInterval img, final String from, final String to) + { + return ops.op("color.convert").input(img, from, to).outType( + new Nil>() + {}).apply(); + } + + /** Converts one colour, stored in a 1 x 3 image. */ + private double[] convert(final double[] c, final String from, + final String to) + { + final var img = ArrayImgs.doubles(c.clone(), 1, 3); + final var out = convert(img, from, to); + final var ra = out.randomAccess(); + return new double[] { ra.setPositionAndGet(0, 0).get(), // + ra.setPositionAndGet(0, 1).get(), ra.setPositionAndGet(0, 2).get() }; + } + + @Test + public void testPrimariesToXYZ() { + // The columns of the IEC 61966-2-1 matrix. + final var img = ArrayImgs.unsignedBytes(new byte[] { (byte) 255, 0, 0, // + 0, (byte) 255, 0, 0, 0, (byte) 255 }, 3, 3); + final var out = convert(img, "sRGB", "XYZ"); + final double[][] expected = { { 0.4124, 0.2126, 0.0193 }, // + { 0.3576, 0.7152, 0.1192 }, { 0.1805, 0.0722, 0.9505 } }; + final var ra = out.randomAccess(); + for (var p = 0; p < 3; p++) { + for (var c = 0; c < 3; c++) { + assertEquals(expected[p][c], ra.setPositionAndGet(p, c).get(), 1e-12); + } + } + } + + @Test + public void testWhiteAndGrey() { + assertArrayEquals(new double[] { 100, 0, 0 }, convert(new double[] { 1, 1, + 1 }, "RGB", "Lab"), 1e-9); + assertArrayEquals(new double[] { 100, 0, 0 }, convert(new double[] { 1, 1, + 1 }, "RGB", "Luv"), 1e-9); + assertArrayEquals(new double[] { 0, 0, 0 }, convert(new double[] { 0, 0, + 0 }, "RGB", "Lab"), 1e-9); + assertArrayEquals(new double[] { 0, 0, 0 }, convert(new double[] { 0, 0, + 0 }, "RGB", "Luv"), 1e-9); + assertArrayEquals(new double[] { 1, 0, 0 }, convert(new double[] { 1, 1, + 1 }, "RGB", "YCbCr"), 1e-12); + // sRGB 50% grey has L* = 53.39. + final var grey = convert(new double[] { 0.5, 0.5, 0.5 }, "RGB", "Lab"); + assertArrayEquals(new double[] { 53.389, 0, 0 }, grey, 1e-3); + } + + @Test + public void testPublishedLabAndLuv() { + // Commonly published D65 values for the sRGB primaries (e.g. Lindbloom); + // they differ from ours by less than 0.02 because of the rounding of the + // D65 white point. + assertArrayEquals(new double[] { 53.24, 80.09, 67.20 }, convert( + new double[] { 1, 0, 0 }, "RGB", "Lab"), 0.03); + assertArrayEquals(new double[] { 87.73, -86.18, 83.18 }, convert( + new double[] { 0, 1, 0 }, "RGB", "Lab"), 0.03); + assertArrayEquals(new double[] { 32.30, 79.19, -107.86 }, convert( + new double[] { 0, 0, 1 }, "RGB", "Lab"), 0.03); + assertArrayEquals(new double[] { 53.24, 175.05, 37.76 }, convert( + new double[] { 1, 0, 0 }, "RGB", "Luv"), 0.03); + assertArrayEquals(new double[] { 32.30, -9.40, -130.35 }, convert( + new double[] { 0, 0, 1 }, "RGB", "Luv"), 0.03); + } + + @Test + public void testHSB() { + // 8-bit RGB inputs and their HSB values, worked out by hand. + final int[][] rgb = { { 255, 0, 0 }, { 0, 128, 0 }, { 51, 102, 204 }, // + { 255, 255, 0 }, { 255, 0, 255 }, { 128, 128, 128 }, { 0, 255, 255 } }; + final double[][] hsb = { { 0, 1, 1 }, { 120, 1, 128 / 255.0 }, // + { 220, 0.75, 0.8 }, { 60, 1, 1 }, { 300, 1, 1 }, { 0, 0, 128 / 255.0 }, // + { 180, 1, 1 } }; + final var img = ArrayImgs.unsignedBytes(rgb.length, 3); + final var ra = img.randomAccess(); + for (var i = 0; i < rgb.length; i++) + for (var c = 0; c < 3; c++) + ra.setPositionAndGet(i, c).set(rgb[i][c]); + final var out = convert(img, "RGB", "HSV"); + final var oa = out.randomAccess(); + for (var i = 0; i < rgb.length; i++) + for (var c = 0; c < 3; c++) + assertEquals(hsb[i][c], oa.setPositionAndGet(i, c).get(), 1e-9); + } + + @Test + public void testYCbCrJFIF() { + final var ycc = convert(new double[] { 0.2, 0.6, 0.9 }, "RGB", "YCbCr"); + final var y = 0.299 * 0.2 + 0.587 * 0.6 + 0.114 * 0.9; + assertArrayEquals(new double[] { y, (0.9 - y) / 1.772, (0.2 - y) / + 1.402 }, ycc, 1e-12); + } + + @Test + public void testRoundTrips() { + // A channel-first float image of random colours. + final var rnd = new Random(11); + final var img = ArrayImgs.doubles(3, 6, 5); + for (final var t : img) + t.set(rnd.nextDouble()); + for (final var space : SPACES) { + final var there = convert(img, "RGB", space); + for (final var other : SPACES) { + final var via = convert(there, space, other); + final var back = convert(via, other, "RGB"); + final var a = img.cursor(); + final var b = back.cursor(); + while (a.hasNext()) { + assertEquals(a.next().get(), b.next().get(), 1e-9, space + " -> " + + other); + } + } + } + } + + @Test + public void testRGBRangeAndChannelDim() { + // 16-bit input defaults to a range of 65535; output RGB uses the same. + final var img = ArrayImgs.unsignedShorts(new short[] { (short) 65535, 0, + (short) 65535, 0, (short) 65535, 0 }, 2, 3); + final var lab = convert(img, "RGB", "Lab"); + final var ra = lab.randomAccess(); + assertEquals(100, ra.setPositionAndGet(0, 0).get(), 1e-9); + assertEquals(0, ra.setPositionAndGet(1, 0).get(), 1e-9); + + final RandomAccessibleInterval rgb = ops.op("color.convert") + .input(lab, "Lab", "RGB", 1, 255.0).outType( + new Nil>() + {}).apply(); + assertEquals(255, rgb.randomAccess().setPositionAndGet(0, 2).get(), 1e-9); + + assertThrows(IllegalArgumentException.class, () -> convert(img, "RGB", + "CMYK")); + } + + @Test + public void testARGB() { + final var img = ArrayImgs.argbs(2); + img.randomAccess().setPositionAndGet(0).set(ARGBType.rgba(255, 0, 0, + 255)); + img.randomAccess().setPositionAndGet(1).set(ARGBType.rgba(128, 128, 128, + 0)); + final RandomAccessibleInterval lab = ops.op("color.convert") + .input(img, "CIELAB").outType( + new Nil>() + {}).apply(); + assertArrayEquals(new long[] { 2, 3 }, lab.dimensionsAsLongArray()); + final var expected = convert(new double[] { 1, 0, 0 }, "RGB", "Lab"); + final var ra = lab.randomAccess(); + for (var c = 0; c < 3; c++) + assertEquals(expected[c], ra.setPositionAndGet(0, c).get(), 1e-9); + assertEquals(0, ra.setPositionAndGet(1, 1).get(), 1e-9); + } +} diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/color/deconvolve/DeconvolveStainsTest.java b/scijava-ops-image/src/test/java/org/scijava/ops/image/color/deconvolve/DeconvolveStainsTest.java new file mode 100644 index 000000000..97a058232 --- /dev/null +++ b/scijava-ops-image/src/test/java/org/scijava/ops/image/color/deconvolve/DeconvolveStainsTest.java @@ -0,0 +1,215 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.color.deconvolve; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.Random; + +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.img.array.ArrayImgs; +import net.imglib2.loops.LoopBuilder; +import net.imglib2.type.numeric.ARGBType; +import net.imglib2.type.numeric.real.DoubleType; + +import org.junit.jupiter.api.Test; +import org.scijava.ops.image.AbstractOpTest; +import org.scijava.types.Nil; + +/** + * Tests {@code color.deconvolveStains}. + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +public class DeconvolveStainsTest extends AbstractOpTest { + + private static final double[][] HED = { { 0.650, 0.704, 0.286 }, // + { 0.072, 0.990, 0.105 }, { 0.268, 0.570, 0.776 } }; + + /** Test pixels (red, green, blue). */ + private static final int[][] RGB = { { 255, 255, 255 }, { 200, 150, 220 }, // + { 120, 60, 160 }, { 230, 120, 180 }, { 100, 90, 150 }, { 50, 40, 100 }, // + { 180, 140, 110 }, { 1, 2, 3 } }; + + /** + * Output of the Fiji Colour_Deconvolution plugin 3.0.3 + * ({@code StainMatrix.compute}) for {@link #RGB} with the {@link #HED} + * vectors: one 8-bit transmittance image per stain. + */ + private static final int[][] PLUGIN = { // + { 255, 184, 92, 255, 66, 25, 229, 0 }, // + { 255, 192, 134, 143, 255, 255, 255, 255 }, // + { 255, 248, 224, 173, 213, 183, 90, 10 } }; + + private static double[][] normalized(final double[][] m) { + return ColourDeconvolution.stainMatrix(m); + } + + @Test + public void testRecoversKnownAmounts() { + // Channel-first float image with I0 = 1, synthesized by Beer-Lambert. + final var m = normalized(HED); + final var rnd = new Random(42); + final long w = 5, h = 4; + final var amounts = new double[(int) (w * h)][3]; + final var img = ArrayImgs.doubles(3, w, h); + final var ra = img.randomAccess(); + for (var i = 0; i < amounts.length; i++) { + for (var k = 0; k < 3; k++) + amounts[i][k] = 1.5 * rnd.nextDouble(); + for (var c = 0; c < 3; c++) { + var od = 0.0; + for (var k = 0; k < 3; k++) + od += amounts[i][k] * m[k][c]; + ra.setPositionAndGet(c, i % w, i / w).set(Math.pow(10, -od)); + } + } + final RandomAccessibleInterval out = ops.op( + "color.deconvolveStains").input(img, HED, 0, 1.0).outType( + new Nil>() + {}).apply(); + assertArrayEquals(img.dimensionsAsLongArray(), out.dimensionsAsLongArray()); + final var oa = out.randomAccess(); + for (var i = 0; i < amounts.length; i++) { + for (var k = 0; k < 3; k++) { + assertEquals(amounts[i][k], oa.setPositionAndGet(k, i % w, i / w) + .get(), 1e-9); + } + } + } + + @Test + public void testTwoStainsUseOrthogonalThirdVector() { + final var m = StainVectors.H_E.vectors(); + final var full = normalized(m); + assertEquals(0, dot(full[0], full[2]), 1e-12); + assertEquals(0, dot(full[1], full[2]), 1e-12); + assertEquals(1, dot(full[2], full[2]), 1e-12); + + // A pure mixture of the first two stains has no third component. + final var img = ArrayImgs.unsignedShorts(4, 3); + final var ra = img.randomAccess(); + final double i0 = 65535; + for (var x = 0; x < 4; x++) { + for (var c = 0; c < 3; c++) { + final var od = 0.3 * x * full[0][c] + 0.2 * full[1][c]; + ra.setPositionAndGet(x, c).setReal(Math.round(i0 * Math.pow(10, -od))); + } + } + final var out = deconvolve(img, "H&E"); + final var oa = out.randomAccess(); + for (var x = 0; x < 4; x++) { + assertEquals(0.3 * x, oa.setPositionAndGet(x, 0).get(), 1e-3); + assertEquals(0.2, oa.setPositionAndGet(x, 1).get(), 1e-3); + assertEquals(0, oa.setPositionAndGet(x, 2).get(), 1e-3); + } + } + + @Test + public void testMatchesPlugin() { + final var img = ArrayImgs.unsignedBytes(RGB.length, 3); + final var ra = img.randomAccess(); + for (var i = 0; i < RGB.length; i++) + for (var c = 0; c < 3; c++) + ra.setPositionAndGet(i, c).set(RGB[i][c]); + final var out = deconvolve(img, HED); + final var oa = out.randomAccess(); + for (var k = 0; k < 3; k++) { + for (var i = 0; i < RGB.length; i++) { + // The plugin outputs 8-bit transmittance and uses a slightly + // different zero-intensity offset, so agreement is within 1 grey + // level. + final var t = Math.min(255, 255 * Math.pow(10, -oa.setPositionAndGet( + i, k).get())); + assertEquals(PLUGIN[k][i], t, 1.5, "stain " + k + ", pixel " + i); + } + } + } + + @Test + public void testARGBMatchesChannels() { + final var argb = ArrayImgs.argbs(RGB.length, 2); + final var channels = ArrayImgs.unsignedBytes(RGB.length, 2, 3); + final var ar = argb.randomAccess(); + final var cr = channels.randomAccess(); + for (var i = 0; i < RGB.length; i++) { + for (var y = 0; y < 2; y++) { + ar.setPositionAndGet(i, y).set(ARGBType.rgba(RGB[i][0], RGB[i][1], + RGB[i][2], 255)); + for (var c = 0; c < 3; c++) + cr.setPositionAndGet(i, y, c).set(RGB[i][c]); + } + } + final var expected = deconvolve(channels, "H DAB"); + final RandomAccessibleInterval actual = ops.op( + "color.deconvolveStains").input(argb, "H DAB").outType( + new Nil>() + {}).apply(); + assertArrayEquals(expected.dimensionsAsLongArray(), actual + .dimensionsAsLongArray()); + LoopBuilder.setImages(expected, actual).forEachPixel((e, a) -> assertEquals(e + .get(), a.get(), 1e-12)); + + final RandomAccessibleInterval actual2 = ops.op( + "color.deconvolveStains").input(argb, StainVectors.H_DAB.vectors(), 255.0) + .outType(new Nil>() + {}).apply(); + LoopBuilder.setImages(expected, actual2).forEachPixel((e, a) -> assertEquals( + e.get(), a.get(), 1e-12)); + } + + @Test + public void testStainNames() { + assertEquals(StainVectors.H_E, StainVectors.fromName("h&e")); + assertEquals(StainVectors.H_E, StainVectors.fromName("H_E")); + assertEquals(StainVectors.H_E_DAB, StainVectors.fromName("H&E DAB")); + assertEquals(StainVectors.MASSON_TRICHROME, StainVectors.fromName( + "masson trichrome")); + assertThrows(IllegalArgumentException.class, () -> StainVectors.fromName( + "Nonexistent")); + for (final var s : StainVectors.values()) + ColourDeconvolution.invert(normalized(s.vectors())); + } + + private RandomAccessibleInterval deconvolve( + final RandomAccessibleInterval img, final Object stains) + { + return ops.op("color.deconvolveStains").input(img, stains).outType( + new Nil>() + {}).apply(); + } + + private static double dot(final double[] a, final double[] b) { + return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; + } +} diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/color/deltae/DeltaETest.java b/scijava-ops-image/src/test/java/org/scijava/ops/image/color/deltae/DeltaETest.java new file mode 100644 index 000000000..a42882079 --- /dev/null +++ b/scijava-ops-image/src/test/java/org/scijava/ops/image/color/deltae/DeltaETest.java @@ -0,0 +1,166 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.color.deltae; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.img.array.ArrayImgs; +import net.imglib2.type.numeric.real.DoubleType; + +import org.junit.jupiter.api.Test; +import org.scijava.ops.image.AbstractOpTest; +import org.scijava.types.Nil; + +/** + * Tests {@code color.deltaE}. + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +public class DeltaETest extends AbstractOpTest { + + /** + * The CIEDE2000 test data of Sharma, Wu and Dalal (2005), Table 1: L1, a1, + * b1, L2, a2, b2 and the expected colour difference. + */ + private static final double[][] SHARMA = { // + { 50.0000, 2.6772, -79.7751, 50.0000, 0.0000, -82.7485, 2.0425 }, // + { 50.0000, 3.1571, -77.2803, 50.0000, 0.0000, -82.7485, 2.8615 }, // + { 50.0000, 2.8361, -74.0200, 50.0000, 0.0000, -82.7485, 3.4412 }, // + { 50.0000, -1.3802, -84.2814, 50.0000, 0.0000, -82.7485, 1.0000 }, // + { 50.0000, -1.1848, -84.8006, 50.0000, 0.0000, -82.7485, 1.0000 }, // + { 50.0000, -0.9009, -85.5211, 50.0000, 0.0000, -82.7485, 1.0000 }, // + { 50.0000, 0.0000, 0.0000, 50.0000, -1.0000, 2.0000, 2.3669 }, // + { 50.0000, -1.0000, 2.0000, 50.0000, 0.0000, 0.0000, 2.3669 }, // + { 50.0000, 2.4900, -0.0010, 50.0000, -2.4900, 0.0009, 7.1792 }, // + { 50.0000, 2.4900, -0.0010, 50.0000, -2.4900, 0.0010, 7.1792 }, // + { 50.0000, 2.4900, -0.0010, 50.0000, -2.4900, 0.0011, 7.2195 }, // + { 50.0000, 2.4900, -0.0010, 50.0000, -2.4900, 0.0012, 7.2195 }, // + { 50.0000, -0.0010, 2.4900, 50.0000, 0.0009, -2.4900, 4.8045 }, // + { 50.0000, -0.0010, 2.4900, 50.0000, 0.0010, -2.4900, 4.8045 }, // + { 50.0000, -0.0010, 2.4900, 50.0000, 0.0011, -2.4900, 4.7461 }, // + { 50.0000, 2.5000, 0.0000, 50.0000, 0.0000, -2.5000, 4.3065 }, // + { 50.0000, 2.5000, 0.0000, 73.0000, 25.0000, -18.0000, 27.1492 }, // + { 50.0000, 2.5000, 0.0000, 61.0000, -5.0000, 29.0000, 22.8977 }, // + { 50.0000, 2.5000, 0.0000, 56.0000, -27.0000, -3.0000, 31.9030 }, // + { 50.0000, 2.5000, 0.0000, 58.0000, 24.0000, 15.0000, 19.4535 }, // + { 50.0000, 2.5000, 0.0000, 50.0000, 3.1736, 0.5854, 1.0000 }, // + { 50.0000, 2.5000, 0.0000, 50.0000, 3.2972, 0.0000, 1.0000 }, // + { 50.0000, 2.5000, 0.0000, 50.0000, 1.8634, 0.5757, 1.0000 }, // + { 50.0000, 2.5000, 0.0000, 50.0000, 3.2592, 0.3350, 1.0000 }, // + { 60.2574, -34.0099, 36.2677, 60.4626, -34.1751, 39.4387, 1.2644 }, // + { 63.0109, -31.0961, -5.8663, 62.8187, -29.7946, -4.0864, 1.2630 }, // + { 61.2901, 3.7196, -5.3901, 61.4292, 2.2480, -4.9620, 1.8731 }, // + { 35.0831, -44.1164, 3.7933, 35.0232, -40.0716, 1.5901, 1.8645 }, // + { 22.7233, 20.0904, -46.6940, 23.0331, 14.9730, -42.5619, 2.0373 }, // + { 36.4612, 47.8580, 18.3852, 36.2715, 50.5065, 21.2231, 1.4146 }, // + { 90.8027, -2.0831, 1.4410, 91.1528, -1.6435, 0.0447, 1.4441 }, // + { 90.9257, -0.5406, -0.9208, 88.6381, -0.8985, -0.7239, 1.5381 }, // + { 6.7747, -0.2908, -2.4247, 5.8714, -0.0985, -2.2286, 0.6377 }, // + { 2.0776, 0.0795, -1.1350, 0.9033, -0.0636, -0.5514, 0.9082 } }; + + private double deltaE(final double[] lab1, final double[] lab2, + final String formula) + { + return ops.op("color.deltaE").input(lab1, lab2, formula).outType( + Double.class).apply(); + } + + @Test + public void testSharmaCIEDE2000() { + for (final var row : SHARMA) { + final double[] p = { row[0], row[1], row[2] }; + final double[] q = { row[3], row[4], row[5] }; + assertEquals(row[6], deltaE(p, q, "CIEDE2000"), 5e-5); + // CIEDE2000 is symmetric. + assertEquals(row[6], deltaE(q, p, "CIEDE2000"), 5e-5); + } + } + + @Test + public void testCIE76AndCIE94() { + final double[] p = { 50, 2.5, 0 }; + final double[] q = { 73, 25, -18 }; + final double dl = 23, da = 22.5, db = -18; + assertEquals(Math.sqrt(dl * dl + da * da + db * db), deltaE(p, q, + "CIE76"), 1e-12); + + // CIE94 by hand: C1 = 2.5, C2 = hypot(25, 18). + final var c1 = 2.5; + final var c2 = Math.hypot(25, 18); + final var dc = c1 - c2; + final var dh2 = da * da + db * db - dc * dc; + final var sc = 1 + 0.045 * c1; + final var sh = 1 + 0.015 * c1; + final var expected = Math.sqrt(dl * dl + dc * dc / (sc * sc) + dh2 / (sh * + sh)); + assertEquals(expected, deltaE(p, q, "CIE94"), 1e-12); + + final var sct = 1 + 0.048 * c1; + final var sht = 1 + 0.014 * c1; + final var textiles = Math.sqrt(dl * dl / 4 + dc * dc / (sct * sct) + dh2 / + (sht * sht)); + assertEquals(textiles, deltaE(p, q, "CIE94 textiles"), 1e-12); + + // Pure lightness differences around L = 50 are equal under all formulas. + final double[] g1 = { 49.5, 0, 0 }; + final double[] g2 = { 50.5, 0, 0 }; + assertEquals(1, deltaE(g1, g2, "CIE76"), 1e-12); + assertEquals(1, deltaE(g1, g2, "CIE94"), 1e-12); + assertEquals(1, deltaE(g1, g2, "CIEDE2000"), 1e-12); + } + + @Test + public void testImage() { + // Lab images with the channel along dimension 0, one Sharma pair per + // pixel. + final var n = SHARMA.length; + final var lab1 = ArrayImgs.doubles(3, n); + final var lab2 = ArrayImgs.doubles(3, n); + final var a1 = lab1.randomAccess(); + final var a2 = lab2.randomAccess(); + for (var i = 0; i < n; i++) { + for (var c = 0; c < 3; c++) { + a1.setPositionAndGet(c, i).set(SHARMA[i][c]); + a2.setPositionAndGet(c, i).set(SHARMA[i][c + 3]); + } + } + final RandomAccessibleInterval out = ops.op("color.deltaE") + .input(lab1, lab2).outType( + new Nil>() + {}).apply(); + assertArrayEquals(new long[] { n }, out.dimensionsAsLongArray()); + final var oa = out.randomAccess(); + for (var i = 0; i < n; i++) + assertEquals(SHARMA[i][6], oa.setPositionAndGet(i).get(), 5e-5); + } +} diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/deconvolve/landweber/LandweberTest.java b/scijava-ops-image/src/test/java/org/scijava/ops/image/deconvolve/landweber/LandweberTest.java new file mode 100644 index 000000000..b252d945a --- /dev/null +++ b/scijava-ops-image/src/test/java/org/scijava/ops/image/deconvolve/landweber/LandweberTest.java @@ -0,0 +1,364 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.deconvolve.landweber; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Random; + +import net.imglib2.FinalDimensions; +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.algorithm.fft2.FFTMethods; +import net.imglib2.img.Img; +import net.imglib2.img.array.ArrayImgs; +import net.imglib2.outofbounds.OutOfBoundsConstantValueFactory; +import net.imglib2.outofbounds.OutOfBoundsFactory; +import net.imglib2.outofbounds.OutOfBoundsPeriodicFactory; +import net.imglib2.type.numeric.complex.ComplexFloatType; +import net.imglib2.type.numeric.integer.UnsignedShortType; +import net.imglib2.type.numeric.real.FloatType; +import net.imglib2.view.Views; + +import org.junit.jupiter.api.Test; +import org.scijava.ops.image.AbstractOpTest; +import org.scijava.types.Nil; + +/** + * Tests {@link Landweber}. + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +public class LandweberTest extends AbstractOpTest { + + private static final Nil> FLOAT_RAI = + new Nil<>() + {}; + + /** + * On noise-free data in the circulant model, each frequency component of + * the error is multiplied by {@code 1 - tau |H|^2} per iteration, so for a + * valid step size the error and the residual decrease monotonically, while + * a step size beyond {@code 2 / max|H|^2} diverges. + */ + @Test + public void testMonotoneConvergence() { + final var size = fastSize(40, 36); + final var object = randomImage(size, 5); + final var kernel = gaussKernel(1.5, 2); + final var blurred = blurCirculant(object, kernel); + + var previousError = Double.POSITIVE_INFINITY; + var previousResidual = Double.POSITIVE_INFINITY; + for (var k = 0; k <= 8; k++) { + final var x = landweberCirculant(blurred, kernel, k, null, false); + assertArrayEquals(size, x.dimensionsAsLongArray()); + final var error = mse(x, object); + final var residual = mse(blurCirculant(x, kernel), blurred); + assertTrue(error < previousError, "error at iteration " + k + ": " + + error + " >= " + previousError); + assertTrue(residual < previousResidual, "residual at iteration " + k + + ": " + residual + " >= " + previousResidual); + previousError = error; + previousResidual = residual; + } + assertEquals(mse(blurred, object), mse(landweberCirculant(blurred, kernel, + 0, null, false), object), 0); + + // Projected Landweber also decreases the residual for tau = 1 / max|H|^2. + previousResidual = Double.POSITIVE_INFINITY; + for (var k = 0; k <= 8; k++) { + final var x = landweberCirculant(blurred, kernel, k, 1.0, true); + final var residual = mse(blurCirculant(x, kernel), blurred); + assertTrue(residual < previousResidual, "projected residual at " + + "iteration " + k + ": " + residual + " >= " + previousResidual); + previousResidual = residual; + } + + // Beyond the stability bound tau < 2, the iteration diverges. + final var initialError = mse(blurred, object); + final var diverged = mse(landweberCirculant(blurred, kernel, 20, 3.0, + false), object); + assertTrue(diverged > 100 * initialError, "diverged: " + diverged); + } + + /** + * The default step size must be stable for a kernel that does not sum to + * one, where {@code max|H|} and {@code max|H|^2} differ. + */ + @Test + public void testDefaultStepUnnormalizedKernel() { + final var size = fastSize(40, 36); + final var object = randomImage(size, 5); + final var kernel = gaussKernel(1.5, 2); + for (final var v : Views.iterable(kernel)) + v.mul(3); + final var blurred = blurCirculant(object, kernel); + + var previousResidual = Double.POSITIVE_INFINITY; + for (var k = 0; k <= 8; k++) { + final var x = landweberCirculant(blurred, kernel, k, null, false); + final var residual = mse(blurCirculant(x, kernel), blurred); + assertTrue(residual < previousResidual, "residual at iteration " + k + + ": " + residual + " >= " + previousResidual); + previousResidual = residual; + } + } + + /** Restoration of a noisy, blurred 3-D phantom must reduce the error. */ + @Test + public void testNoisy3D() { + final var object = phantom3D(); + final var kernel = gaussKernel(1.5, 3); + final var blurred = blur(object, kernel); + addNoise(blurred, 0.01, 17); + final var blurredError = mse(blurred, object); + + final var restored = ops.op("deconvolve.landweber").input(blurred, kernel, + 30).outType(FLOAT_RAI).apply(); + assertArrayEquals(blurred.dimensionsAsLongArray(), restored + .dimensionsAsLongArray()); + final var error = mse(restored, object); + assertTrue(error < 0.8 * blurredError, "landweber: " + error + + " vs blurred: " + blurredError); + for (final var v : Views.iterable(restored)) + assertTrue(v.getRealDouble() >= 0); + + // Without the constraint, negative values appear. + final var unconstrained = ops.op("deconvolve.landweber").input(blurred, + kernel, 30, null, false).outType(FLOAT_RAI).apply(); + var min = Double.POSITIVE_INFINITY; + for (final var v : Views.iterable(unconstrained)) + min = Math.min(min, v.getRealDouble()); + assertTrue(min < 0); + assertTrue(mse(unconstrained, object) < blurredError); + } + + /** + * An object truncated by the image border: non-circulant mode must reduce + * the error, and avoid the artifacts of circulant mode with zero padding, + * which worsen with the iteration count. + */ + @Test + public void testNonCirculant() { + final var full = phantom3D(); + final var kernel = gaussKernel(1.5, 3); + final var fullBlurred = blur(full, kernel); + // Crop so that the boxes are cut by the border of the measured window. + final long[] min = { 12, 10, 0 }; + final long[] max = { 31, 29, 19 }; + final RandomAccessibleInterval object = Views.zeroMin(Views + .interval(full, min, max)); + final var blurred = copy(Views.zeroMin(Views.interval(fullBlurred, min, + max))); + final var blurredError = mse(blurred, object); + + final var nc30 = mse(landweber(blurred, kernel, 30, true, null), object); + final var nc100 = mse(landweber(blurred, kernel, 100, true, null), object); + final OutOfBoundsFactory> zero = + new OutOfBoundsConstantValueFactory<>(new FloatType()); + final var zero30 = mse(landweber(blurred, kernel, 30, false, zero), object); + final var zero100 = mse(landweber(blurred, kernel, 100, false, zero), + object); + assertTrue(nc30 < 0.8 * blurredError, "non-circulant: " + nc30 + + " vs blurred: " + blurredError); + assertTrue(nc100 < nc30, "non-circulant: " + nc100 + " vs " + nc30); + assertTrue(zero100 > zero30, "zero-padded: " + zero100 + " vs " + zero30); + assertTrue(nc100 < 0.5 * zero100, "non-circulant: " + nc100 + + " vs zero-padded: " + zero100); + } + + /** + * With an integer output type, the residual {@code y - h * x} is negative + * in places, so it must not be stored in the output type. The result must + * match the floating-point result, rounded. + */ + @Test + public void testUnsignedShortOutput() { + final var object = phantom3D(); + for (final var v : object) + v.mul(1000); + final var kernel = gaussKernel(1.5, 3); + final var blurred = blur(object, kernel); + final var in = ArrayImgs.unsignedShorts(blurred.dimensionsAsLongArray()); + final var bc = Views.flatIterable(blurred).cursor(); + for (final var v : in) + v.setReal(Math.round(bc.next().get())); + + final var expected = ops.op("deconvolve.landweber").input(in, kernel, + new FloatType(), new ComplexFloatType(), 10, null, true, false, null, + null).outType(FLOAT_RAI).apply(); + final var actual = ops.op("deconvolve.landweber").input(in, kernel, + new UnsignedShortType(), new ComplexFloatType(), 10, null, true, false, + null, null).outType(new Nil>() + {}).apply(); + final var ec = Views.flatIterable(expected).cursor(); + for (final var v : Views.flatIterable(actual)) + assertEquals(Math.min(65535, ec.next().get()), v.get(), 0.5 + 1e-3); + } + + @Test + public void testInvalidArguments() { + final var img = randomImage(new long[] { 10, 10 }, 1); + final var kernel = gaussKernel(1.0, 2); + assertThrows(IllegalArgumentException.class, () -> ops.op( + "deconvolve.landweber").input(img, kernel, 5, -1.0).outType(FLOAT_RAI) + .apply()); + assertThrows(IllegalArgumentException.class, () -> ops.op( + "deconvolve.landweber").input(img, kernel, -1).outType(FLOAT_RAI) + .apply()); + } + + // -- Helper methods -- + + private RandomAccessibleInterval landweber( + final RandomAccessibleInterval img, + final RandomAccessibleInterval kernel, final int iterations, + final boolean nonCirculant, + final OutOfBoundsFactory> obf) + { + return ops.op("deconvolve.landweber").input(img, kernel, new FloatType(), + new ComplexFloatType(), iterations, null, true, nonCirculant, null, obf) + .outType(FLOAT_RAI).apply(); + } + + private RandomAccessibleInterval landweberCirculant( + final RandomAccessibleInterval img, + final RandomAccessibleInterval kernel, final int iterations, + final Double stepSize, final boolean nonNegative) + { + final OutOfBoundsFactory> periodic = + new OutOfBoundsPeriodicFactory<>(); + return ops.op("deconvolve.landweber").input(img, kernel, new FloatType(), + new ComplexFloatType(), iterations, stepSize, nonNegative, false, + new long[img.numDimensions()], periodic).outType(FLOAT_RAI).apply(); + } + + /** Rounds each size up to a size the FFT ops use without further padding. */ + private static long[] fastSize(final long... size) { + final var padded = new long[size.length]; + FFTMethods.dimensionsRealToComplexFast(new FinalDimensions(size), padded, + new long[size.length]); + return padded; + } + + private static Img randomImage(final long[] size, + final long seed) + { + final var img = ArrayImgs.floats(size); + final var random = new Random(seed); + for (final var v : img) + v.setReal(random.nextDouble()); + return img; + } + + /** Two overlapping boxes and a bright point. */ + private static Img phantom3D() { + final var img = ArrayImgs.floats(32, 30, 20); + final var cursor = img.localizingCursor(); + while (cursor.hasNext()) { + cursor.fwd(); + final var x = cursor.getIntPosition(0); + final var y = cursor.getIntPosition(1); + final var z = cursor.getIntPosition(2); + var v = 0.0; + if (x >= 8 && x < 18 && y >= 8 && y < 20 && z >= 6 && z < 12) v += 0.6; + if (x >= 14 && x < 24 && y >= 12 && y < 22 && z >= 9 && z < 15) v += 0.4; + if (x == 22 && y == 8 && z == 5) v = 1; + cursor.get().setReal(v); + } + return img; + } + + private RandomAccessibleInterval gaussKernel(final double sigma, + final int numDims) + { + return ops.op("create.kernelGauss").input(sigma, numDims, new FloatType()) + .outType(FLOAT_RAI).apply(); + } + + /** Linear convolution, with zero outside the image. */ + private RandomAccessibleInterval blur( + final RandomAccessibleInterval img, + final RandomAccessibleInterval kernel) + { + return ops.op("filter.convolve").input(img, kernel, new FloatType(), + new ComplexFloatType(), null, + new OutOfBoundsConstantValueFactory>( + new FloatType())).outType(FLOAT_RAI).apply(); + } + + /** Circular convolution, for images of a fast FFT size. */ + private RandomAccessibleInterval blurCirculant( + final RandomAccessibleInterval img, + final RandomAccessibleInterval kernel) + { + return ops.op("filter.convolve").input(img, kernel, new FloatType(), + new ComplexFloatType(), new long[img.numDimensions()], + new OutOfBoundsPeriodicFactory>()) + .outType(FLOAT_RAI).apply(); + } + + private static Img copy( + final RandomAccessibleInterval img) + { + final var result = ArrayImgs.floats(img.dimensionsAsLongArray()); + final var c = Views.flatIterable(img).cursor(); + for (final var v : result) + v.set(c.next()); + return result; + } + + private static void addNoise(final RandomAccessibleInterval img, + final double sigma, final long seed) + { + final var random = new Random(seed); + for (final var v : Views.flatIterable(img)) + v.setReal(v.getRealDouble() + sigma * random.nextGaussian()); + } + + private static double mse(final RandomAccessibleInterval a, + final RandomAccessibleInterval b) + { + final var ca = Views.flatIterable(a).cursor(); + final var cb = Views.flatIterable(b).cursor(); + var sum = 0.0; + long count = 0; + while (ca.hasNext()) { + final var d = ca.next().getRealDouble() - cb.next().getRealDouble(); + sum += d * d; + count++; + } + return sum / count; + } +} diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/deconvolve/wiener/WienerFilterTest.java b/scijava-ops-image/src/test/java/org/scijava/ops/image/deconvolve/wiener/WienerFilterTest.java new file mode 100644 index 000000000..474ceaf91 --- /dev/null +++ b/scijava-ops-image/src/test/java/org/scijava/ops/image/deconvolve/wiener/WienerFilterTest.java @@ -0,0 +1,291 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.deconvolve.wiener; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Random; + +import net.imglib2.FinalDimensions; +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.algorithm.fft2.FFTMethods; +import net.imglib2.img.Img; +import net.imglib2.img.array.ArrayImgs; +import net.imglib2.loops.LoopBuilder; +import net.imglib2.outofbounds.OutOfBoundsConstantValueFactory; +import net.imglib2.outofbounds.OutOfBoundsPeriodicFactory; +import net.imglib2.type.numeric.complex.ComplexFloatType; +import net.imglib2.type.numeric.real.FloatType; +import net.imglib2.view.Views; + +import org.junit.jupiter.api.Test; +import org.scijava.ops.image.AbstractOpTest; +import org.scijava.types.Nil; + +/** + * Tests {@link WienerFilter} and {@link TikhonovFilter}. + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +public class WienerFilterTest extends AbstractOpTest { + + private static final Nil> FLOAT_RAI = + new Nil<>() + {}; + + private static final Nil> COMPLEX_RAI = + new Nil<>() + {}; + + /** + * On noise-free data blurred with the same circulant model the op uses, + * Wiener with zero regularization is the inverse filter, so the spectrum of + * the result must match the spectrum of the object wherever the PSF + * transfers signal. + */ + @Test + public void testExactInverseWithinPassband() { + final var size = fastSize(50, 40); + final var object = randomImage(size, 1); + final var kernel = gaussKernel(1.0, 2); + final var blurred = blurCirculant(object, kernel); + + final var restored = ops.op("deconvolve.wiener").input(blurred, kernel, + new FloatType(), new ComplexFloatType(), 0.0, new long[2], + new OutOfBoundsPeriodicFactory>()) + .outType(FLOAT_RAI).apply(); + assertArrayEquals(size, restored.dimensionsAsLongArray()); + + final var fftObject = fft(object); + final var fftRestored = fft(restored); + final RandomAccessibleInterval paddedKernel = ops.op( + "filter.padShiftKernelFFTMethods").input(kernel, new FinalDimensions( + size)).outType(FLOAT_RAI).apply(); + final var fftKernel = fft(paddedKernel); + + var maxAbs = 0.0; + for (final var c : fftObject) + maxAbs = Math.max(maxAbs, c.getPowerDouble()); + final var tolerance = 1e-4 * maxAbs; + final var inBand = new long[1]; + final var outOfBand = new long[1]; + final var maxError = new double[1]; + LoopBuilder.setImages(fftObject, fftRestored, fftKernel).forEachPixel(( + o, r, h) -> { + if (h.getPowerDouble() > 0.1) { + inBand[0]++; + final var dr = o.getRealDouble() - r.getRealDouble(); + final var di = o.getImaginaryDouble() - r.getImaginaryDouble(); + maxError[0] = Math.max(maxError[0], Math.sqrt(dr * dr + di * di)); + } + else outOfBand[0]++; + }); + assertTrue(inBand[0] > 100, "passband samples: " + inBand[0]); + assertTrue(outOfBand[0] > 100, "stopband samples: " + outOfBand[0]); + assertTrue(maxError[0] < tolerance, "max spectral error " + maxError[0] + + " exceeds " + tolerance); + } + + /** Restoration of a noisy, blurred 3-D phantom must reduce the error. */ + @Test + public void testNoisy3D() { + final var object = phantom3D(); + final var kernel = gaussKernel(1.5, 3); + final var blurred = blur(object, kernel); + addNoise(blurred, 0.01, 42); + final var blurredError = mse(blurred, object); + + final var wiener = ops.op("deconvolve.wiener").input(blurred, kernel, 1e-2) + .outType(FLOAT_RAI).apply(); + final var wienerError = mse(wiener, object); + assertTrue(wienerError < 0.8 * blurredError, "wiener: " + wienerError + + " vs blurred: " + blurredError); + + final var tikhonov = ops.op("deconvolve.tikhonov").input(blurred, kernel, + 1e-3, 2).outType(FLOAT_RAI).apply(); + final var tikhonovError = mse(tikhonov, object); + assertTrue(tikhonovError < 0.8 * blurredError, "tikhonov: " + + tikhonovError + " vs blurred: " + blurredError); + + // Note: Without regularization, the inverse filter amplifies the noise. + final var inverse = ops.op("deconvolve.wiener").input(blurred, kernel, 0.0) + .outType(FLOAT_RAI).apply(); + assertTrue(mse(inverse, object) > blurredError); + } + + /** Zeroth-order Tikhonov regularization is the constant-NSR Wiener filter. */ + @Test + public void testTikhonovOrderZeroIsWiener() { + final var object = randomImage(new long[] { 30, 24 }, 7); + final var kernel = gaussKernel(2.0, 2); + final var blurred = blur(object, kernel); + final var wiener = ops.op("deconvolve.wiener").input(blurred, kernel, + 5e-3).outType(FLOAT_RAI).apply(); + final var tikhonov0 = ops.op("deconvolve.tikhonov").input(blurred, kernel, + 5e-3, 0).outType(FLOAT_RAI).apply(); + final var tikhonov2 = ops.op("deconvolve.tikhonov").input(blurred, kernel, + 5e-3, 2).outType(FLOAT_RAI).apply(); + assertEquals(0, mse(wiener, tikhonov0), 0); + assertTrue(mse(wiener, tikhonov2) > 1e-6); + } + + /** Defaults of the simple and full signatures agree. */ + @Test + public void testDefaults() { + final var object = randomImage(new long[] { 20, 22 }, 3); + final var kernel = gaussKernel(1.5, 2); + final var blurred = blur(object, kernel); + final var simple = ops.op("deconvolve.wiener").input(blurred, kernel) + .outType(FLOAT_RAI).apply(); + final var full = ops.op("deconvolve.wiener").input(blurred, kernel, + new FloatType(), new ComplexFloatType()).outType(FLOAT_RAI).apply(); + final var explicit = ops.op("deconvolve.wiener").input(blurred, kernel, + WienerFilter.DEFAULT_NSR).outType(FLOAT_RAI).apply(); + assertEquals(0, mse(simple, full), 0); + assertEquals(0, mse(simple, explicit), 0); + + final var tSimple = ops.op("deconvolve.tikhonov").input(blurred, kernel) + .outType(FLOAT_RAI).apply(); + final var tExplicit = ops.op("deconvolve.tikhonov").input(blurred, kernel, + TikhonovFilter.DEFAULT_LAMBDA, TikhonovFilter.DEFAULT_ORDER).outType( + FLOAT_RAI).apply(); + assertEquals(0, mse(tSimple, tExplicit), 0); + + assertThrows(IllegalArgumentException.class, () -> ops.op( + "deconvolve.tikhonov").input(blurred, kernel, 1e-3, 3).outType(FLOAT_RAI) + .apply()); + assertThrows(IllegalArgumentException.class, () -> ops.op( + "deconvolve.wiener").input(blurred, kernel, -1.0).outType(FLOAT_RAI) + .apply()); + } + + // -- Helper methods -- + + /** Rounds each size up to a size the FFT ops use without further padding. */ + private static long[] fastSize(final long... size) { + final var padded = new long[size.length]; + FFTMethods.dimensionsRealToComplexFast(new FinalDimensions(size), padded, + new long[size.length]); + return padded; + } + + private static Img randomImage(final long[] size, + final long seed) + { + final var img = ArrayImgs.floats(size); + final var random = new Random(seed); + for (final var v : img) + v.setReal(random.nextDouble()); + return img; + } + + /** Two overlapping boxes and a bright point, away from the borders. */ + private static Img phantom3D() { + final var img = ArrayImgs.floats(32, 30, 20); + final var ra = img.randomAccess(); + final var cursor = img.localizingCursor(); + while (cursor.hasNext()) { + cursor.fwd(); + final var x = cursor.getIntPosition(0); + final var y = cursor.getIntPosition(1); + final var z = cursor.getIntPosition(2); + var v = 0.0; + if (x >= 8 && x < 18 && y >= 8 && y < 20 && z >= 6 && z < 12) v += 0.6; + if (x >= 14 && x < 24 && y >= 12 && y < 22 && z >= 9 && z < 15) v += 0.4; + cursor.get().setReal(v); + } + ra.setPosition(new int[] { 22, 8, 5 }); + ra.get().setReal(1); + return img; + } + + private RandomAccessibleInterval gaussKernel(final double sigma, + final int numDims) + { + return ops.op("create.kernelGauss").input(sigma, numDims, new FloatType()) + .outType(FLOAT_RAI).apply(); + } + + /** Linear convolution, with zero outside the image. */ + private RandomAccessibleInterval blur( + final RandomAccessibleInterval img, + final RandomAccessibleInterval kernel) + { + return ops.op("filter.convolve").input(img, kernel, new FloatType(), + new ComplexFloatType(), null, + new OutOfBoundsConstantValueFactory>( + new FloatType())).outType(FLOAT_RAI).apply(); + } + + /** Circular convolution, for images of a fast FFT size. */ + private RandomAccessibleInterval blurCirculant( + final RandomAccessibleInterval img, + final RandomAccessibleInterval kernel) + { + return ops.op("filter.convolve").input(img, kernel, new FloatType(), + new ComplexFloatType(), new long[img.numDimensions()], + new OutOfBoundsPeriodicFactory>()) + .outType(FLOAT_RAI).apply(); + } + + private RandomAccessibleInterval fft( + final RandomAccessibleInterval img) + { + return ops.op("filter.fft").input(img, new ComplexFloatType(), null, true) + .outType(COMPLEX_RAI).apply(); + } + + private static void addNoise(final RandomAccessibleInterval img, + final double sigma, final long seed) + { + final var random = new Random(seed); + for (final var v : Views.flatIterable(img)) + v.setReal(v.getRealDouble() + sigma * random.nextGaussian()); + } + + private static double mse(final RandomAccessibleInterval a, + final RandomAccessibleInterval b) + { + final var ca = Views.flatIterable(a).cursor(); + final var cb = Views.flatIterable(b).cursor(); + var sum = 0.0; + long count = 0; + while (ca.hasNext()) { + final var d = ca.next().getRealDouble() - cb.next().getRealDouble(); + sum += d * d; + count++; + } + return sum / count; + } +} diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/features/haralick/IBSIHaralickFeatureTest.java b/scijava-ops-image/src/test/java/org/scijava/ops/image/features/haralick/IBSIHaralickFeatureTest.java new file mode 100644 index 000000000..5dc5af5d6 --- /dev/null +++ b/scijava-ops-image/src/test/java/org/scijava/ops/image/features/haralick/IBSIHaralickFeatureTest.java @@ -0,0 +1,101 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.features.haralick; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import net.imglib2.img.array.ArrayImgs; +import net.imglib2.type.numeric.real.DoubleType; + +import org.junit.jupiter.api.Test; +import org.scijava.ops.image.AbstractOpTest; +import org.scijava.ops.image.image.cooccurrenceMatrix.MatrixOrientation2D; + +/** + * Tests the IBSI co-occurrence features added to {@code features.haralick}. + * The expected values were computed with an independent Python script from + * the IBSI formulas, on the horizontal co-occurrence matrix of a 4x4 image + * with gray levels 0 to 3 (which the 4-level discretization maps to + * themselves). + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +public class IBSIHaralickFeatureTest extends AbstractOpTest { + + private static final double EPSILON = 1e-12; + + private double feature(final String name) { + final var img = ArrayImgs.unsignedBytes(new byte[] { // + 0, 0, 1, 1, // + 0, 0, 1, 1, // + 0, 2, 2, 2, // + 2, 2, 3, 3 }, 4, 4); + return ops.op("features.haralick." + name).input(img, 4, 1, + MatrixOrientation2D.HORIZONTAL).outType(DoubleType.class).apply().get(); + } + + @Test + public void testJointAverage() { + assertEquals(2.0833333333333335, feature("jointAverage"), EPSILON); + } + + @Test + public void testAutocorrelation() { + assertEquals(6.0, feature("autocorrelation"), EPSILON); + } + + @Test + public void testDissimilarity() { + assertEquals(0.41666666666666663, feature("dissimilarity"), EPSILON); + } + + @Test + public void testInverseDifferenceNormalized() { + assertEquals(0.9222222222222222, feature("inverseDifferenceNormalized"), + EPSILON); + } + + @Test + public void testInverseDifferenceMomentNormalized() { + assertEquals(0.9686274509803922, feature( + "inverseDifferenceMomentNormalized"), EPSILON); + } + + @Test + public void testInverseVariance() { + assertEquals(0.2708333333333333, feature("inverseVariance"), EPSILON); + } + + @Test + public void testClusterTendency() { + assertEquals(3.576388888888889, feature("clusterTendency"), EPSILON); + } +} diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/features/texture/IBSIPhantom.java b/scijava-ops-image/src/test/java/org/scijava/ops/image/features/texture/IBSIPhantom.java new file mode 100644 index 000000000..08fc69756 --- /dev/null +++ b/scijava-ops-image/src/test/java/org/scijava/ops/image/features/texture/IBSIPhantom.java @@ -0,0 +1,152 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.features.texture; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.function.Function; + +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.img.Img; +import net.imglib2.img.array.ArrayImgs; +import net.imglib2.type.logic.BitType; +import net.imglib2.type.numeric.integer.UnsignedByteType; +import net.imglib2.view.Views; + +/** + * The IBSI 1 digital phantom (5 x 4 x 4 voxels, gray levels 1 to 6) and helpers + * for comparing texture features against the IBSI reference values. + *

+ * The phantom is part of the IBSI data sets + * (https://github.com/theibsi/data_sets), original design by Alex Zwanenburg, + * licensed under the Creative Commons Attribution 4.0 International License + * (http://creativecommons.org/licenses/by/4.0/). The voxel values below were + * transcribed from {@code ibsi_1_digital_phantom/tiff/phantom_img.tif} and + * {@code phantom_msk.tif} as bundled with RadiomicsJ. The reference feature + * values used by the tests are the IBSI consensus values for the digital + * phantom, taken from {@code validation/IBSI_ValidationFile.xlsx} of RadiomicsJ + * (Apache License 2.0); they are given to 3 significant digits. + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +public final class IBSIPhantom { + + private IBSIPhantom() {} + + /** Phantom intensities, indexed [z][y][x]. */ + private static final int[][][] IMAGE = { // + { { 1, 4, 4, 1, 1 }, { 1, 4, 6, 1, 1 }, { 4, 1, 6, 4, 1 }, { 4, 4, 6, 4, + 1 } }, // + { { 1, 4, 4, 1, 1 }, { 1, 1, 6, 1, 1 }, { 1, 1, 3, 1, 1 }, { 4, 4, 6, 1, + 1 } }, // + { { 1, 4, 4, 1, 1 }, { 1, 1, 1, 1, 1 }, { 1, 1, 9, 1, 1 }, { 1, 1, 6, 1, + 1 } }, // + { { 1, 4, 4, 1, 1 }, { 1, 1, 1, 1, 1 }, { 1, 1, 1, 1, 1 }, { 1, 1, 6, 1, + 1 } } }; + + /** Phantom ROI mask, indexed [z][y][x]. */ + private static final int[][][] MASK = { // + { { 1, 1, 1, 1, 1 }, { 1, 1, 1, 1, 1 }, { 1, 1, 1, 1, 1 }, { 1, 1, 1, 1, + 1 } }, // + { { 1, 1, 1, 1, 1 }, { 1, 1, 1, 1, 1 }, { 0, 1, 1, 1, 1 }, { 1, 1, 1, 1, + 1 } }, // + { { 1, 1, 1, 0, 0 }, { 1, 1, 1, 1, 1 }, { 1, 1, 0, 1, 1 }, { 1, 1, 1, 1, + 1 } }, // + { { 1, 1, 1, 0, 0 }, { 1, 1, 1, 1, 1 }, { 1, 1, 1, 1, 1 }, { 1, 1, 1, 1, + 1 } } }; + + /** @return the phantom image, with dimensions [x, y, z] = [5, 4, 4]. */ + public static Img image() { + final var img = ArrayImgs.unsignedBytes(5, 4, 4); + final var ra = img.randomAccess(); + for (var z = 0; z < 4; z++) + for (var y = 0; y < 4; y++) + for (var x = 0; x < 5; x++) + ra.setPositionAndGet(x, y, z).set(IMAGE[z][y][x]); + return img; + } + + /** @return the phantom mask, with dimensions [x, y, z] = [5, 4, 4]. */ + public static Img mask() { + final var img = ArrayImgs.bits(5, 4, 4); + final var ra = img.randomAccess(); + for (var z = 0; z < 4; z++) + for (var y = 0; y < 4; y++) + for (var x = 0; x < 5; x++) + ra.setPositionAndGet(x, y, z).set(MASK[z][y][x] != 0); + return img; + } + + /** + * @param img a 3D image + * @return the 2D slices of the image along the last axis + */ + public static List> slices( + final RandomAccessibleInterval img) + { + final List> slices = new ArrayList<>(); + for (var z = 0; z < img.dimension(2); z++) + slices.add(Views.hyperSlice(img, 2, z)); + return slices; + } + + /** + * Asserts that features match IBSI reference values, which are given to 3 + * significant digits. + * + * @param expected map from feature name to reference value + * @param actual function computing the value of a named feature + */ + public static void assertFeatures(final Map expected, + final Function actual) + { + for (final var e : expected.entrySet()) { + final double value = actual.apply(e.getKey()); + final var tolerance = 0.005 * Math.abs(e.getValue()) + 1e-12; + assertEquals(e.getValue(), value, tolerance, e.getKey()); + } + } + + /** + * @param values a list of values + * @return the mean of the values + */ + public static double mean(final List values) { + var sum = 0.0; + for (final var v : values) + sum += v; + return sum / values.size(); + } +} diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/features/texture/gldzm/GLDZMFeaturesTest.java b/scijava-ops-image/src/test/java/org/scijava/ops/image/features/texture/gldzm/GLDZMFeaturesTest.java new file mode 100644 index 000000000..c9b434e27 --- /dev/null +++ b/scijava-ops-image/src/test/java/org/scijava/ops/image/features/texture/gldzm/GLDZMFeaturesTest.java @@ -0,0 +1,146 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.features.texture.gldzm; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.img.array.ArrayImgs; +import net.imglib2.type.logic.BitType; +import net.imglib2.type.numeric.integer.UnsignedByteType; +import net.imglib2.type.numeric.real.DoubleType; + +import org.junit.jupiter.api.Test; +import org.scijava.ops.image.AbstractOpTest; +import org.scijava.ops.image.features.texture.IBSIPhantom; + +/** + * Tests {@link DistanceZoneMatrix} and {@link GLDZMFeatures} against the IBSI + * digital phantom reference values (see {@link IBSIPhantom}). As in IBSI, the + * 2D features are computed per slice and averaged. + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +public class GLDZMFeaturesTest extends AbstractOpTest { + + private static final String[] NAMES = { "smallDistanceEmphasis", + "largeDistanceEmphasis", + "lowGrayLevelZoneEmphasis", + "highGrayLevelZoneEmphasis", + "smallDistanceLowGrayLevelEmphasis", + "smallDistanceHighGrayLevelEmphasis", + "largeDistanceLowGrayLevelEmphasis", + "largeDistanceHighGrayLevelEmphasis", + "grayLevelNonUniformity", + "grayLevelNonUniformityNormalized", + "zoneDistanceNonUniformity", + "zoneDistanceNonUniformityNormalized", + "zonePercentage", + "grayLevelVariance", + "zoneDistanceVariance", + "zoneDistanceEntropy" }; + + private static final Set NEEDS_VOXEL_COUNT = Set.of("zonePercentage"); + + private static Map expected(final double... values) { + final Map map = new LinkedHashMap<>(); + for (var i = 0; i < NAMES.length; i++) + map.put(NAMES[i], values[i]); + return map; + } + + private double[][] matrix(final RandomAccessibleInterval img, + final RandomAccessibleInterval mask) + { + return ops.op("image.distanceZoneMatrix").input(img, mask).outType( + double[][].class).apply(); + } + + private double feature(final String name, final double[][] matrix, + final long nVoxels) + { + final var op = ops.op("features.gldzm." + name); + if (NEEDS_VOXEL_COUNT.contains(name)) { + return op.input(matrix, nVoxels).outType(DoubleType.class).apply().get(); + } + return op.input(matrix).outType(DoubleType.class).apply().get(); + } + + private static long count(final RandomAccessibleInterval mask) { + long n = 0; + for (final var b : mask) + if (b.get()) n++; + return n; + } + + @Test + public void testPhantom3D() { + final var m = matrix(IBSIPhantom.image(), IBSIPhantom.mask()); + final var nVoxels = count(IBSIPhantom.mask()); + IBSIPhantom.assertFeatures(expected(1, 1, 0.253, 15.6, 0.253, 15.6, 0.253, 15.6, 1.4, 0.28, 5, 1, 0.0676, 2.64, 0, 1.92), // + name -> feature(name, m, nVoxels)); + } + + @Test + public void testPhantom2D() { + final var imgs = IBSIPhantom.slices(IBSIPhantom.image()); + final var masks = IBSIPhantom.slices(IBSIPhantom.mask()); + IBSIPhantom.assertFeatures(expected(0.946, 1.21, 0.371, 16.4, 0.367, 15.2, 0.386, 21.3, 1.41, 0.323, 3.79, 0.898, 0.24, 3.97, 0.051, 1.73), name -> { + final List values = new ArrayList<>(); + for (var z = 0; z < imgs.size(); z++) { + values.add(feature(name, matrix(imgs.get(z), masks.get(z)), count( + masks.get(z)))); + } + return IBSIPhantom.mean(values); + }); + } + + @Test + public void testMatrixSimple() { + // A 5x5 image of 1s with a central 2: the 1-zone touches the edge + // (distance 1), while the 2 lies at distance 3 from the edge. + final var bytes = new byte[25]; + java.util.Arrays.fill(bytes, (byte) 1); + bytes[12] = 2; + final var img = ArrayImgs.unsignedBytes(bytes, 5, 5); + final double[][] m = ops.op("image.distanceZoneMatrix").input(img) + .outType(double[][].class).apply(); + assertArrayEquals(new double[] { 1, 0, 0 }, m[0]); + assertArrayEquals(new double[] { 0, 0, 1 }, m[1]); + } +} diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/features/texture/glrlm/GLRLMFeaturesTest.java b/scijava-ops-image/src/test/java/org/scijava/ops/image/features/texture/glrlm/GLRLMFeaturesTest.java new file mode 100644 index 000000000..a75ebfbf2 --- /dev/null +++ b/scijava-ops-image/src/test/java/org/scijava/ops/image/features/texture/glrlm/GLRLMFeaturesTest.java @@ -0,0 +1,150 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.features.texture.glrlm; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.img.array.ArrayImgs; +import net.imglib2.type.logic.BitType; +import net.imglib2.type.numeric.integer.UnsignedByteType; +import net.imglib2.type.numeric.real.DoubleType; + +import org.junit.jupiter.api.Test; +import org.scijava.ops.image.AbstractOpTest; +import org.scijava.ops.image.features.texture.IBSIPhantom; +import org.scijava.ops.image.features.texture.TextureUtils; + +/** + * Tests {@link RunLengthMatrix} and {@link GLRLMFeatures} against the IBSI + * digital phantom reference values (see {@link IBSIPhantom}). + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +public class GLRLMFeaturesTest extends AbstractOpTest { + + private static final String[] NAMES = { "shortRunsEmphasis", + "longRunsEmphasis", "lowGrayLevelRunEmphasis", "highGrayLevelRunEmphasis", + "shortRunLowGrayLevelEmphasis", "shortRunHighGrayLevelEmphasis", + "longRunLowGrayLevelEmphasis", "longRunHighGrayLevelEmphasis", + "grayLevelNonUniformity", "grayLevelNonUniformityNormalized", + "runLengthNonUniformity", "runLengthNonUniformityNormalized", + "runPercentage", "grayLevelVariance", "runLengthVariance", "runEntropy" }; + + private static Map expected(final double... values) { + final Map map = new LinkedHashMap<>(); + for (var i = 0; i < NAMES.length; i++) + map.put(NAMES[i], values[i]); + return map; + } + + private double[][] matrix(final RandomAccessibleInterval img, + final RandomAccessibleInterval mask, final int[] direction) + { + return ops.op("image.runLengthMatrix").input(img, mask, null, direction) + .outType(double[][].class).apply(); + } + + private double feature(final String name, final double[][] matrix) { + return ops.op("features.glrlm." + name).input(matrix).outType( + DoubleType.class).apply().get(); + } + + @Test + public void testMatrixSimple() { + // Rows 1 1 2 and 2 2 2: runs (1, length 2), (2, length 1) and + // (2, length 3) along the first axis, covering 6 voxels. + final var img = ArrayImgs.unsignedBytes(new byte[] { 1, 1, 2, 2, 2, 2 }, + 3, 2); + final double[][] m = ops.op("image.runLengthMatrix").input(img, null, null, + new int[] { 1, 0 }).outType(double[][].class).apply(); + assertArrayEquals(new double[] { 0, 1, 0 }, m[0]); + assertArrayEquals(new double[] { 1, 0, 1 }, m[1]); + assertEquals(0.5, feature("runPercentage", m), 1e-12); + } + + @Test + public void testPhantom3DMerged() { + final var m = ops.op("image.runLengthMatrix").input(IBSIPhantom.image(), + IBSIPhantom.mask()).outType(double[][].class).apply(); + IBSIPhantom.assertFeatures(expected(0.729, 2.76, 0.607, 9.64, 0.372, 8.67, + 2.16, 15.6, 281, 0.43, 328, 0.501, 0.68, 3.48, 0.598, 2.62), // + name -> feature(name, m)); + } + + @Test + public void testPhantom3DAveraged() { + final List matrices = new ArrayList<>(); + for (final var dir : TextureUtils.directions(3)) + matrices.add(matrix(IBSIPhantom.image(), IBSIPhantom.mask(), dir)); + IBSIPhantom.assertFeatures(expected(0.705, 3.06, 0.603, 9.7, 0.352, 8.54, + 2.39, 17.6, 21.8, 0.43, 26.9, 0.513, 0.68, 3.46, 0.574, 2.43), // + name -> average(name, matrices)); + } + + @Test + public void testPhantom2DAveraged() { + final List matrices = new ArrayList<>(); + final var masks = IBSIPhantom.slices(IBSIPhantom.mask()); + final var imgs = IBSIPhantom.slices(IBSIPhantom.image()); + for (var z = 0; z < imgs.size(); z++) + for (final var dir : TextureUtils.directions(2)) + matrices.add(matrix(imgs.get(z), masks.get(z), dir)); + IBSIPhantom.assertFeatures(expected(0.641, 3.78, 0.604, 9.82, 0.294, 8.57, + 3.14, 17.4, 5.2, 0.46, 6.12, 0.492, 0.627, 3.35, 0.761, 2.17), // + name -> average(name, matrices)); + } + + @Test + public void testPhantom2DSliceMerged() { + final List matrices = new ArrayList<>(); + final var masks = IBSIPhantom.slices(IBSIPhantom.mask()); + final var imgs = IBSIPhantom.slices(IBSIPhantom.image()); + for (var z = 0; z < imgs.size(); z++) + matrices.add(matrix(imgs.get(z), masks.get(z), null)); + IBSIPhantom.assertFeatures(expected(0.661, 3.51, 0.609, 9.74, 0.311, 8.67, + 2.92, 16.1, 20.5, 0.456, 21.6, 0.441, 0.627, 3.37, 0.778, 2.57), // + name -> average(name, matrices)); + } + + private double average(final String name, final List matrices) { + final List values = new ArrayList<>(); + for (final var m : matrices) + values.add(feature(name, m)); + return IBSIPhantom.mean(values); + } +} diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/features/texture/glszm/GLSZMFeaturesTest.java b/scijava-ops-image/src/test/java/org/scijava/ops/image/features/texture/glszm/GLSZMFeaturesTest.java new file mode 100644 index 000000000..662312c4c --- /dev/null +++ b/scijava-ops-image/src/test/java/org/scijava/ops/image/features/texture/glszm/GLSZMFeaturesTest.java @@ -0,0 +1,144 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.features.texture.glszm; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.img.array.ArrayImgs; +import net.imglib2.type.logic.BitType; +import net.imglib2.type.numeric.integer.UnsignedByteType; +import net.imglib2.type.numeric.real.DoubleType; + +import org.junit.jupiter.api.Test; +import org.scijava.ops.image.AbstractOpTest; +import org.scijava.ops.image.features.texture.IBSIPhantom; + +/** + * Tests {@link SizeZoneMatrix} and {@link GLSZMFeatures} against the IBSI + * digital phantom reference values (see {@link IBSIPhantom}). As in IBSI, the + * 2D features are computed per slice and averaged. + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +public class GLSZMFeaturesTest extends AbstractOpTest { + + private static final String[] NAMES = { "smallZoneEmphasis", + "largeZoneEmphasis", + "lowGrayLevelZoneEmphasis", + "highGrayLevelZoneEmphasis", + "smallZoneLowGrayLevelEmphasis", + "smallZoneHighGrayLevelEmphasis", + "largeZoneLowGrayLevelEmphasis", + "largeZoneHighGrayLevelEmphasis", + "grayLevelNonUniformity", + "grayLevelNonUniformityNormalized", + "zoneSizeNonUniformity", + "zoneSizeNonUniformityNormalized", + "zonePercentage", + "grayLevelVariance", + "zoneSizeVariance", + "zoneSizeEntropy" }; + + private static final Set NEEDS_VOXEL_COUNT = Set.of(); + + private static Map expected(final double... values) { + final Map map = new LinkedHashMap<>(); + for (var i = 0; i < NAMES.length; i++) + map.put(NAMES[i], values[i]); + return map; + } + + private double[][] matrix(final RandomAccessibleInterval img, + final RandomAccessibleInterval mask) + { + return ops.op("image.sizeZoneMatrix").input(img, mask).outType( + double[][].class).apply(); + } + + private double feature(final String name, final double[][] matrix, + final long nVoxels) + { + final var op = ops.op("features.glszm." + name); + if (NEEDS_VOXEL_COUNT.contains(name)) { + return op.input(matrix, nVoxels).outType(DoubleType.class).apply().get(); + } + return op.input(matrix).outType(DoubleType.class).apply().get(); + } + + private static long count(final RandomAccessibleInterval mask) { + long n = 0; + for (final var b : mask) + if (b.get()) n++; + return n; + } + + @Test + public void testPhantom3D() { + final var m = matrix(IBSIPhantom.image(), IBSIPhantom.mask()); + final var nVoxels = count(IBSIPhantom.mask()); + IBSIPhantom.assertFeatures(expected(0.255, 550, 0.253, 15.6, 0.0256, 2.76, 503, 1490, 1.4, 0.28, 1, 0.2, 0.0676, 2.64, 331, 2.32), // + name -> feature(name, m, nVoxels)); + } + + @Test + public void testPhantom2D() { + final var imgs = IBSIPhantom.slices(IBSIPhantom.image()); + final var masks = IBSIPhantom.slices(IBSIPhantom.mask()); + IBSIPhantom.assertFeatures(expected(0.363, 43.9, 0.371, 16.4, 0.0259, 10.3, 40.4, 113, 1.41, 0.323, 1.49, 0.333, 0.24, 3.97, 21, 1.93), name -> { + final List values = new ArrayList<>(); + for (var z = 0; z < imgs.size(); z++) { + values.add(feature(name, matrix(imgs.get(z), masks.get(z)), count( + masks.get(z)))); + } + return IBSIPhantom.mean(values); + }); + } + + @Test + public void testMatrixSimple() { + // Under full connectivity, the diagonal pairs of 1s and of 2s each form a + // single zone of size 2. + final var img = ArrayImgs.unsignedBytes(new byte[] { 2, 1, 1, 2 }, 2, 2); + final double[][] m = ops.op("image.sizeZoneMatrix").input(img).outType( + double[][].class).apply(); + assertArrayEquals(new double[] { 0, 1 }, m[0]); + assertArrayEquals(new double[] { 0, 1 }, m[1]); + assertEquals(0.5, feature("zonePercentage", m, 4), 1e-12); + } +} diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/features/texture/ngldm/NGLDMFeaturesTest.java b/scijava-ops-image/src/test/java/org/scijava/ops/image/features/texture/ngldm/NGLDMFeaturesTest.java new file mode 100644 index 000000000..eb2ce526c --- /dev/null +++ b/scijava-ops-image/src/test/java/org/scijava/ops/image/features/texture/ngldm/NGLDMFeaturesTest.java @@ -0,0 +1,149 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.features.texture.ngldm; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.img.array.ArrayImgs; +import net.imglib2.type.logic.BitType; +import net.imglib2.type.numeric.integer.UnsignedByteType; +import net.imglib2.type.numeric.real.DoubleType; + +import org.junit.jupiter.api.Test; +import org.scijava.ops.image.AbstractOpTest; +import org.scijava.ops.image.features.texture.IBSIPhantom; + +/** + * Tests {@link NeighboringGrayLevelDependenceMatrix} and {@link NGLDMFeatures} against the IBSI + * digital phantom reference values (see {@link IBSIPhantom}). As in IBSI, the + * 2D features are computed per slice and averaged. + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +public class NGLDMFeaturesTest extends AbstractOpTest { + + private static final String[] NAMES = { "lowDependenceEmphasis", + "highDependenceEmphasis", + "lowGrayLevelCountEmphasis", + "highGrayLevelCountEmphasis", + "lowDependenceLowGrayLevelEmphasis", + "lowDependenceHighGrayLevelEmphasis", + "highDependenceLowGrayLevelEmphasis", + "highDependenceHighGrayLevelEmphasis", + "grayLevelNonUniformity", + "grayLevelNonUniformityNormalized", + "dependenceCountNonUniformity", + "dependenceCountNonUniformityNormalized", + "dependenceCountPercentage", + "grayLevelVariance", + "dependenceCountVariance", + "dependenceCountEntropy", + "dependenceCountEnergy" }; + + private static final Set NEEDS_VOXEL_COUNT = Set.of("dependenceCountPercentage"); + + private static Map expected(final double... values) { + final Map map = new LinkedHashMap<>(); + for (var i = 0; i < NAMES.length; i++) + map.put(NAMES[i], values[i]); + return map; + } + + private double[][] matrix(final RandomAccessibleInterval img, + final RandomAccessibleInterval mask) + { + return ops.op("image.neighboringGrayLevelDependenceMatrix").input(img, mask).outType( + double[][].class).apply(); + } + + private double feature(final String name, final double[][] matrix, + final long nVoxels) + { + final var op = ops.op("features.ngldm." + name); + if (NEEDS_VOXEL_COUNT.contains(name)) { + return op.input(matrix, nVoxels).outType(DoubleType.class).apply().get(); + } + return op.input(matrix).outType(DoubleType.class).apply().get(); + } + + private static long count(final RandomAccessibleInterval mask) { + long n = 0; + for (final var b : mask) + if (b.get()) n++; + return n; + } + + @Test + public void testPhantom3D() { + final var m = matrix(IBSIPhantom.image(), IBSIPhantom.mask()); + final var nVoxels = count(IBSIPhantom.mask()); + IBSIPhantom.assertFeatures(expected(0.045, 109, 0.693, 7.66, 0.00963, 0.736, 102, 235, 37.9, 0.512, 4.86, 0.0657, 1, 3.05, 22.1, 4.4, 0.0533), // + name -> feature(name, m, nVoxels)); + } + + @Test + public void testPhantom2D() { + final var imgs = IBSIPhantom.slices(IBSIPhantom.image()); + final var masks = IBSIPhantom.slices(IBSIPhantom.mask()); + IBSIPhantom.assertFeatures(expected(0.158, 19.2, 0.702, 7.49, 0.0473, 3.06, 17.6, 49.5, 10.2, 0.562, 3.96, 0.212, 1, 2.7, 2.73, 2.71, 0.17), name -> { + final List values = new ArrayList<>(); + for (var z = 0; z < imgs.size(); z++) { + values.add(feature(name, matrix(imgs.get(z), masks.get(z)), count( + masks.get(z)))); + } + return IBSIPhantom.mean(values); + }); + } + + @Test + public void testMatrixSimple() { + // In a 3x1 image 1 1 2, each 1 has one dependent neighbor and the 2 none. + final var img = ArrayImgs.unsignedBytes(new byte[] { 1, 1, 2 }, 3, 1); + final double[][] m = ops.op("image.neighboringGrayLevelDependenceMatrix") + .input(img).outType(double[][].class).apply(); + assertArrayEquals(new double[] { 0, 2 }, m[0]); + assertArrayEquals(new double[] { 1, 0 }, m[1]); + // With coarseness 1, all three voxels depend on their neighbors. + final double[][] m1 = ops.op("image.neighboringGrayLevelDependenceMatrix") + .input(img, null, null, 1, 1).outType(double[][].class).apply(); + assertArrayEquals(new double[] { 0, 1, 1 }, m1[0]); + assertArrayEquals(new double[] { 0, 1, 0 }, m1[1]); + assertEquals(1.0, feature("dependenceCountPercentage", m1, 3), 1e-12); + } +} diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/features/texture/ngtdm/NGTDMFeaturesTest.java b/scijava-ops-image/src/test/java/org/scijava/ops/image/features/texture/ngtdm/NGTDMFeaturesTest.java new file mode 100644 index 000000000..ef7dbc1d0 --- /dev/null +++ b/scijava-ops-image/src/test/java/org/scijava/ops/image/features/texture/ngtdm/NGTDMFeaturesTest.java @@ -0,0 +1,112 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.features.texture.ngtdm; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.img.array.ArrayImgs; +import net.imglib2.type.logic.BitType; +import net.imglib2.type.numeric.integer.UnsignedByteType; +import net.imglib2.type.numeric.real.DoubleType; + +import org.junit.jupiter.api.Test; +import org.scijava.ops.image.AbstractOpTest; +import org.scijava.ops.image.features.texture.IBSIPhantom; + +/** + * Tests {@link NeighborhoodGrayToneDifferenceMatrix} and {@link NGTDMFeatures} + * against the IBSI digital phantom reference values (see {@link IBSIPhantom}). + * As in IBSI, the 2D features are computed per slice and averaged. + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +public class NGTDMFeaturesTest extends AbstractOpTest { + + private static final String[] NAMES = { "coarseness", "contrast", + "busyness", "complexity", "strength" }; + + private static Map expected(final double... values) { + final Map map = new LinkedHashMap<>(); + for (var i = 0; i < NAMES.length; i++) + map.put(NAMES[i], values[i]); + return map; + } + + private double[][] matrix(final RandomAccessibleInterval img, + final RandomAccessibleInterval mask) + { + return ops.op("image.neighborhoodGrayToneDifferenceMatrix").input(img, + mask).outType(double[][].class).apply(); + } + + private double feature(final String name, final double[][] matrix) { + return ops.op("features.ngtdm." + name).input(matrix).outType( + DoubleType.class).apply().get(); + } + + @Test + public void testMatrixSimple() { + // In a 3x1 image 1 1 4, the first 1 differs by 0 from its neighbor, the + // second by |1 - 2.5| = 1.5 from its neighbors, and the 4 by 3. + final var img = ArrayImgs.unsignedBytes(new byte[] { 1, 1, 4 }, 3, 1); + final double[][] m = ops.op("image.neighborhoodGrayToneDifferenceMatrix") + .input(img).outType(double[][].class).apply(); + assertArrayEquals(new double[] { 2, 2.0 / 3, 1.5 }, m[0], 1e-12); + assertArrayEquals(new double[] { 0, 0, 0 }, m[1], 1e-12); + assertArrayEquals(new double[] { 1, 1.0 / 3, 3 }, m[3], 1e-12); + } + + @Test + public void testPhantom3D() { + final var m = matrix(IBSIPhantom.image(), IBSIPhantom.mask()); + IBSIPhantom.assertFeatures(expected(0.0296, 0.584, 6.54, 13.5, 0.763), + name -> feature(name, m)); + } + + @Test + public void testPhantom2D() { + final var imgs = IBSIPhantom.slices(IBSIPhantom.image()); + final var masks = IBSIPhantom.slices(IBSIPhantom.mask()); + IBSIPhantom.assertFeatures(expected(0.121, 0.925, 2.99, 10.4, 2.88), + name -> { + final List values = new ArrayList<>(); + for (var z = 0; z < imgs.size(); z++) + values.add(feature(name, matrix(imgs.get(z), masks.get(z)))); + return IBSIPhantom.mean(values); + }); + } +} diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/features/twoPointCorrelation/TwoPointCorrelationTest.java b/scijava-ops-image/src/test/java/org/scijava/ops/image/features/twoPointCorrelation/TwoPointCorrelationTest.java new file mode 100644 index 000000000..61b0ff3ea --- /dev/null +++ b/scijava-ops-image/src/test/java/org/scijava/ops/image/features/twoPointCorrelation/TwoPointCorrelationTest.java @@ -0,0 +1,117 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.features.twoPointCorrelation; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.Random; + +import net.imglib2.img.array.ArrayImgs; +import net.imglib2.type.logic.BitType; +import net.imglib2.img.array.ArrayImg; +import net.imglib2.img.basictypeaccess.array.LongArray; + +import org.junit.jupiter.api.Test; +import org.scijava.ops.image.AbstractOpTest; + +/** + * Tests {@link TwoPointCorrelation}. + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +public class TwoPointCorrelationTest extends AbstractOpTest { + + /** The FFT result matches direct pair counting. */ + @Test + public void testAgainstNaive() { + final var rnd = new Random(1); + final long w = 12, h = 9; + final var img = random(rnd, 0.4, w, h); + final var s2 = (double[]) ops.op("features.twoPointCorrelation").input(img, + 6).apply(); + assertEquals(7, s2.length); + + final var ra = img.randomAccess(); + final var pairs = new double[7]; + final var total = new double[7]; + for (var x = 0; x < w; x++) { + for (var y = 0; y < h; y++) { + for (var vx = -x; vx < w - x; vx++) { + for (var vy = -y; vy < h - y; vy++) { + final var r = Math.round(Math.sqrt(vx * vx + vy * vy)); + if (r > 6) continue; + total[(int) r]++; + if (ra.setPositionAndGet(x, y).get() && ra.setPositionAndGet(x + + vx, y + vy).get()) pairs[(int) r]++; + } + } + } + } + for (var r = 0; r <= 6; r++) + assertEquals(pairs[r] / total[r], s2[r], 1e-6, "r = " + r); + } + + /** S2(0) is the volume fraction, and S2 tends to its square. */ + @Test + public void testRandomMedium() { + final var rnd = new Random(2); + final var img = random(rnd, 0.3, 64, 64); + var count = 0; + for (final var b : img) + if (b.get()) count++; + final var phi = count / (64.0 * 64); + final var s2 = (double[]) ops.op("features.twoPointCorrelation").input(img) + .apply(); + assertEquals(33, s2.length); + assertEquals(phi, s2[0], 1e-6); + for (var r = 1; r < s2.length; r++) + assertEquals(phi * phi, s2[r], 0.01, "r = " + r); + } + + /** For a fully foreground image S2 is 1; in 3D too. */ + @Test + public void testFull3D() { + final var img = ArrayImgs.bits(6, 5, 4); + img.forEach(b -> b.set(true)); + final var s2 = (double[]) ops.op("features.twoPointCorrelation").input(img, + 3).apply(); + for (final var v : s2) + assertEquals(1, v, 1e-6); + } + + private static ArrayImg random(final Random rnd, + final double p, final long... dims) + { + final var img = ArrayImgs.bits(dims); + img.forEach(b -> b.set(rnd.nextDouble() < p)); + return img; + } +} diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/filter/convolvedBackground/ConvolvedBackgroundSubtractionTest.java b/scijava-ops-image/src/test/java/org/scijava/ops/image/filter/convolvedBackground/ConvolvedBackgroundSubtractionTest.java new file mode 100644 index 000000000..bbebaa4e7 --- /dev/null +++ b/scijava-ops-image/src/test/java/org/scijava/ops/image/filter/convolvedBackground/ConvolvedBackgroundSubtractionTest.java @@ -0,0 +1,141 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.filter.convolvedBackground; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import net.imglib2.img.Img; +import net.imglib2.img.array.ArrayImgs; +import net.imglib2.loops.LoopBuilder; +import net.imglib2.type.numeric.integer.UnsignedByteType; + +import org.junit.jupiter.api.Test; +import org.scijava.ops.image.AbstractOpTest; + +/** + * Tests {@link ConvolvedBackgroundSubtraction}. + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +public class ConvolvedBackgroundSubtractionTest extends AbstractOpTest { + + /** Creates sparse single-pixel spots of 200 on a background of 50. */ + private static Img spots() { + final var img = ArrayImgs.unsignedBytes(40, 30); + final var c = img.localizingCursor(); + while (c.hasNext()) { + c.fwd(); + final boolean spot = c.getLongPosition(0) % 7 == 3 && c.getLongPosition( + 1) % 7 == 3; + c.get().set(spot ? 200 : 50); + } + return img; + } + + private static void assertSpotsOnly(final Img in, + final Img> out) + { + LoopBuilder.setImages(in, out).forEachPixel((i, o) -> assertEquals(i + .get() == 200 ? 150 : 0, o.getRealDouble(), 1e-4)); + } + + @Test + public void testMedian() { + // The median ignores the sparse spots, so the background is exactly 50; + // with radius 10 the subsequent maximum filter (radius 1) keeps it so. + final var in = spots(); + final var out = ArrayImgs.unsignedBytes(40, 30); + ops.op("filter.convolvedBackgroundSubtraction").input(in, 10.0, "median") + .output(out).compute(); + assertSpotsOnly(in, out); + } + + @Test + public void testGaussianDefault() { + // A constant image has a constant Gaussian background. + final var in = ArrayImgs.unsignedBytes(20, 20, 5); + in.forEach(t -> t.set(77)); + final var out = ArrayImgs.floats(20, 20, 5); + ops.op("filter.convolvedBackgroundSubtraction").input(in, 12.0).output(out) + .compute(); + for (final var t : out) + assertEquals(0, t.get(), 1e-3); + } + + @Test + public void testMeanOnRamp() { + // The mean over a symmetric neighborhood of a linear ramp is the center + // value, so the result is zero away from the border. + final var in = ArrayImgs.floats(30, 30); + final var c = in.localizingCursor(); + while (c.hasNext()) { + c.fwd(); + c.get().set(2 * c.getFloatPosition(0) + 3 * c.getFloatPosition(1)); + } + final var out = ArrayImgs.floats(30, 30); + ops.op("filter.convolvedBackgroundSubtraction").input(in, 3.0, "mean") + .output(out).compute(); + final var oc = out.localizingCursor(); + while (oc.hasNext()) { + oc.fwd(); + final long x = oc.getLongPosition(0), y = oc.getLongPosition(1); + if (x >= 3 && x < 27 && y >= 3 && y < 27) { + assertEquals(0, oc.get().get(), 1e-3); + } + } + } + + @Test + public void testClamping() { + final var in = ArrayImgs.unsignedBytes(15, 15); + in.forEach(t -> t.set(100)); + in.randomAccess().setPositionAndGet(7, 7).set(0); + final var outByte = ArrayImgs.unsignedBytes(15, 15); + ops.op("filter.convolvedBackgroundSubtraction").input(in, 2.0, "median") + .output(outByte).compute(); + assertEquals(0, outByte.randomAccess().setPositionAndGet(7, 7).get()); + final var outFloat = ArrayImgs.floats(15, 15); + ops.op("filter.convolvedBackgroundSubtraction").input(in, 2.0, "median") + .output(outFloat).compute(); + assertEquals(-100, outFloat.randomAccess().setPositionAndGet(7, 7).get(), + 1e-4); + } + + @Test + public void testUnknownFilter() { + final var in = spots(); + final var out = ArrayImgs.unsignedBytes(40, 30); + assertThrows(IllegalArgumentException.class, () -> ops.op( + "filter.convolvedBackgroundSubtraction").input(in, 10.0, "bogus").output( + out).compute()); + } +} diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/filter/deriche/DericheFiltersTest.java b/scijava-ops-image/src/test/java/org/scijava/ops/image/filter/deriche/DericheFiltersTest.java new file mode 100644 index 000000000..02f7b9b07 --- /dev/null +++ b/scijava-ops-image/src/test/java/org/scijava/ops/image/filter/deriche/DericheFiltersTest.java @@ -0,0 +1,215 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.filter.deriche; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import net.imglib2.Cursor; +import net.imglib2.RandomAccess; +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.img.Img; +import net.imglib2.img.array.ArrayImgs; +import net.imglib2.type.logic.BitType; +import net.imglib2.type.numeric.real.DoubleType; +import net.imglib2.type.numeric.real.FloatType; +import net.imglib2.view.Views; + +import org.junit.jupiter.api.Test; +import org.scijava.ops.image.AbstractOpTest; +import org.scijava.types.Nil; + +/** + * Tests {@link DericheFilters}. + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +public class DericheFiltersTest extends AbstractOpTest { + + private static final Nil> DOUBLES = + new Nil<>() + {}; + + /** + * Responses of the 3D ImageJ Suite (mcib3d-core 3.96.31) + * {@code CannyDeriche1D} to a 20-pixel step (0 then 100) and ramp (0 to + * 19), for alpha 0.5 and 1, run as a black box. Its derivative filter is + * {@code -n exp(-alpha (|n| + 1))}, i.e. not normalized; scaled by + * {@code (1 - a)^3 / (2 a^2 (1 + a))} with {@code a = exp(-alpha)}, it must + * equal the normalized Deriche derivative. + */ + private static final double[][] ORACLE = { // + { 11.987579093015258, 18.051731392192178, 26.938928622786023, + 39.75987584489995, 57.878305898291046, 82.77184008222301, + 115.60583953178252, 156.2064245027663, 200.8324565324523, + 237.62040064959655, 237.62040064959655, 200.8324565324523, + 156.20642450276634, 115.60583953178252, 82.771840082223, 57.878305898291, + 39.75987584489991, 26.938928622786, 18.051731392192167, + 11.98757909301523 }, // + { 9.697907221765716, 12.07174107563967, 14.0763481554042, + 15.632596948202394, 16.779584397386582, 17.593199912656637, + 18.150136904905306, 18.514035131067857, 18.73168261469354, + 18.83319185206995, 18.83319185206996, 18.731682614693554, + 18.51403513106787, 18.15013690490532, 17.593199912656647, + 16.779584397386586, 15.6325969482024, 14.0763481554042, + 12.07174107563967, 9.697907221765718 }, // + { 0.027959383104863515, 0.06881931989109988, 0.1675471631604435, + 0.4023710026922018, 0.9495001820249115, 2.1888762703580906, + 4.884055069992278, 10.378746736612532, 20.33616041018532, + 33.869688733846594, 33.869688733846594, 20.33616041018532, + 10.378746736612532, 4.884055069992277, 2.1888762703580897, + 0.9495001820249083, 0.40237100269219933, 0.1675471631604486, + 0.06881931989110292, 0.027959383104860524 }, // + { 0.7329242454169593, 1.0716210689045322, 1.2749825083051567, + 1.3787695520603973, 1.4276090167594004, 1.4494950054344404, + 1.4589829505936622, 1.462988794086209, 1.4646192752305538, + 1.4651948916063184, 1.465194891606318, 1.4646192752305536, + 1.4629887940862087, 1.458982950593662, 1.4494950054344402, + 1.4276090167594016, 1.3787695520603993, 1.2749825083051574, + 1.071621068904533, 0.7329242454169603 } }; + + private RandomAccessibleInterval derivative( + final RandomAccessibleInterval img, final double alpha, final int d, + final double[] calibration) + { + return ops.op("filter.derichePartialDerivative").input(img, alpha, d, + calibration).outType(DOUBLES).apply(); + } + + @Test + public void testOracle1D() { + final double[] alphas = { 0.5, 0.5, 1, 1 }; + for (int c = 0; c < 4; c++) { + final Img line = ArrayImgs.doubles(20); + final boolean step = c % 2 == 0; + for (int i = 0; i < 20; i++) + line.getAt(i).set(step ? (i < 10 ? 0 : 100) : i); + final double a = Math.exp(-alphas[c]); + final double scale = Math.pow(1 - a, 3) / (2 * a * a * (1 + a)); + final RandomAccessibleInterval result = derivative(line, + alphas[c], 0, null); + for (int i = 0; i < 20; i++) { + final double expected = ORACLE[c][i] * scale; + assertEquals(expected, result.getAt(i).get(), 1e-9 * Math.abs( + expected), "case " + c + " at " + i); + } + } + } + + /** A linear ramp has a constant derivative in physical units. */ + @Test + public void testRampAndCalibration() { + final Img img = ArrayImgs.floats(40, 30); + final Cursor c = img.localizingCursor(); + while (c.hasNext()) { + c.fwd(); + c.get().setReal(3 * c.getDoublePosition(0) + 7); + } + final RandomAccessibleInterval dx = derivative(img, 2.0, 0, + null); + final RandomAccessibleInterval dy = derivative(img, 2.0, 1, + null); + final RandomAccessibleInterval dxCal = derivative(img, 1.0, 0, + new double[] { 2, 1 }); + for (int y = 0; y < 30; y++) { + for (int x = 10; x < 30; x++) { + assertEquals(3, dx.getAt(x, y).get(), 1e-6); + assertEquals(1.5, dxCal.getAt(x, y).get(), 1e-6); + } + for (int x = 0; x < 40; x++) + assertEquals(0, dy.getAt(x, y).get(), 1e-9); + } + } + + /** + * The edges of a 3D box are one voxel thick, on its bright side, and the + * gradient magnitude peaks there. + */ + @Test + public void testBoxEdges3D() { + final Img img = ArrayImgs.floats(20, 18, 16); + final Cursor c = img.localizingCursor(); + while (c.hasNext()) { + c.fwd(); + boolean inside = true; + for (int d = 0; d < 3; d++) { + final int p = c.getIntPosition(d); + inside &= p >= 5 && p <= 12; + } + c.get().setReal(inside ? 100 : 10); + } + final RandomAccessibleInterval magnitude = ops.op( + "filter.dericheGradientMagnitude").input(img, 1.0).outType(DOUBLES).apply(); + // Along the middle line x, the magnitude peaks at the two faces. + final RandomAccess ma = magnitude.randomAccess(); + double best = 0; + int bestX = -1; + for (int x = 0; x < 10; x++) { + ma.setPosition(new int[] { x, 9, 8 }); + if (ma.get().get() > best) { + best = ma.get().get(); + bestX = x; + } + } + assertTrue(bestX == 4 || bestX == 5, "peak at " + bestX); + + @SuppressWarnings("unchecked") + final RandomAccessibleInterval edges = + (RandomAccessibleInterval) ops.op("segment.cannyDericheEdges") + .input(img, 1.0, 5.0, 10.0).apply(); + // The face between x = 4 and x = 5 gives a one-voxel-thick edge. + for (int y = 7; y <= 10; y++) { + for (int z = 7; z <= 10; z++) { + assertTrue(edges.getAt(4, y, z).get() ^ edges.getAt(5, y, z).get(), + "face at " + y + "," + z); + assertTrue(!edges.getAt(3, y, z).get()); + assertTrue(!edges.getAt(6, y, z).get()); + assertTrue(!edges.getAt(8, y, z).get()); + } + } + // Far from the box, nothing. + for (final BitType b : Views.interval(edges, new long[] { 16, 0, 0 }, + new long[] { 19, 17, 15 })) + assertTrue(!b.get()); + } + + @Test + public void testInvalidArguments() { + final Img img = ArrayImgs.floats(5, 5); + assertThrows(IllegalArgumentException.class, () -> derivative(img, 0, 0, + null)); + assertThrows(IllegalArgumentException.class, () -> derivative(img, 1, 2, + null)); + assertThrows(IllegalArgumentException.class, () -> derivative(img, 1, 0, + new double[] { 1 })); + } +} diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/filter/diffusion/CoherenceEnhancingDiffusionTest.java b/scijava-ops-image/src/test/java/org/scijava/ops/image/filter/diffusion/CoherenceEnhancingDiffusionTest.java new file mode 100644 index 000000000..678ad1b3d --- /dev/null +++ b/scijava-ops-image/src/test/java/org/scijava/ops/image/filter/diffusion/CoherenceEnhancingDiffusionTest.java @@ -0,0 +1,210 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.filter.diffusion; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Random; + +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.img.array.ArrayImgs; +import net.imglib2.type.numeric.RealType; +import net.imglib2.view.Views; + +import org.junit.jupiter.api.Test; +import org.scijava.ops.image.AbstractOpTest; + +/** + * Tests {@link CoherenceEnhancingDiffusion}. + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +public class CoherenceEnhancingDiffusionTest extends AbstractOpTest { + + /** With alpha = 1, one iteration is one explicit step of the heat equation. */ + @Test + public void testIsotropicLimit() { + final var rnd = new Random(1); + final var in = ArrayImgs.doubles(7, 6); + in.forEach(t -> t.set(10 * rnd.nextDouble())); + final var out = ArrayImgs.doubles(7, 6); + final var dt = 0.2; + ops.op("filter.coherenceEnhancingDiffusion").input(in, 1.0, 2.0, 1, dt, + 1.0).output(out).compute(); + final var ra = in.randomAccess(); + final var c = out.localizingCursor(); + final var pos = new long[2]; + while (c.hasNext()) { + c.fwd(); + c.localize(pos); + final var v = ra.setPositionAndGet(pos).get(); + var lap = 0.0; + for (var d = 0; d < 2; d++) { + for (final var o : new int[] { -1, 1 }) { + final var q = pos.clone(); + q[d] += o; + if (q[d] < 0 || q[d] >= in.dimension(d)) continue; + lap += ra.setPositionAndGet(q).get() - v; + } + } + assertEquals(v + dt * lap, c.get().get(), 1e-9); + } + } + + @Test + public void testConstantImage() { + final var in = ArrayImgs.floats(8, 7, 5); + in.forEach(t -> t.set(42)); + final var out = ArrayImgs.floats(8, 7, 5); + ops.op("filter.coherenceEnhancingDiffusion").input(in, 1.0, 2.0, 5).output( + out).compute(); + out.forEach(t -> assertEquals(42, t.get(), 1e-4)); + } + + /** + * Noisy oblique stripes are restored much better than by isotropic diffusion + * of the same duration, since smoothing happens along the stripes. + */ + @Test + public void testStripes() { + final long w = 48, h = 48; + final var clean = ArrayImgs.doubles(w, h); + final var noisy = ArrayImgs.doubles(w, h); + final var rnd = new Random(3); + final var cc = clean.localizingCursor(); + final var nc = noisy.cursor(); + while (cc.hasNext()) { + cc.fwd(); + final var x = cc.getDoublePosition(0); + final var y = cc.getDoublePosition(1); + final var v = 100 + 50 * Math.sin(2 * Math.PI * (x + 0.5 * y) / 8); + cc.get().set(v); + nc.next().set(v + 30 * rnd.nextGaussian()); + } + final var ced = ArrayImgs.doubles(w, h); + ops.op("filter.coherenceEnhancingDiffusion").input(noisy, 1.0, 4.0, 40, + 0.2, 0.001, 1.0).output(ced).compute(); + final var iso = ArrayImgs.doubles(w, h); + ops.op("filter.coherenceEnhancingDiffusion").input(noisy, 1.0, 4.0, 40, + 0.2, 1.0).output(iso).compute(); + final var before = rmse(noisy, clean); + final var afterCed = rmse(ced, clean); + final var afterIso = rmse(iso, clean); + assertTrue(afterCed < before / 2, afterCed + " vs " + before); + assertTrue(afterCed < afterIso / 2, afterCed + " vs " + afterIso); + } + + /** Noise in 3D stays bounded and is smoothed. */ + @Test + public void testStability3D() { + final var rnd = new Random(4); + final var in = ArrayImgs.doubles(12, 11, 10); + in.forEach(t -> t.set(255 * rnd.nextDouble())); + final var out = ArrayImgs.doubles(12, 11, 10); + ops.op("filter.coherenceEnhancingDiffusion").input(in, 1.0, 2.0, 30) + .output(out).compute(); + out.forEach(t -> assertTrue(t.get() > -10 && t.get() < 265, "" + t)); + assertTrue(stdDev(out) < stdDev(in) / 2); + } + + @Test + public void testInvalidArguments() { + final var in = ArrayImgs.doubles(5, 5); + final var out = ArrayImgs.doubles(5, 5); + assertThrows(IllegalArgumentException.class, () -> ops.op( + "filter.coherenceEnhancingDiffusion").input(in, 1.0, 2.0, 1, 0.3).output( + out).compute()); + assertThrows(IllegalArgumentException.class, () -> ops.op( + "filter.coherenceEnhancingDiffusion").input(in, 1.0, 2.0, 1, 0.1, 0.0) + .output(out).compute()); + } + + private static double rmse(final RandomAccessibleInterval> a, + final RandomAccessibleInterval> b) + { + final var ac = Views.flatIterable(a).cursor(); + final var bc = Views.flatIterable(b).cursor(); + var sum = 0.0; + var count = 0; + while (ac.hasNext()) { + final var d = ac.next().getRealDouble() - bc.next().getRealDouble(); + sum += d * d; + count++; + } + return Math.sqrt(sum / count); + } + + private static double stdDev( + final RandomAccessibleInterval> img) + { + var sum = 0.0; + var sumSq = 0.0; + var count = 0; + for (final var t : Views.flatIterable(img)) { + final var v = t.getRealDouble(); + sum += v; + sumSq += v * v; + count++; + } + final var mean = sum / count; + return Math.sqrt(sumSq / count - mean * mean); + } + + /** + * An 8-bit output saturates rather than wrapping around: the result must be + * the floating-point result clamped to [0, 255]. + */ + @Test + public void testUnsignedByteOutputIsClamped() { + final var in = ArrayImgs.floats(16, 16); + final var rnd = new Random(3); + final var c = in.localizingCursor(); + while (c.hasNext()) { + c.fwd(); + c.get().setReal((c.getIntPosition(0) < 8 ? 300 : -20) + rnd + .nextDouble()); + } + final var expected = ArrayImgs.floats(16, 16); + ops.op("filter.coherenceEnhancingDiffusion").input(in, 1.0, 2.0, 5).output(expected).compute(); + final var actual = ArrayImgs.unsignedBytes(16, 16); + ops.op("filter.coherenceEnhancingDiffusion").input(in, 1.0, 2.0, 5).output(actual).compute(); + final var ec = expected.cursor(); + var saturated = 0; + for (final var t : actual) { + final var e = Math.max(0, Math.min(255, ec.next().get())); + assertEquals(e, t.get(), 0.5 + 1e-4); + if (t.get() == 0 || t.get() == 255) saturated++; + } + assertTrue(saturated > 100, "saturated pixels: " + saturated); + } +} diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/filter/diffusion/PeronaMalikDiffusionTest.java b/scijava-ops-image/src/test/java/org/scijava/ops/image/filter/diffusion/PeronaMalikDiffusionTest.java new file mode 100644 index 000000000..4c32a973f --- /dev/null +++ b/scijava-ops-image/src/test/java/org/scijava/ops/image/filter/diffusion/PeronaMalikDiffusionTest.java @@ -0,0 +1,190 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.filter.diffusion; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Random; + +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.img.array.ArrayImgs; +import net.imglib2.type.numeric.RealType; +import net.imglib2.util.Intervals; +import net.imglib2.view.Views; + +import org.junit.jupiter.api.Test; +import org.scijava.ops.image.AbstractOpTest; + +/** + * Tests {@link PeronaMalikDiffusion}. + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +public class PeronaMalikDiffusionTest extends AbstractOpTest { + + /** + * With a huge kappa the conductance is 1, so one iteration is one explicit + * step of the linear heat equation with zero-flux borders. + */ + @Test + public void testLinearLimit3D() { + final var rnd = new Random(1); + final var in = ArrayImgs.doubles(5, 4, 3); + in.forEach(t -> t.set(10 * rnd.nextDouble())); + final var out = ArrayImgs.doubles(5, 4, 3); + final var dt = 0.1; + ops.op("filter.peronaMalik").input(in, 1e9, 1, dt).output(out) + .compute(); + + final var ra = in.randomAccess(); + final var c = out.localizingCursor(); + final var pos = new long[3]; + while (c.hasNext()) { + c.fwd(); + c.localize(pos); + final var v = ra.setPositionAndGet(pos).get(); + var lap = 0.0; + for (var d = 0; d < 3; d++) { + for (final var o : new int[] { -1, 1 }) { + final var q = pos.clone(); + q[d] += o; + if (q[d] < 0 || q[d] >= in.dimension(d)) continue; + lap += ra.setPositionAndGet(q).get() - v; + } + } + assertEquals(v + dt * lap, c.get().get(), 1e-6); + } + } + + @Test + public void testTotalIntensityPreserved() { + final var rnd = new Random(2); + final var in = ArrayImgs.floats(9, 8); + in.forEach(t -> t.set(100 * rnd.nextFloat())); + for (final var g : new String[] { "exponential", "quadratic" }) { + final var out = ArrayImgs.floats(9, 8); + ops.op("filter.peronaMalik").input(in, 20.0, 15, 0.25, g) + .output(out).compute(); + assertEquals(sum(in), sum(out), 1e-2); + } + } + + @Test + public void testNoisyStep() { + final var rnd = new Random(3); + final long w = 40, h = 20; + final var in = ArrayImgs.floats(w, h); + final var c = in.localizingCursor(); + while (c.hasNext()) { + c.fwd(); + c.get().set((float) ((c.getLongPosition(0) < w / 2 ? 50 : 150) + 5 * rnd + .nextGaussian())); + } + final var flat = Intervals.createMinMax(0, 0, w / 2 - 4, h - 1); + final var noise = stdDev(Views.interval(in, flat)); + for (final var g : new String[] { "exponential", "quadratic" }) { + final var out = ArrayImgs.floats(w, h); + ops.op("filter.peronaMalik").input(in, 15.0, 30, null, g).output( + out).compute(); + assertTrue(stdDev(Views.interval(out, flat)) < noise / 3, g); + final var ra = out.randomAccess(); + for (var y = 0; y < h; y++) { + final var step = ra.setPositionAndGet(w / 2, y).get() - ra + .setPositionAndGet(w / 2 - 1, y).get(); + assertTrue(step > 80, g + ": step at y=" + y + ": " + step); + } + } + } + + @Test + public void testInvalidParameters() { + final var img = ArrayImgs.floats(5, 5); + final var out = ArrayImgs.floats(5, 5); + assertThrows(RuntimeException.class, () -> ops.op( + "filter.peronaMalik").input(img, 5.0, 1, 0.3).output(out) + .compute()); + assertThrows(RuntimeException.class, () -> ops.op( + "filter.peronaMalik").input(img, 5.0, 1, 0.1, "linear").output( + out).compute()); + } + + private static double sum( + final RandomAccessibleInterval> img) + { + double s = 0; + for (final var t : img) + s += t.getRealDouble(); + return s; + } + + private static double stdDev( + final RandomAccessibleInterval> img) + { + double s = 0, sq = 0; + for (final var t : img) { + final var v = t.getRealDouble(); + s += v; + sq += v * v; + } + final var mean = s / img.size(); + return Math.sqrt(sq / img.size() - mean * mean); + } + + /** + * An 8-bit output saturates rather than wrapping around: the result must be + * the floating-point result clamped to [0, 255]. + */ + @Test + public void testUnsignedByteOutputIsClamped() { + final var in = ArrayImgs.floats(16, 16); + final var rnd = new Random(3); + final var c = in.localizingCursor(); + while (c.hasNext()) { + c.fwd(); + c.get().setReal((c.getIntPosition(0) < 8 ? 300 : -20) + rnd + .nextDouble()); + } + final var expected = ArrayImgs.floats(16, 16); + ops.op("filter.peronaMalik").input(in, 10.0).output(expected).compute(); + final var actual = ArrayImgs.unsignedBytes(16, 16); + ops.op("filter.peronaMalik").input(in, 10.0).output(actual).compute(); + final var ec = expected.cursor(); + var saturated = 0; + for (final var t : actual) { + final var e = Math.max(0, Math.min(255, ec.next().get())); + assertEquals(e, t.get(), 0.5 + 1e-4); + if (t.get() == 0 || t.get() == 255) saturated++; + } + assertTrue(saturated > 100, "saturated pixels: " + saturated); + } +} diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/filter/flatfield/FlatFieldCorrectTest.java b/scijava-ops-image/src/test/java/org/scijava/ops/image/filter/flatfield/FlatFieldCorrectTest.java new file mode 100644 index 000000000..c48263b49 --- /dev/null +++ b/scijava-ops-image/src/test/java/org/scijava/ops/image/filter/flatfield/FlatFieldCorrectTest.java @@ -0,0 +1,157 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.filter.flatfield; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.Random; + +import net.imglib2.img.array.ArrayImg; +import net.imglib2.img.array.ArrayImgs; +import net.imglib2.img.basictypeaccess.array.DoubleArray; +import net.imglib2.loops.LoopBuilder; +import net.imglib2.type.numeric.integer.UnsignedByteType; +import net.imglib2.type.numeric.real.DoubleType; + +import org.junit.jupiter.api.Test; +import org.scijava.ops.image.AbstractOpTest; + +/** + * Tests {@link FlatFieldCorrect}. + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +public class FlatFieldCorrectTest extends AbstractOpTest { + + private static final long[] DIMS = { 12, 10, 3 }; + + /** + * Builds a specimen S, an illumination profile P, and simulates + * {@code in = S·P + dark} and {@code flat = c·P + dark}. The corrected image + * must then be {@code S·mean(P)}. + */ + private void check(final double darkValue, final boolean passDark) { + final var random = new Random(0xF1A7); + final var specimen = ArrayImgs.doubles(DIMS); + final var profile = ArrayImgs.doubles(DIMS); + final var c = profile.localizingCursor(); + final var s = specimen.cursor(); + double profileSum = 0; + while (c.hasNext()) { + c.fwd(); + final double x = c.getDoublePosition(0) / DIMS[0]; + final double y = c.getDoublePosition(1) / DIMS[1]; + final double p = 0.5 + x * (1 - y) + 0.1 * c.getDoublePosition(2); + c.get().set(p); + profileSum += p; + s.next().set(10 + 90 * random.nextDouble()); + } + final double meanProfile = profileSum / profile.size(); + + final ArrayImg in = ArrayImgs.doubles(DIMS); + final ArrayImg flat = ArrayImgs.doubles(DIMS); + final ArrayImg dark = ArrayImgs.doubles(DIMS); + dark.forEach(t -> t.set(darkValue)); + LoopBuilder.setImages(specimen, profile, in, flat).forEachPixel((sp, p, i, + f) -> { + i.set(sp.get() * p.get() + darkValue); + f.set(1234 * p.get() + darkValue); + }); + + final var out = ArrayImgs.floats(DIMS); + if (passDark) { + ops.op("filter.flatFieldCorrect").input(in, flat, dark).output(out) + .compute(); + } + else { + ops.op("filter.flatFieldCorrect").input(in, flat).output(out).compute(); + } + LoopBuilder.setImages(specimen, out).forEachPixel((sp, o) -> assertEquals( + sp.get() * meanProfile, o.get(), 1e-3)); + } + + @Test + public void testWithoutDark() { + check(0, false); + } + + @Test + public void testWithDark() { + check(100, true); + } + + @Test + public void testZeroFlatPixel() { + final var in = ArrayImgs.doubles(new double[] { 5, 6, 7, 8 }, 4); + final var flat = ArrayImgs.doubles(new double[] { 2, 0, 4, 2 }, 4); + final var out = ArrayImgs.doubles(4); + ops.op("filter.flatFieldCorrect").input(in, flat).output(out).compute(); + // The mean of the flat-field image is 2. + final double[] expected = { 5, 6, 3.5, 8 }; + final var o = out.cursor(); + for (final double e : expected) + assertEquals(e, o.next().get(), 1e-12); + } + + /** + * Integer outputs must saturate rather than wrap around: values above 255 + * become 255, and {@code in < dark} becomes 0. + */ + @Test + public void testUnsignedByteOutputIsClamped() { + final var in = ArrayImgs.unsignedBytes(new byte[] { 10, (byte) 200, + (byte) 250, 5 }, 4); + final var flat = ArrayImgs.unsignedBytes(new byte[] { 100, 50, 100, 100 }, + 4); + final var dark = ArrayImgs.unsignedBytes(new byte[] { 8, 8, 8, 8 }, 4); + + // Without dark: mean(flat) = 87.5, so the values are 8.75, 350 (too + // bright), 218.75 and 4.375. + final var out = ArrayImgs.unsignedBytes(4); + ops.op("filter.flatFieldCorrect").input(in, flat).output(out).compute(); + assertValues(new int[] { 9, 255, 219, 4 }, out); + + // With dark: mean(flat - dark) = 79.5, so the values are 1.73, 363.4 (too + // bright), 209.1 and -2.59 (in < dark). + final var outDark = ArrayImgs.unsignedBytes(4); + ops.op("filter.flatFieldCorrect").input(in, flat, dark).output(outDark) + .compute(); + assertValues(new int[] { 2, 255, 209, 0 }, outDark); + } + + private static void assertValues(final int[] expected, + final Iterable actual) + { + final var c = actual.iterator(); + for (final int e : expected) + assertEquals(e, c.next().get()); + } +} diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/filter/flatfield/PseudoFlatFieldTest.java b/scijava-ops-image/src/test/java/org/scijava/ops/image/filter/flatfield/PseudoFlatFieldTest.java new file mode 100644 index 000000000..063a6c5c3 --- /dev/null +++ b/scijava-ops-image/src/test/java/org/scijava/ops/image/filter/flatfield/PseudoFlatFieldTest.java @@ -0,0 +1,166 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.filter.flatfield; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.img.Img; +import net.imglib2.img.array.ArrayImgs; +import net.imglib2.loops.LoopBuilder; +import net.imglib2.outofbounds.OutOfBoundsBorderFactory; +import net.imglib2.type.numeric.RealType; +import net.imglib2.type.numeric.integer.UnsignedShortType; +import net.imglib2.type.numeric.real.FloatType; +import net.imglib2.view.Views; + +import org.junit.jupiter.api.Test; +import org.scijava.ops.image.AbstractOpTest; + +/** + * Tests {@link PseudoFlatField}. + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +public class PseudoFlatFieldTest extends AbstractOpTest { + + private static final int W = 64, H = 48; + + /** Computes a smooth vignetting profile: 1 at the center, 0.5 in corners. */ + private static double vignette(final long x, final long y) { + final double dx = (x - (W - 1) / 2.0) / (W / 2.0); + final double dy = (y - (H - 1) / 2.0) / (H / 2.0); + return 1 - 0.25 * (dx * dx + dy * dy); + } + + /** Creates a vignetted image of a few bright spots on a flat background. */ + private static Img vignettedSpots() { + final var img = ArrayImgs.unsignedShorts(W, H); + final var c = img.localizingCursor(); + while (c.hasNext()) { + c.fwd(); + final long x = c.getLongPosition(0), y = c.getLongPosition(1); + final boolean spot = x % 16 == 8 && y % 16 == 8; + c.get().setReal(Math.round((spot ? 3000 : 1000) * vignette(x, y))); + } + return img; + } + + /** Computes the coefficient of variation of the non-spot pixels. */ + private static double backgroundCV(final RandomAccessibleInterval< + ? extends RealType> img) + { + double sum = 0, sumSq = 0; + long n = 0; + final var c = Views.flatIterable(img).localizingCursor(); + while (c.hasNext()) { + c.fwd(); + final long x = c.getLongPosition(0), y = c.getLongPosition(1); + if (Math.abs(x % 16 - 8) <= 1 && Math.abs(y % 16 - 8) <= 1) continue; + final double v = c.get().getRealDouble(); + sum += v; + sumSq += v * v; + n++; + } + final double mean = sum / n; + return Math.sqrt(sumSq / n - mean * mean) / mean; + } + + @Test + public void testConstantImageUnchanged() { + final var in = ArrayImgs.floats(20, 15, 4); + in.forEach(t -> t.set(42)); + final var out = ArrayImgs.floats(20, 15, 4); + ops.op("filter.pseudoFlatField").input(in, 5.0).output(out).compute(); + for (final var t : out) + assertEquals(42, t.get(), 1e-3); + } + + @Test + public void testVignettingIsReduced() { + final var in = vignettedSpots(); + final var out = ArrayImgs.floats(W, H); + ops.op("filter.pseudoFlatField").input(in, 4.0).output(out).compute(); + final double before = backgroundCV(in); + final double after = backgroundCV(out); + // Before correction, the background varies by ~15%. + assertTrue(before > 0.1, "before: " + before); + assertTrue(after < before / 3, "before: " + before + ", after: " + after); + } + + @Test + public void testFormula() { + final var in = vignettedSpots(); + final double[] sigmas = { 10, 6 }; + final var out = ArrayImgs.floats(W, H); + ops.op("filter.pseudoFlatField").input(in, sigmas).output(out).compute(); + + // Compute the background independently, then check out * G = in * . + final Img bg = ArrayImgs.floats(W, H); + ops.op("filter.gauss").input(in, sigmas, + new OutOfBoundsBorderFactory<>()).output(bg).compute(); + double sum = 0; + for (final var t : bg) + sum += t.get(); + final double meanBg = sum / (W * H); + LoopBuilder.setImages(in, bg, out).forEachPixel((i, g, o) -> assertEquals( + i.getRealDouble() * meanBg, o.getRealDouble() * g.getRealDouble(), + 1e-2 * i.getRealDouble())); + } + + /** + * A bright pixel in a dim region is brightened beyond the range of an 8-bit + * output. It must saturate at 255 rather than wrap around, and every other + * pixel must match the floating-point result. + */ + @Test + public void testUnsignedByteOutputIsClamped() { + final int n = 64; + final var in = ArrayImgs.unsignedBytes(n); + final var c = in.cursor(); + for (int x = 0; x < n; x++) + c.next().set(x < n / 2 ? 200 : x == n - 8 ? 250 : 20); + final var outFloat = ArrayImgs.floats(n); + ops.op("filter.pseudoFlatField").input(in, 4.0).output(outFloat) + .compute(); + final var outByte = ArrayImgs.unsignedBytes(n); + ops.op("filter.pseudoFlatField").input(in, 4.0).output(outByte).compute(); + + // The bright pixel lies where the background is far below its mean. + assertTrue(outFloat.getAt(n - 8).get() > 300, "" + outFloat.getAt(n - 8)); + assertEquals(255, outByte.getAt(n - 8).get()); + LoopBuilder.setImages(outFloat, outByte).forEachPixel((f, b) -> { + final double expected = Math.min(255, f.get()); + assertEquals(expected, b.get(), 0.5 + 1e-6); + }); + } +} diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/filter/helmholtz/HelmholtzAnalysisTest.java b/scijava-ops-image/src/test/java/org/scijava/ops/image/filter/helmholtz/HelmholtzAnalysisTest.java new file mode 100644 index 000000000..26ee5afe7 --- /dev/null +++ b/scijava-ops-image/src/test/java/org/scijava/ops/image/filter/helmholtz/HelmholtzAnalysisTest.java @@ -0,0 +1,172 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.filter.helmholtz; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Arrays; + +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.img.Img; +import net.imglib2.img.array.ArrayImgs; +import net.imglib2.type.numeric.real.FloatType; +import net.imglib2.view.Views; + +import org.junit.jupiter.api.Test; +import org.scijava.ops.image.AbstractOpTest; +import org.scijava.types.Nil; + +/** + * Tests {@link HelmholtzAnalysis}. + *

+ * The plugin values below were produced by replicating + * {@code Helmholtz_Analysis.run} (with ImageJ 1.54p "Gaussian Blur...", + * "Bandpass Filter..." and image subtraction, then the plugin's + * {@code average} methods) on {@link #grating}. + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +public class HelmholtzAnalysisTest extends AbstractOpTest { + + private static final int W = 64, H = 64; + + private static final Nil> FLOATS = + new Nil<>() + {}; + + /** + * A sinusoidal grating whose wave vector points at angle {@code thetaDeg} + * from the x axis towards the y axis, plus a linear trend. + */ + private static Img grating(final double lambda, + final double thetaDeg) + { + final var p = new float[W * H]; + final double t = Math.toRadians(thetaDeg), k = 2 * Math.PI / lambda; + for (var y = 0; y < H; y++) + for (var x = 0; x < W; x++) + p[x + W * y] = (float) (100 + 50 * Math.cos(k * (x * Math.cos(t) + y * + Math.sin(t))) + 0.2 * x); + return ArrayImgs.floats(p, W, H); + } + + @Test + public void testGratings() { + // lambda, theta, lambdaMin, lambdaMax, radius, plugin lambda and phi + // medians (raw), plugin lambda and phi medians (averaged). + final double[][] cases = { // + { 8, 30, 4, 16, 5, 8.132935, 119.34082, 8.145168, 119.33787 }, // + { 10, 120, 4, 20, 6, 10.122702, 29.580921, 10.175095, 29.583069 }, // + { 6, 0, 3, 12, 4, 6.2832785, 90.0, 6.283233, 90.0 } }; + for (final var c : cases) { + final var in = grating(c[0], c[1]); + final var expectedPhi = (c[1] + 90) % 180; + for (final var r : new int[] { 0, (int) c[4] }) { + final var lambda = ops.op("filter.helmholtzWavelength").input(in, c[2], + c[3], r).outType(FLOATS).apply(); + final var phi = ops.op("filter.helmholtzOrientation").input(in, c[2], + c[3], r).outType(FLOATS).apply(); + final var lambdaMedian = median(lambda); + final var phiMedian = median(phi); + final var pluginLambda = r == 0 ? c[5] : c[7]; + final var pluginPhi = r == 0 ? c[6] : c[8]; + // Note: the discrete Laplacian biases short wavelengths upwards. + assertEquals(c[0], lambdaMedian, 0.06 * c[0]); + assertEquals(pluginLambda, lambdaMedian, 0.01 * c[0]); + assertEquals(expectedPhi, phiMedian, 1.5); + assertEquals(pluginPhi, phiMedian, 0.5); + assertTrue(validFraction(lambda) > 0.8); + assertTrue(validFraction(phi) > 0.7); + } + } + } + + /** + * Exactly horizontal fringes, whose gradient has no x component, must have + * orientation 0 everywhere, never 180, which is outside {@code [0, 180)}. + */ + @Test + public void testHorizontalFringes() { + final var p = new float[W * H]; + for (var y = 0; y < H; y++) + for (var x = 0; x < W; x++) + p[x + W * y] = (float) (100 + 50 * Math.cos(2 * Math.PI * y / 8)); + final var in = ArrayImgs.floats(p, W, H); + final var phi = ops.op("filter.helmholtzOrientation").input(in, 4.0, 16.0) + .outType(FLOATS).apply(); + assertTrue(validFraction(phi) > 0.5); + for (final var v : valid(phi)) + assertEquals(0, v, 1e-3); + } + + @Test + public void testOutOfRange() { + final var in = grating(6, 45); + final var lambda = ops.op("filter.helmholtzWavelength").input(in, 12.0, + 24.0).outType(FLOATS).apply(); + assertTrue(validFraction(lambda) < 0.25); + for (final var t : Views.iterable(lambda)) { + final var v = t.get(); + assertTrue(Float.isNaN(v) || v >= 12 && v <= 24); + } + } + + @Test + public void test3DUnsupported() { + final var in = ArrayImgs.floats(8, 8, 2); + assertThrows(IllegalArgumentException.class, () -> ops.op( + "filter.helmholtzWavelength").input(in, 2.0, 6.0).outType(FLOATS) + .apply()); + } + + private static double median(final RandomAccessibleInterval img) { + final var values = valid(img); + Arrays.sort(values); + return values[values.length / 2]; + } + + private static double validFraction( + final RandomAccessibleInterval img) + { + return (double) valid(img).length / img.size(); + } + + private static float[] valid(final RandomAccessibleInterval img) { + final var values = new float[(int) img.size()]; + var n = 0; + for (final var t : Views.iterable(img)) + if (!Float.isNaN(t.get())) values[n++] = t.get(); + return Arrays.copyOf(values, n); + } +} diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/filter/kalman/KalmanFilterTest.java b/scijava-ops-image/src/test/java/org/scijava/ops/image/filter/kalman/KalmanFilterTest.java new file mode 100644 index 000000000..002d2d5e6 --- /dev/null +++ b/scijava-ops-image/src/test/java/org/scijava/ops/image/filter/kalman/KalmanFilterTest.java @@ -0,0 +1,154 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.filter.kalman; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Random; + +import net.imglib2.img.array.ArrayImgs; +import net.imglib2.view.Views; + +import org.junit.jupiter.api.Test; +import org.scijava.ops.image.AbstractOpTest; + +/** + * Tests {@code filter.kalman}. + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +public class KalmanFilterTest extends AbstractOpTest { + + @Test + public void testNoiseDecreases() { + final int w = 8, h = 8, frames = 60; + final var rnd = new Random(4); + final var img = ArrayImgs.doubles(w, h, frames); + for (final var t : img) + t.set(100 + 10 * rnd.nextGaussian()); + final var out = ArrayImgs.doubles(w, h, frames); + ops.op("filter.kalman").input(img).output(out).compute(); + final var first = rms(Views.hyperSlice(out, 2, 0)); + final var last = rms(Views.hyperSlice(out, 2, frames - 1)); + assertEquals(10, first, 3); + // The steady state is an exponential average with weight 0.2, whose + // noise is 10 * sqrt(0.2 / 1.8) = 3.3. + assertTrue(last < 5, "final noise " + last); + } + + @Test + public void testRunningMean() { + // With g = 1, the update is x += K (z - x) with K_t = 1 / (t + 1), so the + // estimate is the mean of all frames so far. + final var rnd = new Random(8); + final var img = ArrayImgs.unsignedShorts(3, 2, 20); + for (final var t : img) + t.set(rnd.nextInt(1000)); + final var out = ArrayImgs.doubles(3, 2, 20); + ops.op("filter.kalman").input(img, 2, 1.0).output(out).compute(); + final var ia = img.randomAccess(); + final var oa = out.randomAccess(); + for (var x = 0; x < 3; x++) { + for (var y = 0; y < 2; y++) { + var sum = 0.0; + for (var t = 0; t < 20; t++) { + sum += ia.setPositionAndGet(x, y, t).get(); + assertEquals(sum / (t + 1), oa.setPositionAndGet(x, y, t).get(), + 1e-9); + } + } + } + } + + /** + * A step from 0 to 100 at frame 1, filtered along dimension 0 with the + * default gain g = 0.8. By the documented update of the Kalman stack + * filter, seeded with {@code x_0 = z_0} and {@code P_0 = R}, the Kalman gain + * is {@code K_t = 1 / (t + 1)}: + *
    + *
  • t = 1: K = 1/2, x = 0.8·0 + 0.2·100 + 0.5·(100 - 0) = 70;
  • + *
  • t = 2: K = 1/3, x = 0.8·70 + 0.2·100 + (100 - 70)/3 = 86;
  • + *
  • t = 3: K = 1/4, x = 0.8·86 + 0.2·100 + (100 - 86)/4 = 92.3;
  • + *
  • t = 4: K = 1/5, x = 0.8·92.3 + 0.2·100 + (100 - 92.3)/5 = 95.38.
  • + *
+ */ + @Test + public void testStepResponse() { + final var img = ArrayImgs.doubles(new double[] { 0, 100, 100, 100, 100, 0, + 100, 100, 100, 100 }, 5, 2); + final var out = ArrayImgs.doubles(5, 2); + ops.op("filter.kalman").input(img, 0).output(out).compute(); + final double[] expected = { 0, 70, 86, 92.3, 95.38 }; + final var oa = out.randomAccess(); + for (var y = 0; y < 2; y++) + for (var t = 0; t < 5; t++) + assertEquals(expected[t], oa.setPositionAndGet(t, y).get(), 1e-9); + } + + /** + * With {@code g = 0}, the Kalman term is not scaled down, so the estimate + * overshoots: for frames (0, 200), {@code x_1 = 200 + (200 - 0) / 2 = 300}. + * An 8-bit output saturates at 255. + */ + @Test + public void testNoGainOvershoots() { + final var img = ArrayImgs.doubles(new double[] { 0, 200 }, 1, 2); + final var out = ArrayImgs.doubles(1, 2); + ops.op("filter.kalman").input(img, 1, 0.0).output(out).compute(); + assertEquals(0, out.getAt(0, 0).get(), 0); + assertEquals(300, out.getAt(0, 1).get(), 1e-9); + + final var outByte = ArrayImgs.unsignedBytes(1, 2); + ops.op("filter.kalman").input(img, 1, 0.0).output(outByte).compute(); + assertEquals(255, outByte.getAt(0, 1).get()); + } + + @Test + public void testInvalidGain() { + final var img = ArrayImgs.doubles(4, 5); + assertThrows(RuntimeException.class, () -> ops.op("filter.kalman").input( + img, 1, 1.5).output(ArrayImgs.doubles(4, 5)).compute()); + } + + private static double rms( + final net.imglib2.RandomAccessibleInterval img) + { + var sum = 0.0; + long n = 0; + for (final var t : Views.iterable(img)) { + sum += Math.pow(t.get() - 100, 2); + n++; + } + return Math.sqrt(sum / n); + } +} diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/filter/kuwahara/DefaultKuwaharaTest.java b/scijava-ops-image/src/test/java/org/scijava/ops/image/filter/kuwahara/DefaultKuwaharaTest.java new file mode 100644 index 000000000..4f568b52b --- /dev/null +++ b/scijava-ops-image/src/test/java/org/scijava/ops/image/filter/kuwahara/DefaultKuwaharaTest.java @@ -0,0 +1,228 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.filter.kuwahara; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Random; + +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.img.array.ArrayImgs; +import net.imglib2.outofbounds.OutOfBoundsBorderFactory; +import net.imglib2.type.numeric.RealType; +import net.imglib2.type.numeric.real.DoubleType; +import net.imglib2.util.Intervals; +import net.imglib2.view.Views; + +import org.junit.jupiter.api.Test; +import org.scijava.ops.image.AbstractOpTest; + +/** + * Tests {@link DefaultKuwahara}. + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +public class DefaultKuwaharaTest extends AbstractOpTest { + + /** A noisy vertical step edge (8-bit). */ + private static final int[][] INPUT = { + { 58, 63, 62, 62, 57, 77, 181, 169, 196, 174, 172, 177 }, + { 78, 76, 44, 69, 69, 47, 186, 196, 194, 163, 168, 199 }, + { 49, 47, 61, 62, 42, 73, 177, 165, 195, 186, 181, 193 }, + { 56, 41, 50, 46, 41, 58, 170, 193, 195, 163, 185, 196 }, + { 41, 62, 76, 51, 40, 47, 200, 188, 181, 189, 182, 196 }, + { 78, 60, 51, 73, 68, 60, 183, 176, 162, 190, 169, 185 }, + { 74, 71, 60, 63, 64, 72, 181, 166, 189, 163, 180, 191 }, + { 58, 50, 41, 75, 70, 66, 184, 199, 196, 162, 179, 171 }, + { 46, 42, 45, 42, 74, 41, 171, 179, 174, 187, 198, 161 }, + { 56, 80, 69, 51, 73, 61, 168, 195, 164, 174, 175, 193 } }; + + /** + * Output of the Fiji Kuwahara_Filter plugin 2.0.1 ({@code filter(ip)} with + * its default size of 5) for {@link #INPUT}. Its border handling differs, so + * only pixels at least 2 pixels from the border are compared. + */ + private static final int[][] PLUGIN = { + { 60, 61, 59, 61, 59, 62, 184, 182, 184, 179, 181, 179 }, + { 56, 55, 54, 55, 54, 56, 186, 183, 186, 182, 181, 182 }, + { 54, 61, 59, 61, 59, 51, 184, 184, 184, 186, 184, 186 }, + { 57, 57, 54, 54, 54, 54, 186, 182, 186, 184, 180, 184 }, + { 54, 63, 54, 63, 61, 51, 181, 178, 184, 186, 184, 186 }, + { 60, 60, 63, 68, 63, 68, 183, 182, 180, 177, 180, 177 }, + { 54, 63, 61, 63, 61, 60, 182, 178, 178, 183, 178, 183 }, + { 60, 60, 63, 68, 63, 68, 182, 181, 182, 177, 179, 177 }, + { 54, 54, 54, 63, 59, 63, 182, 179, 182, 177, 181, 177 }, + { 54, 55, 54, 61, 60, 61, 181, 181, 179, 178, 179, 178 } }; + + @Test + public void testMatchesPlugin() { + final var h = INPUT.length; + final var w = INPUT[0].length; + final var in = ArrayImgs.unsignedBytes(w, h); + final var ra = in.randomAccess(); + for (var y = 0; y < h; y++) + for (var x = 0; x < w; x++) + ra.setPositionAndGet(x, y).set(INPUT[y][x]); + final var out = ArrayImgs.floats(w, h); + ops.op("filter.kuwahara").input(in).output(out).compute(); + final var oa = out.randomAccess(); + for (var y = 2; y < h - 2; y++) { + for (var x = 2; x < w - 2; x++) { + assertEquals(PLUGIN[y][x], Math.floor(oa.setPositionAndGet(x, y).get() + + 0.5), 0, "(" + x + ", " + y + ")"); + } + } + } + + @Test + public void testMatchesBruteForce3D() { + final var rnd = new Random(3); + final var in = ArrayImgs.doubles(7, 6, 5); + in.forEach(t -> t.set(100 * rnd.nextDouble())); + final var out = ArrayImgs.doubles(7, 6, 5); + final var oob = new OutOfBoundsBorderFactory>(); + ops.op("filter.kuwahara").input(in, 3, oob).output(out).compute(); + + final var ext = Views.extend(in, oob).randomAccess(); + final var c = out.localizingCursor(); + while (c.hasNext()) { + c.fwd(); + var bestVar = Double.POSITIVE_INFINITY; + var bestMean = 0.0; + for (var w = 0; w < 8; w++) { + double sum = 0, sumSq = 0; + for (var dz = 0; dz <= 1; dz++) + for (var dy = 0; dy <= 1; dy++) + for (var dx = 0; dx <= 1; dx++) { + final var v = ext.setPositionAndGet( // + c.getLongPosition(0) + ((w & 1) == 0 ? -dx : dx), // + c.getLongPosition(1) + ((w & 2) == 0 ? -dy : dy), // + c.getLongPosition(2) + ((w & 4) == 0 ? -dz : dz)).get(); + sum += v; + sumSq += v * v; + } + final var mean = sum / 8; + final var variance = sumSq / 8 - mean * mean; + if (variance < bestVar) { + bestVar = variance; + bestMean = mean; + } + } + assertEquals(bestMean, c.get().get(), 1e-9); + } + } + + @Test + public void testStepEdgePreservedAndNoiseReduced() { + final var rnd = new Random(11); + final long w = 40, h = 30; + final var clean = ArrayImgs.floats(w, h); + final var noisy = ArrayImgs.floats(w, h); + final var cc = clean.localizingCursor(); + final var nc = noisy.cursor(); + while (cc.hasNext()) { + final var v = cc.next(); + v.set(cc.getLongPosition(0) < w / 2 ? 50 : 150); + nc.next().set(v.get() + (float) (5 * rnd.nextGaussian())); + } + + // A clean step is reproduced exactly: every pixel has a flat sub-window. + final var out = ArrayImgs.floats(w, h); + ops.op("filter.kuwahara").input(clean, 7).output(out).compute(); + final var oc = out.cursor(); + for (final var t : clean) + assertEquals(t.get(), oc.next().get(), 1e-4); + + ops.op("filter.kuwahara").input(noisy, 7).output(out).compute(); + // Noise is reduced within the flat regions... + final var flat = Intervals.createMinMax(2, 2, w / 2 - 4, h - 3); + assertTrue(stdDev(Views.interval(out, flat)) < stdDev(Views.interval( + noisy, flat)) / 2); + // ...while the edge stays sharp. + final var ra = out.randomAccess(); + for (var y = 0; y < h; y++) { + assertEquals(50, ra.setPositionAndGet(w / 2 - 1, y).get(), 10); + assertEquals(150, ra.setPositionAndGet(w / 2, y).get(), 10); + } + } + + @Test + public void testInvalidSize() { + final var img = ArrayImgs.floats(5, 5); + assertThrows(RuntimeException.class, () -> ops.op("filter.kuwahara").input( + img, 4).output(ArrayImgs.floats(5, 5)).compute()); + } + + private static double stdDev( + final RandomAccessibleInterval> img) + { + double sum = 0, sumSq = 0; + long n = 0; + for (final var t : img) { + final var v = t.getRealDouble(); + sum += v; + sumSq += v * v; + n++; + } + final var mean = sum / n; + return Math.sqrt(sumSq / n - mean * mean); + } + + /** + * An 8-bit output saturates rather than wrapping around: the result must be + * the floating-point result clamped to [0, 255]. + */ + @Test + public void testUnsignedByteOutputIsClamped() { + final var in = ArrayImgs.floats(16, 16); + final var rnd = new Random(3); + final var c = in.localizingCursor(); + while (c.hasNext()) { + c.fwd(); + c.get().setReal((c.getIntPosition(0) < 8 ? 300 : -20) + rnd + .nextDouble()); + } + final var expected = ArrayImgs.floats(16, 16); + ops.op("filter.kuwahara").input(in).output(expected).compute(); + final var actual = ArrayImgs.unsignedBytes(16, 16); + ops.op("filter.kuwahara").input(in).output(actual).compute(); + final var ec = expected.cursor(); + var saturated = 0; + for (final var t : actual) { + final var e = Math.max(0, Math.min(255, ec.next().get())); + assertEquals(e, t.get(), 0.5 + 1e-4); + if (t.get() == 0 || t.get() == 255) saturated++; + } + assertTrue(saturated > 100, "saturated pixels: " + saturated); + } +} diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/filter/kuwahara/LinearKuwaharaTest.java b/scijava-ops-image/src/test/java/org/scijava/ops/image/filter/kuwahara/LinearKuwaharaTest.java new file mode 100644 index 000000000..1ac71a7a3 --- /dev/null +++ b/scijava-ops-image/src/test/java/org/scijava/ops/image/filter/kuwahara/LinearKuwaharaTest.java @@ -0,0 +1,229 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.filter.kuwahara; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Random; + +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.img.array.ArrayImg; +import net.imglib2.img.array.ArrayImgs; +import net.imglib2.img.basictypeaccess.array.DoubleArray; +import net.imglib2.type.numeric.real.DoubleType; + +import org.junit.jupiter.api.Test; +import org.scijava.ops.image.AbstractOpTest; +import org.scijava.types.Nil; + +/** + * Tests {@code filter.kuwaharaLinear}. + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +public class LinearKuwaharaTest extends AbstractOpTest { + + private static void assertImagesEqual( + final RandomAccessibleInterval expected, + final RandomAccessibleInterval actual, final double tol) + { + final var e = expected.randomAccess(); + final var c = net.imglib2.view.Views.flatIterable(actual) + .localizingCursor(); + while (c.hasNext()) { + final var v = c.next().get(); + assertEquals(e.setPositionAndGet(c).get(), v, tol, () -> "at " + c); + } + } + + @Test + public void testConstant() { + final var img = ArrayImgs.doubles(12, 10); + for (final var t : img) + t.set(7); + final var out = ArrayImgs.doubles(12, 10); + ops.op("filter.kuwaharaLinear").input(img).output(out).compute(); + assertImagesEqual(img, out, 1e-9); + ops.op("filter.kuwaharaLinear").input(img, 8, 5, "varianceOverMean") + .output(out).compute(); + assertImagesEqual(img, out, 1e-9); + } + + @Test + public void testStepEdgePreserved() { + final var img = ArrayImgs.doubles(16, 16); + final var c = img.localizingCursor(); + while (c.hasNext()) + c.next().set(c.getLongPosition(0) < 8 ? 10 : 90); + final var out = ArrayImgs.doubles(16, 16); + ops.op("filter.kuwaharaLinear").input(img).output(out).compute(); + assertImagesEqual(img, out, 1e-6); + } + + @Test + public void testThinLinePreserved() { + // A one-pixel bright diagonal line on a noisy background. + final var size = 32; + final var rnd = new Random(3); + final var img = ArrayImgs.doubles(size, size); + final var c = img.localizingCursor(); + while (c.hasNext()) { + final var t = c.next(); + final var onLine = c.getLongPosition(0) == c.getLongPosition(1); + t.set((onLine ? 100 : 0) + 10 * rnd.nextGaussian()); + } + final var linear = ArrayImgs.doubles(size, size); + ops.op("filter.kuwaharaLinear").input(img, 36).output(linear).compute(); + final var iso = ArrayImgs.doubles(size, size); + ops.op("filter.kuwahara").input(img, 5).output(iso).compute(); + + final var la = linear.randomAccess(); + final var ia = iso.randomAccess(); + double linearLine = 0, isoLine = 0, bgSq = 0, noisySq = 0; + var nBg = 0; + final var ra = img.randomAccess(); + for (var i = 6; i < size - 6; i++) { + linearLine += la.setPositionAndGet(i, i).get(); + isoLine += ia.setPositionAndGet(i, i).get(); + final var bg = la.setPositionAndGet(i, (i + size / 2) % size).get(); + bgSq += bg * bg; + final var n = ra.setPositionAndGet(i, (i + size / 2) % size).get(); + noisySq += n * n; + nBg++; + } + linearLine /= size - 12; + isoLine /= size - 12; + // The line keeps its brightness, unlike with the isotropic filter. + assertTrue(linearLine > 85, "linear line mean " + linearLine); + assertTrue(isoLine < 50, "isotropic line mean " + isoLine); + // The background noise is reduced. + assertTrue(Math.sqrt(bgSq / nBg) < Math.sqrt(noisySq / nBg) / 2); + } + + @Test + public void testOrientedConstantMatchesSearch() { + final var rnd = new Random(5); + final var img = ArrayImgs.doubles(10, 9); + for (final var t : img) + t.set(rnd.nextDouble() * 50); + final var zero = ArrayImgs.doubles(10, 9); + final var a = ArrayImgs.doubles(10, 9); + final var b = ArrayImgs.doubles(10, 9); + ops.op("filter.kuwaharaLinear").input(img, zero, 7).output(a).compute(); + ops.op("filter.kuwaharaLinear").input(img, 1, 7).output(b).compute(); + assertImagesEqual(b, a, 1e-12); + } + + @Test + public void testOrientationAdaptiveStripes() { + // Horizontal stripes three rows wide, with noise. + final long w = 24, h = 24; + final var rnd = new Random(9); + final var clean = ArrayImgs.doubles(w, h); + final var noisy = ArrayImgs.doubles(w, h); + final var cc = clean.localizingCursor(); + final var nc = noisy.cursor(); + while (cc.hasNext()) { + final var t = cc.next(); + t.set(cc.getLongPosition(1) / 3 % 2 == 0 ? 100 : 0); + nc.next().set(t.get() + 5 * rnd.nextGaussian()); + } + // On the clean image, the structure tensor orientation is exactly 0 + // (horizontal) and the stripes are reproduced exactly. + final var orientation = orientation(clean); + final var out = ArrayImgs.doubles(w, h); + ops.op("filter.kuwaharaLinear").input(clean, orientation).output(out) + .compute(); + assertImagesEqual(clean, out, 1e-9); + + // On the noisy image, smoothing along the stripes removes most noise. + ops.op("filter.kuwaharaLinear").input(noisy, orientation(noisy)).output( + out).compute(); + var errSq = 0.0; + var noiseSq = 0.0; + final var oc = out.cursor(); + final var c2 = clean.cursor(); + final var n2 = noisy.cursor(); + while (oc.hasNext()) { + final var e = c2.next().get(); + errSq += Math.pow(oc.next().get() - e, 2); + noiseSq += Math.pow(n2.next().get() - e, 2); + } + assertTrue(errSq < noiseSq / 4, errSq + " vs " + noiseSq); + } + + @Test + public void testInvalidArguments() { + final var img = ArrayImgs.doubles(5, 5); + assertThrows(RuntimeException.class, () -> ops.op("filter.kuwaharaLinear") + .input(img, 4, 4).output(ArrayImgs.doubles(5, 5)).compute()); + assertThrows(RuntimeException.class, () -> ops.op("filter.kuwaharaLinear") + .input(img, 4, 5, "median").output(ArrayImgs.doubles(5, 5)).compute()); + } + + private RandomAccessibleInterval orientation( + final ArrayImg img) + { + return ops.op("filter.orientation").input(img, 1.0, 2.0).outType( + new Nil>() + {}).apply(); + } + + /** + * An 8-bit output saturates rather than wrapping around: the result must be + * the floating-point result clamped to [0, 255]. + */ + @Test + public void testUnsignedByteOutputIsClamped() { + final var in = ArrayImgs.floats(16, 16); + final var rnd = new Random(3); + final var c = in.localizingCursor(); + while (c.hasNext()) { + c.fwd(); + c.get().setReal((c.getIntPosition(0) < 8 ? 300 : -20) + rnd + .nextDouble()); + } + final var expected = ArrayImgs.floats(16, 16); + ops.op("filter.kuwaharaLinear").input(in).output(expected).compute(); + final var actual = ArrayImgs.unsignedBytes(16, 16); + ops.op("filter.kuwaharaLinear").input(in).output(actual).compute(); + final var ec = expected.cursor(); + var saturated = 0; + for (final var t : actual) { + final var e = Math.max(0, Math.min(255, ec.next().get())); + assertEquals(e, t.get(), 0.5 + 1e-4); + if (t.get() == 0 || t.get() == 255) saturated++; + } + assertTrue(saturated > 100, "saturated pixels: " + saturated); + } +} diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/filter/localCoefficientOfVariation/LocalCoefficientOfVariationTest.java b/scijava-ops-image/src/test/java/org/scijava/ops/image/filter/localCoefficientOfVariation/LocalCoefficientOfVariationTest.java new file mode 100644 index 000000000..a8cc10998 --- /dev/null +++ b/scijava-ops-image/src/test/java/org/scijava/ops/image/filter/localCoefficientOfVariation/LocalCoefficientOfVariationTest.java @@ -0,0 +1,136 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.filter.localCoefficientOfVariation; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.img.array.ArrayImgs; +import net.imglib2.type.numeric.real.FloatType; +import net.imglib2.view.Views; + +import org.junit.jupiter.api.Test; +import org.scijava.ops.image.AbstractOpTest; +import org.scijava.ops.image.segment.phantast.PhantastTest; +import org.scijava.types.Nil; + +/** + * Tests {@link DefaultLocalCoefficientOfVariation}. + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +public class LocalCoefficientOfVariationTest extends AbstractOpTest { + + private static final Nil> FLOATS = + new Nil<>() + {}; + + /** + * Compares the local contrast to the values of the original PHANTAST + * plugin. The expected values were produced by calling + * {@code PHANTAST_.getLocalContrastImage(img, 1.2)} of PHANTAST-FIJI on + * {@link PhantastTest#synth()}. The plugin uses the older imglib2 {@code Gauss}, whose + * kernel differs slightly from that of {@code filter.gauss}. + */ + @Test + public void testLocalCoefficientOfVariationRegression() { + final var lc = ops.op("filter.localCoefficientOfVariation").input(PhantastTest.synth(), 1.2) + .outType(FLOATS).apply(); + final var ra = lc.randomAccess(); + final int[][] pos = { { 0, 0 }, { 20, 22 }, { 10, 20 }, { 33, 22 }, { 40, + 5 }, { 46, 40 }, { 63, 63 } }; + final double[] expected = { 0.014355456, 0.023312092, 0.14764106, + 0.23673302, 0.01423413, 0.09012429, 0.015692648 }; + for (var i = 0; i < pos.length; i++) { + final var actual = ra.setPositionAndGet(pos[i]).getRealDouble(); + assertEquals(expected[i], actual, 0.02 * expected[i], "at " + pos[i][0] + + "," + pos[i][1]); + } + } + + /** The local contrast is 0 on flat images and invariant to scaling. */ + @Test + public void testLocalCoefficientOfVariationInvariants() { + final var flat = ArrayImgs.floats(16, 16, 4); + for (final var t : flat) + t.set(42); + final var lcFlat = ops.op("filter.localCoefficientOfVariation").input(flat, 2.0) + .outType(FLOATS).apply(); + for (final var t : Views.iterable(lcFlat)) + assertEquals(0, t.get(), 1e-5); + + final var img = PhantastTest.synth(); + final var scaled = ArrayImgs.floats(64, 64); + final var c = img.cursor(); + final var sc = scaled.cursor(); + while (c.hasNext()) + sc.next().set(7 * c.next().get()); + final var lc = ops.op("filter.localCoefficientOfVariation").input(img, 1.5).outType( + FLOATS).apply(); + final var lcScaled = ops.op("filter.localCoefficientOfVariation").input(scaled, 1.5) + .outType(FLOATS).apply(); + final var a = Views.flatIterable(lc).cursor(); + final var b = Views.flatIterable(lcScaled).cursor(); + while (a.hasNext()) { + final var expected = a.next().get(); + assertEquals(expected, b.next().get(), 1e-4 * Math.max(1, expected)); + } + } + + /** + * A 16-bit checkerboard of {@code 40000 ± 5} has a local mean of 40000 and a + * local standard deviation of 5 wherever the Gaussian window averages over + * many squares, so {@code C = 5 / 40000}. Computing {@code E[I²] - E[I]²} in + * single precision loses this entirely, since {@code E[I²] ≈ 1.6e9} has a + * float ulp of 128. + */ + @Test + public void testSixteenBitWithLargeOffset() { + final int n = 32; + final var img = ArrayImgs.unsignedShorts(n, n); + final var c = img.localizingCursor(); + while (c.hasNext()) { + c.fwd(); + final var odd = (c.getIntPosition(0) + c.getIntPosition(1)) % 2 != 0; + c.get().set(odd ? 40005 : 39995); + } + final var lc = ops.op("filter.localCoefficientOfVariation").input(img, 3.0).outType( + FLOATS).apply(); + final var ra = lc.randomAccess(); + for (int y = 10; y < n - 10; y++) { + for (int x = 10; x < n - 10; x++) { + assertEquals(5.0 / 40000, ra.setPositionAndGet(x, y).get(), 1e-2 * 5.0 / + 40000, "at " + x + "," + y); + } + } + } + +} diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/filter/phaseCorrelation/DefaultPhaseCorrelateTest.java b/scijava-ops-image/src/test/java/org/scijava/ops/image/filter/phaseCorrelation/DefaultPhaseCorrelateTest.java new file mode 100644 index 000000000..0089de6ad --- /dev/null +++ b/scijava-ops-image/src/test/java/org/scijava/ops/image/filter/phaseCorrelation/DefaultPhaseCorrelateTest.java @@ -0,0 +1,232 @@ +/*- + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.filter.phaseCorrelation; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Random; + +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.img.array.ArrayImgs; +import net.imglib2.type.numeric.real.FloatType; +import net.imglib2.view.Views; + +import org.junit.jupiter.api.Test; +import org.scijava.ops.image.AbstractOpTest; + +/** + * Tests {@link DefaultPhaseCorrelate}. + *

+ * The test images sample a texture of random Gaussian blobs, which can be + * evaluated at any real position, so that subpixel shifts are exact. + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +public class DefaultPhaseCorrelateTest extends AbstractOpTest { + + @Test + public void testIntegerShift2D() { + final var texture = new Texture(2, 120, 64, 1); + final double[] shift = { 7, -4 }; + final var result = correlate(texture.sample(new long[] { 64, 64 }, + new double[2], 0.05, 2), texture.sample(new long[] { 64, 64 }, shift, + 0.05, 3), null); + assertArrayEquals(shift, result.getShift(), 0.1, result.toString()); + assertTrue(result.getCorrelation() > 0.9, result.toString()); + assertEquals((64 - 7) * (64 - 4), result.getOverlap()); + } + + @Test + public void testIntegerShiftWithoutSubpixel() { + final var texture = new Texture(2, 120, 64, 4); + final double[] shift = { -11, 13 }; + final var result = correlate(texture.sample(new long[] { 64, 64 }, + new double[2], 0.05, 5), texture.sample(new long[] { 64, 64 }, shift, + 0.05, 6), false); + assertArrayEquals(shift, result.getShift(), 0); + } + + /** + * Shifts of more than half the image size: the peak alone cannot tell them + * from the opposite shifts, so the cross-correlation must decide. + */ + @Test + public void testLargeShift2D() { + final var texture = new Texture(2, 400, 100, 7); + for (final double[] shift : new double[][] { { 40, -10 }, { -42, 6 } }) { + final var result = correlate(texture.sample(new long[] { 64, 64 }, + new double[2], 0.05, 8), texture.sample(new long[] { 64, 64 }, shift, + 0.05, 9), false); + assertArrayEquals(shift, result.getShift(), 0, result.toString()); + assertTrue(result.getCorrelation() > 0.9, result.toString()); + } + } + + @Test + public void testSubpixelShift2D() { + final var texture = new Texture(2, 120, 64, 10); + for (final double[] shift : new double[][] { { 5.3, -2.6 }, { -8.5, + 3.25 }, { 0.4, 0.7 } }) + { + final var result = correlate(texture.sample(new long[] { 64, 64 }, + new double[2], 0.02, 11), texture.sample(new long[] { 64, 64 }, + shift, 0.02, 12), null); + assertArrayEquals(shift, result.getShift(), 0.1, result.toString()); + } + } + + /** Images of different sizes. */ + @Test + public void testDifferentSizes() { + final var texture = new Texture(2, 150, 80, 13); + final double[] shift = { 12, 9 }; + final var result = correlate(texture.sample(new long[] { 70, 60 }, + new double[2], 0.05, 14), texture.sample(new long[] { 40, 50 }, shift, + 0.05, 15), false); + assertArrayEquals(shift, result.getShift(), 0); + } + + @Test + public void testIntegerShift3D() { + final var texture = new Texture(3, 150, 32, 16); + final double[] shift = { 3, -5, 2 }; + final var dims = new long[] { 32, 32, 24 }; + final var result = correlate(texture.sample(dims, new double[3], 0.05, + 17), texture.sample(dims, shift, 0.05, 18), null); + assertArrayEquals(shift, result.getShift(), 0.1, result.toString()); + assertTrue(result.getCorrelation() > 0.9, result.toString()); + } + + @Test + public void testSubpixelShift3D() { + final var texture = new Texture(3, 150, 32, 19); + final double[] shift = { 2.5, -1.25, 3.7 }; + final var dims = new long[] { 32, 32, 24 }; + final var result = correlate(texture.sample(dims, new double[3], 0.02, + 20), texture.sample(dims, shift, 0.02, 21), null); + assertArrayEquals(shift, result.getShift(), 0.1, result.toString()); + } + + /** The same image is not shifted, wherever it is in space. */ + @Test + public void testIdentity() { + final var texture = new Texture(2, 100, 48, 22); + final var img = texture.sample(new long[] { 48, 40 }, new double[2], 0, + 23); + final var result = correlate(img, Views.translate(img, 100, -7), null); + assertArrayEquals(new double[2], result.getShift(), 1e-6); + assertEquals(1, result.getCorrelation(), 1e-6); + assertEquals(48 * 40, result.getOverlap()); + } + + /** Constant images have no shift to find. */ + @Test + public void testConstant() { + final var img = ArrayImgs.floats(16, 16); + img.forEach(t -> t.set(3)); + final var result = correlate(img, img, null); + assertTrue(Double.isNaN(result.getCorrelation())); + assertArrayEquals(new double[2], result.getShift(), 0); + assertEquals(0, result.getOverlap()); + } + + private PhaseCorrelationResult correlate( + final RandomAccessibleInterval image1, + final RandomAccessibleInterval image2, final Boolean subpixel) + { + if (subpixel == null) { + return ops.op("filter.phaseCorrelate").input(image1, image2).outType( + PhaseCorrelationResult.class).apply(); + } + return ops.op("filter.phaseCorrelate").input(image1, image2, 5, subpixel) + .outType(PhaseCorrelationResult.class).apply(); + } + + /** A sum of random Gaussian blobs. */ + private static class Texture { + + private final double[][] centers; + private final double[] sigmas; + private final double[] amplitudes; + + private Texture(final int n, final int count, final double extent, + final long seed) + { + final var rnd = new Random(seed); + centers = new double[count][n]; + sigmas = new double[count]; + amplitudes = new double[count]; + for (var i = 0; i < count; i++) { + // Note: blobs also lie around the field, so that it has no edges. + for (var d = 0; d < n; d++) + centers[i][d] = (1.5 * rnd.nextDouble() - 0.5) * extent; + sigmas[i] = 1.5 + 2.5 * rnd.nextDouble(); + amplitudes[i] = 0.5 + rnd.nextDouble(); + } + } + + private double value(final double[] x) { + double sum = 0; + for (var i = 0; i < sigmas.length; i++) { + double r2 = 0; + for (var d = 0; d < x.length; d++) { + final var dx = x[d] - centers[i][d]; + r2 += dx * dx; + } + sum += amplitudes[i] * Math.exp(-r2 / (2 * sigmas[i] * sigmas[i])); + } + return sum; + } + + /** + * Samples {@code image(x) = texture(x - shift)} with additive Gaussian + * noise of the given standard deviation. + */ + private RandomAccessibleInterval sample(final long[] dims, + final double[] shift, final double noise, final long seed) + { + final var rnd = new Random(seed); + final var img = ArrayImgs.floats(dims); + final var cursor = img.localizingCursor(); + final var x = new double[dims.length]; + while (cursor.hasNext()) { + cursor.fwd(); + for (var d = 0; d < x.length; d++) + x[d] = cursor.getDoublePosition(d) - shift[d]; + cursor.get().setReal(value(x) + noise * rnd.nextGaussian()); + } + return img; + } + } +} diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/filter/retinex/RetinexTest.java b/scijava-ops-image/src/test/java/org/scijava/ops/image/filter/retinex/RetinexTest.java new file mode 100644 index 000000000..16f79a7cd --- /dev/null +++ b/scijava-ops-image/src/test/java/org/scijava/ops/image/filter/retinex/RetinexTest.java @@ -0,0 +1,246 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.filter.retinex; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Random; + +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.img.array.ArrayImgs; +import net.imglib2.type.numeric.ARGBType; +import net.imglib2.type.numeric.real.FloatType; +import net.imglib2.util.Intervals; +import net.imglib2.view.Views; + +import org.junit.jupiter.api.Test; +import org.scijava.ops.image.AbstractOpTest; +import org.scijava.types.Nil; + +/** + * Tests {@code filter.retinex}. + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +public class RetinexTest extends AbstractOpTest { + + private static final Nil> OUT = + new Nil<>() + {}; + + private RandomAccessibleInterval retinex( + final RandomAccessibleInterval img, final double[] sigmas, + final Double dynamic) + { + return ops.op("filter.retinex").input(img, sigmas, dynamic).outType(OUT) + .apply(); + } + + @Test + public void testConstantImage() { + final var img = ArrayImgs.doubles(16, 12); + for (final var t : img) + t.set(3); + // log I - log(G * I) vanishes everywhere. + for (final var t : Views.iterable(retinex(img, new double[] { 2, 5 }, + 0.0))) + assertEquals(0, t.get(), 1e-6); + // With no contrast, the stretch maps to mid-grey. + for (final var t : Views.iterable(ops.op("filter.retinex").input(img) + .outType(OUT).apply())) + assertEquals(0.5, t.get(), 1e-6); + } + + @Test + public void testScalesApplyToOriginal() { + // The multi-scale result is the mean of the single-scale results, i.e. + // each surround is applied to the original image, not to the previous + // blur. + final var rnd = new Random(1); + final var img = ArrayImgs.unsignedBytes(20, 18); + for (final var t : img) + t.set(1 + rnd.nextInt(255)); + final var a = retinex(img, new double[] { 2 }, 0.0); + final var b = retinex(img, new double[] { 6 }, 0.0); + final var ab = retinex(img, new double[] { 2, 6 }, 0.0); + final var ca = Views.flatIterable(a).cursor(); + final var cb = Views.flatIterable(b).cursor(); + for (final var t : Views.flatIterable(ab)) + assertEquals((ca.next().get() + cb.next().get()) / 2, t.get(), 1e-5); + + // Single scale, by hand at one pixel: log I - log(G * I). + final RandomAccessibleInterval blurred = ops.op("filter.gauss") + .input(img, 2.0).outType(OUT).apply(); + final var x = 7; + final var y = 9; + final var i = img.randomAccess().setPositionAndGet(x, y).get(); + final var g = blurred.randomAccess().setPositionAndGet(x, y).get(); + assertEquals(Math.log(i) - Math.log(g), a.randomAccess().setPositionAndGet( + x, y).get(), 1e-4); + } + + @Test + public void testIlluminationRemoved() { + // A reflectance checkerboard under an exponential illumination ramp. + final long w = 64, h = 32; + final var lit = ArrayImgs.doubles(w, h); + final var c = lit.localizingCursor(); + while (c.hasNext()) { + c.fwd(); + final long x = c.getLongPosition(0), y = c.getLongPosition(1); + final var reflectance = (x / 4 + y / 4) % 2 == 0 ? 0.2 : 0.8; + c.get().set(reflectance * Math.exp(x / 30.0)); + } + final var sigmas = new double[] { 3, 6, 12 }; + final var out = retinex(lit, sigmas, null); + + final var left = Intervals.createMinMax(12, 4, 27, h - 5); + final var right = Intervals.createMinMax(36, 4, 51, h - 5); + final var inRatio = mean(Views.interval(lit, right)) / mean(Views.interval( + lit, left)); + assertTrue(inRatio > 2, "input ratio " + inRatio); + final var outDiff = Math.abs(mean(Views.interval(out, right)) - mean(Views + .interval(out, left))); + assertTrue(outDiff < 0.05, "output difference " + outDiff); + for (final var t : Views.iterable(out)) + assertTrue(t.get() >= 0 && t.get() <= 1); + } + + @Test + public void testColorRestoration() { + // A grey image keeps equal channels. + final var rnd = new Random(2); + final long w = 12, h = 10; + final var grey = ArrayImgs.unsignedBytes(w, h, 3); + final var ra = grey.randomAccess(); + for (var y = 0; y < h; y++) { + for (var x = 0; x < w; x++) { + final var v = 1 + rnd.nextInt(255); + for (var ch = 0; ch < 3; ch++) + ra.setPositionAndGet(x, y, ch).set(v); + } + } + final RandomAccessibleInterval out = ops.op("filter.retinex") + .input(grey, 2, new double[] { 2, 4 }).outType(OUT).apply(); + final var oa = out.randomAccess(); + for (var y = 0; y < h; y++) { + for (var x = 0; x < w; x++) { + final var r = oa.setPositionAndGet(x, y, 0).get(); + assertEquals(r, oa.setPositionAndGet(x, y, 1).get(), 1e-6); + assertEquals(r, oa.setPositionAndGet(x, y, 2).get(), 1e-6); + } + } + + // The ARGB variant agrees with the channel variant. + final var argb = ArrayImgs.argbs(w, h); + final var aa = argb.randomAccess(); + final var col = ArrayImgs.unsignedBytes(w, h, 3); + final var ca = col.randomAccess(); + for (var y = 0; y < h; y++) { + for (var x = 0; x < w; x++) { + final int r = rnd.nextInt(256), g = rnd.nextInt(256), b = rnd.nextInt( + 256); + aa.setPositionAndGet(x, y).set(ARGBType.rgba(r, g, b, 255)); + ca.setPositionAndGet(x, y, 0).set(r); + ca.setPositionAndGet(x, y, 1).set(g); + ca.setPositionAndGet(x, y, 2).set(b); + } + } + final var sigmas = new double[] { 3 }; + final RandomAccessibleInterval fromARGB = ops.op( + "filter.retinex").input(argb, sigmas, 1.0).outType(OUT).apply(); + final RandomAccessibleInterval fromChannels = ops.op( + "filter.retinex").input(col, 2, sigmas, 1.0).outType(OUT).apply(); + assertArrayEquals(col.dimensionsAsLongArray(), fromARGB + .dimensionsAsLongArray()); + final var fa = Views.flatIterable(fromARGB).cursor(); + for (final var t : Views.flatIterable(fromChannels)) + assertEquals(t.get(), fa.next().get(), 1e-6); + } + + /** + * Checks the MSRCR value analytically. If channel {@code c} is + * {@code k_c f(x, y)} for a spatial pattern {@code f}, the retinex of each + * channel equals the (single-channel) retinex {@code R_f} of {@code f}, as + * the constant factor cancels in {@code log I - log(G * I)}, and the colour + * restoration factor {@code β (log(α I_c) - log Σ_j I_j)} reduces to the + * constant {@code β log(α k_c / Σ_j k_j)}. With {@code dynamic = 0}, the + * output is therefore {@code R_f β log(α k_c / Σ_j k_j)}, with + * {@code α = 125} and {@code β = 46}. + */ + @Test + public void testColorRestorationValue() { + final long w = 14, h = 11; + final double[] k = { 1, 2, 5 }; + final var rnd = new Random(5); + final var pattern = ArrayImgs.doubles(w, h); + for (final var t : pattern) + t.set(10 + 90 * rnd.nextDouble()); + final var color = ArrayImgs.doubles(w, h, 3); + final var pa = pattern.randomAccess(); + final var ca = color.randomAccess(); + for (var y = 0; y < h; y++) + for (var x = 0; x < w; x++) + for (var c = 0; c < 3; c++) + ca.setPositionAndGet(x, y, c).set(k[c] * pa.setPositionAndGet(x, y) + .get()); + + final var sigmas = new double[] { 2, 5 }; + final var rf = retinex(pattern, sigmas, 0.0).randomAccess(); + final RandomAccessibleInterval out = ops.op("filter.retinex") + .input(color, 2, sigmas, 0.0).outType(OUT).apply(); + final var oa = out.randomAccess(); + for (var c = 0; c < 3; c++) { + final var restoration = 46 * Math.log(125 * k[c] / 8); + for (var y = 0; y < h; y++) { + for (var x = 0; x < w; x++) { + final var expected = rf.setPositionAndGet(x, y).get() * restoration; + assertEquals(expected, oa.setPositionAndGet(x, y, c).get(), 1e-4 * + (1 + Math.abs(expected))); + } + } + } + } + + private static double mean( + final RandomAccessibleInterval> img) + { + double sum = 0; + long n = 0; + for (final var t : Views.iterable(img)) { + sum += t.getRealDouble(); + n++; + } + return sum / n; + } +} diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/filter/tensor/TensorFiltersTest.java b/scijava-ops-image/src/test/java/org/scijava/ops/image/filter/tensor/TensorFiltersTest.java new file mode 100644 index 000000000..6050c13ff --- /dev/null +++ b/scijava-ops-image/src/test/java/org/scijava/ops/image/filter/tensor/TensorFiltersTest.java @@ -0,0 +1,408 @@ +/*- + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.filter.tensor; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Random; +import java.util.function.ToDoubleFunction; + +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.img.Img; +import net.imglib2.img.array.ArrayImgs; +import net.imglib2.type.numeric.integer.UnsignedByteType; +import net.imglib2.type.numeric.real.DoubleType; +import net.imglib2.util.Intervals; +import net.imglib2.view.Views; + +import org.junit.jupiter.api.Test; +import org.scijava.ops.image.AbstractOpTest; +import org.scijava.types.Nil; + +/** + * Tests {@link TensorFilters}, mostly against analytic results on synthetic + * images. + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +public class TensorFiltersTest extends AbstractOpTest { + + private static final Nil> OUT = + new Nil<>() + {}; + + // -- filter.hessianEigenvalues -- + + @Test + public void testHessianEigenvalues2D() { + // For f = x² + xy + y², H = [[2, 1], [1, 2]] has eigenvalues 1 and 3. + final var img = image(p -> p[0] * p[0] + p[0] * p[1] + p[1] * p[1], 21, + 21); + for (final double sigma : new double[] { 0, 1.5 }) { + final var evs = ops.op("filter.hessianEigenvalues").input(img, sigma) + .outType(OUT).apply(); + assertArrayEquals(new long[] { 21, 21, 2 }, evs.dimensionsAsLongArray()); + assertArrayEquals(new double[] { 1, 3 }, at(evs, 10, 10), 1e-6); + } + } + + @Test + public void testHessianEigenvaluesSortedByMagnitude3D() { + // For f = -x² - 2y² + 3z², the eigenvalues are -2, -4 and 6. + final var img = image(p -> -p[0] * p[0] - 2 * p[1] * p[1] + 3 * p[2] * + p[2], 15, 15, 15); + final var evs = ops.op("filter.hessianEigenvalues").input(img, 1.0) + .outType(OUT).apply(); + assertArrayEquals(new double[] { -2, -4, 6 }, at(evs, 7, 7, 7), 1e-6); + } + + @Test + public void testHessianEigenvaluesNormalizedAndCalibrated() { + // For f = x² in pixel units, Hxx is 2. + final var img = image(p -> p[0] * p[0], 21, 21); + // Scale normalization multiplies by sigma². + final var normalized = ops.op("filter.hessianEigenvalues").input(img, 2.0, + true).outType(OUT).apply(); + assertArrayEquals(new double[] { 0, 8 }, at(normalized, 10, 10), 1e-6); + // With pixel spacing 0.5 along x, f is 4 x² in physical units, so Hxx is 8. + final var calibrated = ops.op("filter.hessianEigenvalues").input(img, 1.0, + false, new double[] { 0.5, 1 }).outType(OUT).apply(); + assertArrayEquals(new double[] { 0, 8 }, at(calibrated, 10, 10), 1e-6); + } + + @Test + public void testHessianEigenvaluesTranslatedInput() { + final var img = image(p -> 3 * p[0] * p[0] - p[1] * p[1], 21, 21); + final var translated = Views.translate(img, 100, -50); + final var evs = ops.op("filter.hessianEigenvalues").input(translated, 1.0) + .outType(OUT).apply(); + assertArrayEquals(new long[] { 0, 0, 0 }, evs.minAsLongArray()); + assertArrayEquals(new double[] { -2, 6 }, at(evs, 10, 10), 1e-6); + } + + // -- filter.structureTensor -- + + @Test + public void testStructureTensor() { + // The linear ramp f = 2x + 3y has gradient (2, 3) everywhere. + final var img = image(p -> 2 * p[0] + 3 * p[1], 31, 31); + final var tensor = ops.op("filter.structureTensor").input(img, 1.0, 2.0) + .outType(OUT).apply(); + assertArrayEquals(new long[] { 31, 31, 3 }, tensor.dimensionsAsLongArray()); + assertArrayEquals(new double[] { 4, 6, 9 }, at(tensor, 15, 15), 1e-9); + + // In 3D, for f = x - 2y + 3z, a z spacing of 2 halves the z derivative. + final var img3 = image(p -> p[0] - 2 * p[1] + 3 * p[2], 21, 21, 21); + final var tensor3 = ops.op("filter.structureTensor").input(img3, 1.0, 1.0, + new double[] { 1, 1, 2 }).outType(OUT).apply(); + // The gradient is (1, -2, 1.5), and the order is [xx, xy, xz, yy, yz, zz]. + assertArrayEquals(new double[] { 1, -2, 1.5, 4, -3, 2.25 }, at(tensor3, 10, + 10, 10), 1e-9); + } + + @Test + public void testStructureTensorEigenvalues() { + final var img = image(p -> 2 * p[0] + 3 * p[1], 31, 31); + final var evs = ops.op("filter.structureTensorEigenvalues").input(img, 1.0, + 2.0).outType(OUT).apply(); + assertArrayEquals(new double[] { 13, 0 }, at(evs, 15, 15), 1e-9); + + // For a random 3D image, the eigenvalues are sorted descending and non-negative, + // and their sum equals the trace of the structure tensor. + final var rnd = randomImage(new Random(42), 12, 13, 14); + final var evs3 = ops.op("filter.structureTensorEigenvalues").input(rnd, + 1.0, 1.5).outType(OUT).apply(); + final var tensor3 = ops.op("filter.structureTensor").input(rnd, 1.0, 1.5) + .outType(OUT).apply(); + for (final var pos : new long[][] { { 0, 0, 0 }, { 5, 6, 7 }, { 11, 12, + 13 } }) + { + final var ev = at(evs3, pos); + final var t = at(tensor3, pos); + assertTrue(ev[0] >= ev[1] && ev[1] >= ev[2] && ev[2] >= -1e-12); + assertEquals(t[0] + t[3] + t[5], ev[0] + ev[1] + ev[2], 1e-9); + } + } + + // -- filter.shapeIndex -- + + @Test + public void testShapeIndex() { + // A cap gives 1. + assertShapeIndex(1, p -> -(p[0] * p[0] + p[1] * p[1])); + // A ridge gives 0.5. + assertShapeIndex(0.5, p -> -p[0] * p[0]); + // A saddle gives 0. + assertShapeIndex(0, p -> p[0] * p[0] - p[1] * p[1]); + // A valley gives -0.5. + assertShapeIndex(-0.5, p -> 2 * p[1] * p[1]); + // A cup gives -1. + assertShapeIndex(-1, p -> p[0] * p[0] + p[1] * p[1]); + // A plane, where the shape index is undefined, gives 0. + assertShapeIndex(0, p -> 7); + // An elliptic cap with κ1 = -2 and κ2 = -6 gives -(2/π) atan(-8/4). + assertShapeIndex(2 / Math.PI * Math.atan(2), p -> -(p[0] * p[0] + 3 * + p[1] * p[1])); + } + + @Test + public void testShapeIndexRequires2D() { + final var img = image(p -> 0, 5, 5, 5); + assertThrows(RuntimeException.class, () -> ops.op("filter.shapeIndex") + .input(img, 1.0).outType(OUT).apply()); + } + + // -- filter.sheetness -- + + @Test + public void testSheetness() { + final double s = 2, sigma = 2; + final var plate = image(p -> gauss(p[2], s), 21, 21, 21); + final var line = image(p -> gauss(p[1], s) * gauss(p[2], s), 21, 21, 21); + final var blob = image(p -> gauss(p[0], s) * gauss(p[1], s) * gauss(p[2], + s), 21, 21, 21); + + // Analytically, smoothing the plate profile gives a Gaussian of variance + // w² = s² + σ² and amplitude s / w, whose second derivative at the + // center is -s / w³; scale-normalized, the response is σ² s / w³. + final var w = Math.sqrt(s * s + sigma * sigma); + final var expected = sigma * sigma * s / (w * w * w); + final var plateResponse = sheetness(plate, sigma)[0]; + assertEquals(expected, plateResponse, 0.05 * expected); + + final var lineResponse = sheetness(line, sigma)[0]; + final var blobResponse = sheetness(blob, sigma)[0]; + assertEquals(0, lineResponse, 1e-3 * plateResponse); + assertEquals(0, blobResponse, 1e-3 * plateResponse); + + // A dark plate, whose curvature across it is positive, gives 0. + final var darkPlate = image(p -> -gauss(p[2], s), 21, 21, 21); + assertEquals(0, sheetness(darkPlate, sigma)[0], 0); + + // Away from the plate center, on its flank, the response decreases. + final var flank = sheetness(plate, sigma)[1]; + assertTrue(flank < plateResponse); + } + + @Test + public void testSheetnessRequires3D() { + final var img = image(p -> 0, 5, 5); + assertThrows(RuntimeException.class, () -> ops.op("filter.sheetness") + .input(img, 1.0).outType(OUT).apply()); + } + + // -- filter.orientation, filter.coherency -- + + @Test + public void testOrientationOfStripes() { + for (final double alpha : new double[] { 0, Math.PI / 6, Math.PI / 4, + -Math.PI / 3, 1.2 }) + { + // The stripes run at angle alpha counterclockwise as displayed (y up), + // i.e. along (cos α, -sin α) in pixel coordinates. + final var img = stripes(alpha); + final var orientation = ops.op("filter.orientation").input(img, 1.0, 3.0) + .outType(OUT).apply(); + // Note: the tolerance covers the finite-difference bias of the gradient + // direction on sampled sinusoids. + assertEquals(alpha, at(orientation, 32, 32)[0], 1e-2, "alpha=" + alpha); + final var coherency = ops.op("filter.coherency").input(img, 1.0, 3.0) + .outType(OUT).apply(); + assertEquals(1, at(coherency, 32, 32)[0], 1e-6, "alpha=" + alpha); + } + // Vertical stripes give ±π/2. + final var vertical = ops.op("filter.orientation").input(stripes(Math.PI / + 2), 1.0, 3.0).outType(OUT).apply(); + assertEquals(Math.PI / 2, Math.abs(at(vertical, 32, 32)[0]), 1e-2); + } + + @Test + public void testCoherencyIsotropic() { + // An egg-crate pattern has no dominant orientation. + final var w = 2 * Math.PI / 8; + final var img = image(p -> Math.sin(w * p[0]) * Math.sin(w * p[1]), 64, + 64); + final var coherency = ops.op("filter.coherency").input(img, 1.0, 8.0) + .outType(OUT).apply(); + assertEquals(0, at(coherency, 32, 32)[0], 0.02); + // A constant image has zero coherency. + final var flat = ops.op("filter.coherency").input(image(p -> 5, 16, 16), + 1.0, 2.0).outType(OUT).apply(); + assertEquals(0, at(flat, 8, 8)[0], 0); + } + + // -- filter.harris -- + + @Test + public void testHarrisSquare() { + // Draw a bright square spanning [20, 59] on a dark background. + final Img img = ArrayImgs.unsignedBytes(80, 80); + Views.interval(img, Intervals.createMinMax(20, 20, 59, 59)).forEach(t -> t + .set(200)); + final var response = ops.op("filter.harris").input(img, 1.0, 2.0) + .outType(OUT).apply(); + + // The strongest responses are at the four corners. + final var c = Views.flatIterable(response).cursor(); + var max = Double.NEGATIVE_INFINITY; + final var argmax = new long[2]; + while (c.hasNext()) { + final var v = c.next().get(); + if (v > max) { + max = v; + c.localize(argmax); + } + } + assertTrue(max > 0); + final var dx = Math.min(Math.abs(argmax[0] - 19.5), Math.abs(argmax[0] - + 59.5)); + final var dy = Math.min(Math.abs(argmax[1] - 19.5), Math.abs(argmax[1] - + 59.5)); + assertTrue(dx <= 2 && dy <= 2, "argmax at " + argmax[0] + "," + + argmax[1]); + + // Edges give negative responses, and flat regions give about 0. + assertTrue(at(response, 40, 20)[0] < 0); + assertTrue(at(response, 20, 40)[0] < 0); + assertEquals(0, at(response, 40, 40)[0], 1e-9 * max); + assertEquals(0, at(response, 3, 3)[0], 1e-9 * max); + + // The response respects the symmetry of the configuration. + assertEquals(at(response, 20, 20)[0], at(response, 59, 59)[0], 1e-9 * max); + } + + @Test + public void testHarrisFormula() { + // Check R = det(J) - k·trace(J)ⁿ against the structure tensor. + final var rnd2 = randomImage(new Random(7), 16, 17); + final var t2 = ops.op("filter.structureTensor").input(rnd2, 1.0, 1.5) + .outType(OUT).apply(); + final var r2 = ops.op("filter.harris").input(rnd2, 1.0, 1.5, 0.06) + .outType(OUT).apply(); + final var j = at(t2, 8, 9); + final var tr2 = j[0] + j[2]; + assertEquals(j[0] * j[2] - j[1] * j[1] - 0.06 * tr2 * tr2, at(r2, 8, + 9)[0], 1e-9); + + final var rnd3 = randomImage(new Random(8), 10, 11, 12); + final var t3 = ops.op("filter.structureTensor").input(rnd3, 1.0, 1.5) + .outType(OUT).apply(); + final var r3 = ops.op("filter.harris").input(rnd3, 1.0, 1.5).outType(OUT) + .apply(); + final var m = at(t3, 4, 5, 6); + final double xx = m[0], xy = m[1], xz = m[2], yy = m[3], yz = m[4], + zz = m[5]; + final var det = xx * (yy * zz - yz * yz) - xy * (xy * zz - yz * xz) + xz * + (xy * yz - yy * xz); + final var tr3 = xx + yy + zz; + assertEquals(det - 0.04 * tr3 * tr3 * tr3, at(r3, 4, 5, 6)[0], 1e-9 * Math + .abs(tr3 * tr3 * tr3)); + } + + // -- Helper methods -- + + private void assertShapeIndex(final double expected, + final ToDoubleFunction f) + { + final var img = image(f, 21, 21); + final var si = ops.op("filter.shapeIndex").input(img, 1.0).outType(OUT) + .apply(); + assertEquals(expected, at(si, 10, 10)[0], 1e-6); + } + + /** Returns sheetness at the center and 3 pixels off the plate center. */ + private double[] sheetness(final Img img, final double sigma) { + final var out = ops.op("filter.sheetness").input(img, sigma).outType(OUT) + .apply(); + return new double[] { at(out, 10, 10, 10)[0], at(out, 10, 10, 13)[0] }; + } + + private static double gauss(final double x, final double s) { + return Math.exp(-x * x / (2 * s * s)); + } + + private static Img stripes(final double alpha) { + final var w = 2 * Math.PI / 20; + final var nx = Math.sin(alpha); + final var ny = Math.cos(alpha); + return image(p -> Math.sin(w * (nx * p[0] + ny * p[1])), 64, 64); + } + + /** + * Creates an image whose values are given by a function of the position + * relative to the image center. + */ + private static Img image(final ToDoubleFunction f, + final long... dims) + { + final var img = ArrayImgs.doubles(dims); + final var c = img.localizingCursor(); + final var p = new double[dims.length]; + while (c.hasNext()) { + c.fwd(); + for (var d = 0; d < dims.length; d++) + p[d] = c.getDoublePosition(d) - dims[d] / 2; + c.get().set(f.applyAsDouble(p)); + } + return img; + } + + private static Img randomImage(final Random r, + final long... dims) + { + final var img = ArrayImgs.doubles(dims); + img.forEach(t -> t.set(r.nextDouble())); + return img; + } + + /** Gets all values along the last dimension at the given position. */ + private static double[] at(final RandomAccessibleInterval img, + final long... pos) + { + final var n = img.numDimensions(); + final var ra = img.randomAccess(); + if (pos.length == n) { + ra.setPosition(pos); + return new double[] { ra.get().get() }; + } + final var values = new double[(int) img.dimension(n - 1)]; + for (var d = 0; d < pos.length; d++) + ra.setPosition(pos[d], d); + for (var i = 0; i < values.length; i++) { + ra.setPosition(i, n - 1); + values[i] = ra.get().get(); + } + return values; + } +} diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/filter/tensor/TensorUtilsTest.java b/scijava-ops-image/src/test/java/org/scijava/ops/image/filter/tensor/TensorUtilsTest.java new file mode 100644 index 000000000..ea2942cb2 --- /dev/null +++ b/scijava-ops-image/src/test/java/org/scijava/ops/image/filter/tensor/TensorUtilsTest.java @@ -0,0 +1,70 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.filter.tensor; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.Random; + +import org.junit.jupiter.api.Test; + +/** + * Tests {@link TensorUtils}. + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +public class TensorUtilsTest { + + @Test + public void testSymmetricEigen() { + final var rnd = new Random(7); + for (var n = 1; n <= 4; n++) { + final var a = new double[n][n]; + for (var r = 0; r < n; r++) + for (var s = r; s < n; s++) + a[r][s] = a[s][r] = rnd.nextGaussian(); + final var copy = new double[n][]; + for (var r = 0; r < n; r++) + copy[r] = a[r].clone(); + final var vals = new double[n]; + final var vecs = new double[n][n]; + TensorUtils.symmetricEigen(copy, vals, vecs); + for (var k = 0; k < n; k++) { + for (var r = 0; r < n; r++) { + var av = 0.0; + for (var s = 0; s < n; s++) + av += a[r][s] * vecs[s][k]; + assertEquals(vals[k] * vecs[r][k], av, 1e-10); + } + } + } + } +} diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/filter/tubeness/TubenessTest.java b/scijava-ops-image/src/test/java/org/scijava/ops/image/filter/tubeness/TubenessTest.java index d66706673..70a727b25 100644 --- a/scijava-ops-image/src/test/java/org/scijava/ops/image/filter/tubeness/TubenessTest.java +++ b/scijava-ops-image/src/test/java/org/scijava/ops/image/filter/tubeness/TubenessTest.java @@ -29,13 +29,18 @@ package org.scijava.ops.image.filter.tubeness; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + import org.junit.jupiter.api.Test; import org.scijava.types.Nil; import org.scijava.ops.image.AbstractOpTest; import net.imglib2.img.Img; +import net.imglib2.img.array.ArrayImgs; import net.imglib2.test.ImgLib2Assert; import net.imglib2.type.numeric.integer.UnsignedByteType; import net.imglib2.type.numeric.real.DoubleType; +import net.imglib2.view.Views; /** * Tests {@link DefaultTubeness}. @@ -61,4 +66,76 @@ public void testTubeness() { ImgLib2Assert.assertImageEquals(expected, actual); } + /** + * Tests that calibration is honored: a straight tube along X with a Gaussian + * cross-section of physical standard deviation {@code s = 6} is sampled with + * voxel size 1 x 1 x 2. At the tube axis, the Gaussian-smoothed Hessian has + * two equal eigenvalues {@code -100 s² / (s² + σ²)²}, so the expected + * tubeness is {@code σ² × 100 s² / (s² + σ²)²}, up to the finite difference + * error of the Hessian (about 6% here). + *

+ * Note: VIB's {@code TubenessProcessor} with calibration enabled smooths + * with calibrated sigmas but differentiates in pixel units, which yields + * twice the analytic value here (10.90 instead of 5.54 for σ = 1.5). + */ + @Test + public void testTubenessCalibrated() { + final double s = 6; + final double[] cal = { 1, 1, 2 }; + final int w = 11, h = 61, d = 31; + final var input = ArrayImgs.doubles(w, h, d); + final var ra = input.randomAccess(); + for (int z = 0; z < d; z++) { + for (int y = 0; y < h; y++) { + for (int x = 0; x < w; x++) { + final double dy = (y - 30) * cal[1], dz = (z - 15) * cal[2]; + ra.setPositionAndGet(x, y, z).set(100 * Math.exp(-(dy * dy + dz * + dz) / (2 * s * s))); + } + } + } + for (final double sigma : new double[] { 1.5, 2.5 }) { + final var actual = ArrayImgs.doubles(w, h, d); + ops.op("filter.tubeness").input(input, sigma, cal).output(actual) + .compute(); + final double v = actual.randomAccess().setPositionAndGet(5, 30, 15) + .get(); + final double ss = s * s + sigma * sigma; + final double expected = sigma * sigma * 100 * s * s / (ss * ss); + assertEquals(expected, v, 0.1 * expected); + } + } + + /** + * A crop view with a non-zero minimum must be filtered over its own + * window, giving the same result as a zero-min copy of the crop. + */ + @Test + public void testNonZeroMinInput() { + Img input = openRelativeUnsignedByteImg( + DefaultTubeness.class, "TubesInput.png"); + final long[] min = { 20, 15 }; + final long[] max = { 59, 49 }; + final var crop = Views.interval(input, min, max); + final var copy = ArrayImgs.unsignedBytes(40, 35); + final var cc = copy.cursor(); + for (final var t : Views.flatIterable(crop)) + cc.next().set(t); + + final var expected = ArrayImgs.doubles(40, 35); + ops.op("filter.tubeness").input(copy, 2.0).output(expected).compute(); + final var actual = ArrayImgs.doubles(40, 35); + ops.op("filter.tubeness").input(crop, 2.0).output(actual).compute(); + ImgLib2Assert.assertImageEquals(expected, actual); + } + + /** Only 2D and 3D images are supported. */ + @Test + public void testUnsupportedDimensionality() { + final var input = ArrayImgs.doubles(5, 5, 5, 5); + final var output = ArrayImgs.doubles(5, 5, 5, 5); + assertThrows(IllegalArgumentException.class, () -> ops.op( + "filter.tubeness").input(input, 1.0).output(output).compute()); + } + } diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/filter/tv/DefaultTVDenoiseTest.java b/scijava-ops-image/src/test/java/org/scijava/ops/image/filter/tv/DefaultTVDenoiseTest.java new file mode 100644 index 000000000..5366ab13d --- /dev/null +++ b/scijava-ops-image/src/test/java/org/scijava/ops/image/filter/tv/DefaultTVDenoiseTest.java @@ -0,0 +1,183 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.filter.tv; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Random; + +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.img.array.ArrayImgs; +import net.imglib2.type.numeric.RealType; + +import org.junit.jupiter.api.Test; +import org.scijava.ops.image.AbstractOpTest; + +/** + * Tests {@link DefaultTVDenoise}. + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +public class DefaultTVDenoiseTest extends AbstractOpTest { + + /** + * For a 1D signal of two plateaus of length L and height difference h, the + * ROF minimizer moves each plateau by lambda / L towards the other (as long + * as this is less than h / 2). + */ + @Test + public void testTwoPlateausAnalytic() { + final var in = ArrayImgs.doubles(20); + final var c = in.localizingCursor(); + while (c.hasNext()) + c.next().set(c.getLongPosition(0) < 10 ? 0 : 100); + final var out = ArrayImgs.doubles(20); + ops.op("filter.tvDenoise").input(in, 200.0, 5000).output(out).compute(); + final var oc = out.localizingCursor(); + while (oc.hasNext()) { + final var v = oc.next().get(); + assertEquals(oc.getLongPosition(0) < 10 ? 20 : 80, v, 1e-3); + } + } + + @Test + public void testConstantImageUnchanged() { + final var in = ArrayImgs.floats(6, 5, 4); + in.forEach(t -> t.set(42)); + final var out = ArrayImgs.floats(6, 5, 4); + ops.op("filter.tvDenoise").input(in, 10.0).output(out).compute(); + for (final var t : out) + assertEquals(42, t.get(), 1e-4); + } + + @Test + public void testNoisySquare() { + final var rnd = new Random(5); + final int w = 32, h = 32; + final var clean = ArrayImgs.doubles(w, h); + final var noisy = ArrayImgs.unsignedBytes(w, h); + final var cc = clean.localizingCursor(); + final var nc = noisy.cursor(); + while (cc.hasNext()) { + final var v = cc.next(); + final long x = cc.getLongPosition(0), y = cc.getLongPosition(1); + v.set(x >= 8 && x < 24 && y >= 8 && y < 24 ? 180 : 60); + nc.next().set((int) Math.round(v.get() + 15 * rnd.nextGaussian())); + } + final var out = ArrayImgs.doubles(w, h); + ops.op("filter.tvDenoise").input(noisy, 25.0, 100, 0.2).output(out) + .compute(); + + assertEquals(mean(noisy), mean(out), 1e-9); + assertTrue(tv(out) < tv(noisy) / 3, "TV: " + tv(noisy) + " -> " + tv( + out)); + final var before = rmse(noisy, clean); + final var after = rmse(out, clean); + assertTrue(after < before / 2, "RMSE: " + before + " -> " + after); + // The edge stays sharp: the step across it is at least 70% of its height (TV denoising also reduces contrast). + final var ra = out.randomAccess(); + for (var y = 10; y < 22; y++) { + final var step = ra.setPositionAndGet(8, y).get() - ra.setPositionAndGet( + 7, y).get(); + assertTrue(step > 0.7 * 120, "step at y=" + y + ": " + step); + } + } + + private static double mean( + final RandomAccessibleInterval> img) + { + double sum = 0; + for (final var t : img) + sum += t.getRealDouble(); + return sum / img.size(); + } + + private static double rmse( + final RandomAccessibleInterval> a, + final RandomAccessibleInterval> b) + { + final var ca = a.cursor(); + final var cb = b.cursor(); + double sum = 0; + while (ca.hasNext()) { + final var d = ca.next().getRealDouble() - cb.next().getRealDouble(); + sum += d * d; + } + return Math.sqrt(sum / a.size()); + } + + /** Computes the anisotropic total variation of a 2D image. */ + private static double tv( + final RandomAccessibleInterval> img) + { + final var ra = img.randomAccess(); + double sum = 0; + for (var y = 0; y < img.dimension(1); y++) { + for (var x = 0; x < img.dimension(0); x++) { + final var v = ra.setPositionAndGet(x, y).getRealDouble(); + if (x > 0) sum += Math.abs(v - ra.setPositionAndGet(x - 1, y) + .getRealDouble()); + if (y > 0) sum += Math.abs(v - ra.setPositionAndGet(x, y - 1) + .getRealDouble()); + } + } + return sum; + } + + /** + * An 8-bit output saturates rather than wrapping around: the result must be + * the floating-point result clamped to [0, 255]. + */ + @Test + public void testUnsignedByteOutputIsClamped() { + final var in = ArrayImgs.floats(16, 16); + final var rnd = new Random(3); + final var c = in.localizingCursor(); + while (c.hasNext()) { + c.fwd(); + c.get().setReal((c.getIntPosition(0) < 8 ? 300 : -20) + rnd + .nextDouble()); + } + final var expected = ArrayImgs.floats(16, 16); + ops.op("filter.tvDenoise").input(in, 10.0).output(expected).compute(); + final var actual = ArrayImgs.unsignedBytes(16, 16); + ops.op("filter.tvDenoise").input(in, 10.0).output(actual).compute(); + final var ec = expected.cursor(); + var saturated = 0; + for (final var t : actual) { + final var e = Math.max(0, Math.min(255, ec.next().get())); + assertEquals(e, t.get(), 0.5 + 1e-4); + if (t.get() == 0 || t.get() == 255) saturated++; + } + assertTrue(saturated > 100, "saturated pixels: " + saturated); + } +} diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/filter/wavelet/WaveletTest.java b/scijava-ops-image/src/test/java/org/scijava/ops/image/filter/wavelet/WaveletTest.java new file mode 100644 index 000000000..40d245ba3 --- /dev/null +++ b/scijava-ops-image/src/test/java/org/scijava/ops/image/filter/wavelet/WaveletTest.java @@ -0,0 +1,203 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.filter.wavelet; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.Random; + +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.img.array.ArrayImgs; +import net.imglib2.type.numeric.RealType; +import net.imglib2.type.numeric.real.FloatType; + +import org.junit.jupiter.api.Test; +import org.scijava.ops.image.AbstractOpTest; +import org.scijava.types.Nil; + +/** + * Tests {@link WaveletATrous} and {@link WaveletDenoise}. + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +public class WaveletTest extends AbstractOpTest { + + private static final Nil>> PLANES = + new Nil<>() + {}; + + private static final Nil> FLOATS = + new Nil<>() + {}; + + @Test + public void testReconstruction() { + final var rnd = new Random(1); + final var in = ArrayImgs.unsignedShorts(23, 17, 5); + in.forEach(t -> t.set(rnd.nextInt(4000))); + final var planes = ops.op("filter.waveletATrous").input(in, 4).outType( + PLANES).apply(); + assertEquals(5, planes.size()); + final var sum = ArrayImgs.doubles(23, 17, 5); + for (final var p : planes) { + final var c = sum.cursor(); + final var pc = p.cursor(); + while (c.hasNext()) + c.next().set(c.get().get() + pc.next().get()); + } + final var c = sum.cursor(); + final var ic = in.cursor(); + while (c.hasNext()) + assertEquals(ic.next().get(), c.next().get(), 1e-2); + } + + /** Checks the first smoothing step against the explicit 1D filter. */ + @Test + public void testFirstScale1D() { + final var in = ArrayImgs.floats(new float[] { 0, 0, 0, 16, 0, 0, 0, 0 }, + 8); + final var planes = ops.op("filter.waveletATrous").input(in, 2).outType( + PLANES).apply(); + final float[] w1 = { 0, -1, -4, 10, -4, -1, 0, 0 }; + var i = 0; + for (final var t : planes.get(0)) + assertEquals(w1[i++], t.get(), 1e-6); + // Second scale: c1 convolved with taps 2 apart, mirrored at the left + // border (c1 = 0, 1, 4, 6, 4, 1, 0, 0). + final float[] c1 = { 0, 1, 4, 6, 4, 1, 0, 0 }; + i = 0; + for (final var t : planes.get(1)) { + final var c2 = (tap(c1, i - 4) + 4 * tap(c1, i - 2) + 6 * c1[i] + 4 * + tap(c1, i + 2) + tap(c1, i + 4)) / 16; + assertEquals(c1[i] - c2, t.get(), 1e-6); + i++; + } + } + + @Test + public void testConstantImage() { + final var in = ArrayImgs.floats(10, 12); + in.forEach(t -> t.set(7)); + final var planes = ops.op("filter.waveletATrous").input(in, 3).outType( + PLANES).apply(); + for (var j = 0; j < 3; j++) + for (final var t : planes.get(j)) + assertEquals(0, t.get(), 1e-5); + for (final var t : planes.get(3)) + assertEquals(7, t.get(), 1e-5); + } + + /** + * The noise levels of the wavelet planes of white noise match the + * published values for the 2D B3-spline starlet (Starck and Murtagh), which + * were estimated by simulation. + */ + @Test + public void testNoiseLevels() { + final var levels = Starlet.noiseLevels(5, 2); + final double[] expected = { 0.889, 0.200, 0.086, 0.041, 0.020 }; + for (var j = 0; j < 5; j++) + assertEquals(expected[j], levels[j], 0.002); + final var rnd = new Random(7); + final var noise = ArrayImgs.floats(128, 128); + noise.forEach(t -> t.set((float) rnd.nextGaussian())); + final var planes = ops.op("filter.waveletATrous").input(noise, 2).outType( + PLANES).apply(); + assertEquals(levels[0], std(planes.get(0)), 0.02); + assertEquals(levels[1], std(planes.get(1)), 0.02); + } + + @Test + public void testDenoise() { + final var rnd = new Random(11); + final int w = 64, h = 64; + final var clean = ArrayImgs.doubles(w, h); + final var noisy = ArrayImgs.floats(w, h); + final var cc = clean.localizingCursor(); + final var nc = noisy.cursor(); + while (cc.hasNext()) { + final var v = cc.next(); + final double dx = cc.getDoublePosition(0) - 32, dy = cc + .getDoublePosition(1) - 32; + v.set(100 + 200 * Math.exp(-(dx * dx + dy * dy) / (2 * 4 * 4))); + nc.next().set((float) (v.get() + 20 * rnd.nextGaussian())); + } + final var out = ops.op("filter.waveletDenoise").input(noisy).outType( + FLOATS).apply(); + final var before = rmse(noisy, clean); + final var after = rmse(out, clean); + assertTrue(after < before / 3, "RMSE: " + before + " -> " + after); + final var out2 = ops.op("filter.waveletDenoise").input(noisy, 3, 2.0) + .outType(FLOATS).apply(); + assertTrue(rmse(out2, clean) < before / 2); + } + + private static float tap(final float[] a, final int i) { + final var n = a.length; + final var m = i < 0 ? -i : i >= n ? 2 * n - 2 - i : i; + return a[m]; + } + + private static double mean( + final RandomAccessibleInterval> img) + { + double sum = 0; + for (final var t : img) + sum += t.getRealDouble(); + return sum / img.size(); + } + + private static double std( + final RandomAccessibleInterval> img) + { + final var m = mean(img); + double sum = 0; + for (final var t : img) + sum += (t.getRealDouble() - m) * (t.getRealDouble() - m); + return Math.sqrt(sum / img.size()); + } + + private static double rmse( + final RandomAccessibleInterval> a, + final RandomAccessibleInterval> b) + { + final var ca = a.cursor(); + final var cb = b.cursor(); + double sum = 0; + while (ca.hasNext()) { + final var d = ca.next().getRealDouble() - cb.next().getRealDouble(); + sum += d * d; + } + return Math.sqrt(sum / a.size()); + } +} diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/filter/weightedMedian/GaussianWeightedMedianTest.java b/scijava-ops-image/src/test/java/org/scijava/ops/image/filter/weightedMedian/GaussianWeightedMedianTest.java new file mode 100644 index 000000000..7f84ed470 --- /dev/null +++ b/scijava-ops-image/src/test/java/org/scijava/ops/image/filter/weightedMedian/GaussianWeightedMedianTest.java @@ -0,0 +1,99 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.filter.weightedMedian; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.Random; + +import net.imglib2.img.array.ArrayImgs; +import net.imglib2.loops.LoopBuilder; + +import org.junit.jupiter.api.Test; +import org.scijava.ops.image.AbstractOpTest; + +/** + * Tests {@link GaussianWeightedMedian}. + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +public class GaussianWeightedMedianTest extends AbstractOpTest { + + @Test + public void testKernel() { + final var kernel = GaussianWeightedMedian.kernel(2, 1.0, 2); + final var ra = kernel.randomAccess(); + assertEquals(1, ra.setPositionAndGet(2, 2).get(), 1e-12); + assertEquals(Math.exp(-0.5), ra.setPositionAndGet(3, 2).get(), 1e-12); + assertEquals(Math.exp(-1), ra.setPositionAndGet(1, 1).get(), 1e-12); + assertEquals(Math.exp(-2), ra.setPositionAndGet(2, 0).get(), 1e-12); + // The corners lie outside the circle of radius 2. + assertEquals(0, ra.setPositionAndGet(0, 0).get(), 0); + assertEquals(0, ra.setPositionAndGet(4, 3).get(), 0); + } + + @Test + public void testSpikeRemovedAndEdgePreserved() { + // A vertical step edge with a single-pixel spike: the spike carries less + // than half of the total weight, so it is removed, while the step edge + // (a median root) is preserved exactly. + final var in = ArrayImgs.unsignedShorts(20, 16); + final var c = in.localizingCursor(); + while (c.hasNext()) { + c.fwd(); + c.get().set(c.getLongPosition(0) < 10 ? 100 : 500); + } + in.randomAccess().setPositionAndGet(4, 8).set(4000); + final var out = ArrayImgs.unsignedShorts(20, 16); + ops.op("filter.gaussianWeightedMedian").input(in, 1.0).output(out) + .compute(); + final var oc = out.localizingCursor(); + while (oc.hasNext()) { + oc.fwd(); + assertEquals(oc.getLongPosition(0) < 10 ? 100 : 500, oc.get().get()); + } + } + + @Test + public void testMatchesWeightedMedian() { + final var random = new Random(3); + final var in = ArrayImgs.floats(12, 11, 5); + in.forEach(t -> t.set(random.nextFloat())); + final var out = ArrayImgs.floats(12, 11, 5); + ops.op("filter.gaussianWeightedMedian").input(in, 1.5, 2).output(out) + .compute(); + final var expected = ArrayImgs.floats(12, 11, 5); + ops.op("filter.weightedMedian").input(in, GaussianWeightedMedian.kernel(3, + 1.5, 2)).output(expected).compute(); + LoopBuilder.setImages(expected, out).forEachPixel((e, o) -> assertEquals(e + .get(), o.get(), 0)); + } +} diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/filter/weightedMedian/WeightedMedianTest.java b/scijava-ops-image/src/test/java/org/scijava/ops/image/filter/weightedMedian/WeightedMedianTest.java new file mode 100644 index 000000000..6e1ff8e26 --- /dev/null +++ b/scijava-ops-image/src/test/java/org/scijava/ops/image/filter/weightedMedian/WeightedMedianTest.java @@ -0,0 +1,145 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.filter.weightedMedian; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.Random; + +import net.imglib2.algorithm.neighborhood.RectangleShape; +import net.imglib2.img.array.ArrayImgs; +import net.imglib2.loops.LoopBuilder; +import net.imglib2.outofbounds.OutOfBoundsBorderFactory; +import net.imglib2.view.Views; + +import org.junit.jupiter.api.Test; +import org.scijava.ops.image.AbstractOpTest; + +/** + * Tests {@link DefaultWeightedMedian}. + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +public class WeightedMedianTest extends AbstractOpTest { + + @Test + public void testUniformKernelIsMedian() { + final var random = new Random(42); + final var in = ArrayImgs.floats(17, 13, 4); + in.forEach(t -> t.set(random.nextInt(1000))); + final var kernel = ArrayImgs.doubles(3, 3, 3); + kernel.forEach(t -> t.set(2.5)); + + final var out = ArrayImgs.floats(17, 13, 4); + ops.op("filter.weightedMedian").input(in, kernel).output(out).compute(); + final var expected = ArrayImgs.floats(17, 13, 4); + ops.op("filter.median").input(in, new RectangleShape(1, false), + new OutOfBoundsBorderFactory<>()).output(expected).compute(); + LoopBuilder.setImages(expected, out).forEachPixel((e, o) -> assertEquals(e + .get(), o.get(), 0)); + } + + @Test + public void testHandComputed() { + final var in = ArrayImgs.doubles(new double[] { 1, 10, 3, 2, 11 }, 5); + final var kernel = ArrayImgs.doubles(new double[] { 1, 1, 3, 1, 1 }, 5); + final var out = ArrayImgs.doubles(5); + ops.op("filter.weightedMedian").input(in, kernel).output(out).compute(); + // At x=2 the neighborhood is (1, 10, 3, 2, 11) with weights + // (1, 1, 3, 1, 1); sorted, the cumulative weights are 1 (1), 2 (2), + // 5 (3), 6 (10) and 7 (11), so the weighted median is 3. + assertEquals(3, out.randomAccess().setPositionAndGet(2).get()); + // At x=0 the border-extended neighborhood is (1, 1, 1, 10, 3), so the + // median is 1. + assertEquals(1, out.randomAccess().setPositionAndGet(0).get()); + // At x=4 the neighborhood is (3, 2, 11, 11, 11), so the median is 11. + assertEquals(11, out.randomAccess().setPositionAndGet(4).get()); + } + + @Test + public void testDominantCenterIsIdentity() { + final var random = new Random(7); + final var in = ArrayImgs.unsignedBytes(9, 8); + in.forEach(t -> t.set(random.nextInt(256))); + final var kernel = ArrayImgs.doubles(new double[] { 1, 1, 1, 1, 9, 1, 1, 1, + 1 }, 3, 3); + final var out = ArrayImgs.unsignedBytes(9, 8); + ops.op("filter.weightedMedian").input(in, kernel).output(out).compute(); + LoopBuilder.setImages(in, out).forEachPixel((i, o) -> assertEquals(i.get(), + o.get())); + } + + @Test + public void testEvenKernelThrows() { + final var in = ArrayImgs.doubles(5, 5); + final var kernel = ArrayImgs.doubles(2, 3); + kernel.forEach(t -> t.set(1)); + final var out = ArrayImgs.doubles(5, 5); + assertThrows(IllegalArgumentException.class, () -> ops.op( + "filter.weightedMedian").input(in, kernel).output(out).compute()); + } + + /** + * The input and output may have different minima: each output pixel is + * computed from the input pixel at the same offset from the input's + * minimum, as for {@code filter.kuwahara}. + */ + @Test + public void testNonZeroMinInput() { + final var random = new Random(7); + final var img = ArrayImgs.floats(30, 30); + img.forEach(t -> t.set(random.nextInt(1000))); + final var kernel = ArrayImgs.doubles(new double[] { 1, 2, 1, 2, 4, 2, 1, + 2, 1 }, 3, 3); + final var crop = Views.interval(img, new long[] { 10, 10 }, new long[] { + 19, 19 }); + + // Reference: the same crop, copied into a zero-min image. + final var cropCopy = ArrayImgs.floats(10, 10); + LoopBuilder.setImages(crop, cropCopy).forEachPixel((c, o) -> o.set(c)); + final var expected = ArrayImgs.floats(10, 10); + ops.op("filter.weightedMedian").input(cropCopy, kernel).output(expected) + .compute(); + + final var zeroMinOut = ArrayImgs.floats(10, 10); + ops.op("filter.weightedMedian").input(crop, kernel).output(zeroMinOut) + .compute(); + final var shiftedOut = Views.translate(ArrayImgs.floats(10, 10), -3, 5); + ops.op("filter.weightedMedian").input(crop, kernel).output(shiftedOut) + .compute(); + LoopBuilder.setImages(expected, zeroMinOut, shiftedOut).forEachPixel((e, + a, b) -> { + assertEquals(e.get(), a.get(), 0); + assertEquals(e.get(), b.get(), 0); + }); + } +} diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/geom/compactness/CompactnessTest.java b/scijava-ops-image/src/test/java/org/scijava/ops/image/geom/compactness/CompactnessTest.java new file mode 100644 index 000000000..a8714bf7c --- /dev/null +++ b/scijava-ops-image/src/test/java/org/scijava/ops/image/geom/compactness/CompactnessTest.java @@ -0,0 +1,88 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.geom.compactness; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import net.imglib2.roi.geom.real.DefaultWritablePolygon2D; +import net.imglib2.roi.geom.real.Polygon2D; +import net.imglib2.type.numeric.real.DoubleType; + +import org.junit.jupiter.api.Test; +import org.scijava.ops.image.AbstractOpTest; + +/** + * Tests {@link DefaultCompactness}. + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +public class CompactnessTest extends AbstractOpTest { + + private double feature(final String name, final Polygon2D p) { + return ops.op(name).input(p).outType(DoubleType.class).apply().get(); + } + + private static Polygon2D ellipse(final double a, final double b) { + final var n = 360; + final var x = new double[n]; + final var y = new double[n]; + for (var i = 0; i < n; i++) { + x[i] = 50 + a * Math.cos(2 * Math.PI * i / n); + y[i] = 50 + b * Math.sin(2 * Math.PI * i / n); + } + return new DefaultWritablePolygon2D(x, y); + } + + @Test + public void testCircle() { + assertEquals(1, feature("geom.compactness", ellipse(20, 20)), 1e-3); + } + + @Test + public void testEllipse() { + // The more elongated the ellipse, the lower its compactness. + final var c2 = feature("geom.compactness", ellipse(20, 10)); + final var c4 = feature("geom.compactness", ellipse(40, 10)); + assertTrue(c2 < 1); + assertTrue(c4 < c2); + } + + @Test + public void testSquareRootOfRoundness() { + final var shape = new DefaultWritablePolygon2D(new double[] { 0, 30, 30, + 10, 0 }, new double[] { 0, 0, 8, 20, 12 }); + final var compactness = feature("geom.compactness", shape); + assertTrue(compactness < 1); + assertEquals(Math.sqrt(feature("geom.roundness", shape)), compactness, + 1e-12); + } +} diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/geom/extent/ExtentsTest.java b/scijava-ops-image/src/test/java/org/scijava/ops/image/geom/extent/ExtentsTest.java new file mode 100644 index 000000000..68deed2d0 --- /dev/null +++ b/scijava-ops-image/src/test/java/org/scijava/ops/image/geom/extent/ExtentsTest.java @@ -0,0 +1,87 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.geom.extent; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.Arrays; + +import net.imglib2.img.array.ArrayImgs; +import net.imglib2.roi.geom.real.DefaultWritablePolygon2D; +import net.imglib2.roi.labeling.ImgLabeling; +import net.imglib2.roi.labeling.LabelRegions; +import net.imglib2.type.numeric.real.DoubleType; + +import org.junit.jupiter.api.Test; +import org.scijava.ops.image.AbstractOpTest; + +/** + * Tests {@link Extents}. + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +public class ExtentsTest extends AbstractOpTest { + + private double extent(final Object shape) { + return ops.op("geom.extent").input(shape).outType(DoubleType.class).apply() + .get(); + } + + @Test + public void testPolygons() { + final var rectangle = new DefaultWritablePolygon2D(new double[] { 1, 11, 11, + 1 }, new double[] { 2, 2, 6, 6 }); + assertEquals(1, extent(rectangle), 1e-12); + final var triangle = new DefaultWritablePolygon2D(new double[] { 0, 10, + 0 }, new double[] { 0, 0, 6 }); + assertEquals(0.5, extent(triangle), 1e-12); + final var diamond = new DefaultWritablePolygon2D(new double[] { 5, 10, 5, + 0 }, new double[] { 0, 5, 10, 5 }); + assertEquals(0.5, extent(diamond), 1e-12); + } + + @Test + public void testRegion3D() { + // An L-shaped region of 3 voxels in a 2 x 2 x 1 bounding box, and a + // single voxel. + final var index = ArrayImgs.ints(5, 5, 5); + final var ra = index.randomAccess(); + ra.setPositionAndGet(1, 1, 1).set(1); + ra.setPositionAndGet(2, 1, 1).set(1); + ra.setPositionAndGet(1, 2, 1).set(1); + ra.setPositionAndGet(3, 3, 3).set(2); + final var labeling = ImgLabeling.fromImageAndLabels(index, Arrays.asList( + 1, 2)); + final var regions = new LabelRegions<>(labeling); + assertEquals(0.75, extent(regions.getLabelRegion(1)), 1e-12); + assertEquals(1, extent(regions.getLabelRegion(2)), 1e-12); + } +} diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/geom/feretAspectRatio/FeretAspectRatioTest.java b/scijava-ops-image/src/test/java/org/scijava/ops/image/geom/feretAspectRatio/FeretAspectRatioTest.java new file mode 100644 index 000000000..8389c01a0 --- /dev/null +++ b/scijava-ops-image/src/test/java/org/scijava/ops/image/geom/feretAspectRatio/FeretAspectRatioTest.java @@ -0,0 +1,74 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.geom.feretAspectRatio; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import net.imglib2.roi.geom.real.DefaultWritablePolygon2D; +import net.imglib2.roi.geom.real.Polygon2D; +import net.imglib2.type.numeric.real.DoubleType; + +import org.junit.jupiter.api.Test; +import org.scijava.ops.image.AbstractOpTest; + +/** + * Tests {@link DefaultFeretAspectRatio}. + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +public class FeretAspectRatioTest extends AbstractOpTest { + + private double ratio(final Polygon2D p) { + return ops.op("geom.feretAspectRatio").input(p).outType(DoubleType.class) + .apply().get(); + } + + @Test + public void testRectangle() { + // The maximum Feret diameter is the diagonal, the minimum the width. + final var rectangle = new DefaultWritablePolygon2D(new double[] { 0, 10, 10, + 0 }, new double[] { 0, 0, 4, 4 }); + assertEquals(Math.sqrt(116) / 4, ratio(rectangle), 1e-9); + } + + @Test + public void testRegularPolygon() { + // A regular polygon with many vertices is nearly a circle. + final var n = 720; + final var x = new double[n]; + final var y = new double[n]; + for (var i = 0; i < n; i++) { + x[i] = 30 * Math.cos(2 * Math.PI * i / n); + y[i] = 30 * Math.sin(2 * Math.PI * i / n); + } + assertEquals(1, ratio(new DefaultWritablePolygon2D(x, y)), 1e-3); + } +} diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/geom/geom2d/PolygonFeatureTests.java b/scijava-ops-image/src/test/java/org/scijava/ops/image/geom/geom2d/PolygonFeatureTests.java index d6de3b692..e492f66f6 100644 --- a/scijava-ops-image/src/test/java/org/scijava/ops/image/geom/geom2d/PolygonFeatureTests.java +++ b/scijava-ops-image/src/test/java/org/scijava/ops/image/geom/geom2d/PolygonFeatureTests.java @@ -37,6 +37,7 @@ import org.scijava.ops.image.geom.GeomUtils; import net.imglib2.RealLocalizable; import net.imglib2.RealPoint; +import net.imglib2.roi.geom.real.DefaultWritablePolygon2D; import net.imglib2.roi.geom.real.Polygon2D; import net.imglib2.roi.labeling.LabelRegion; import net.imglib2.type.numeric.real.DoubleType; @@ -191,6 +192,40 @@ public void testMajorAxis() { .outType(DoubleType.class).apply().get(), EPSILON, "geom.majorAxis"); } + /** + * Tests the moment-equivalent ellipse axes on polygons approximating an + * ellipse with semi-axes 40 and 10, for which the major and minor axis + * lengths must be 80 and 20, whatever the orientation and vertex order. + * ImageJ 1.x Fit Ellipse on the rasterized polygons gives 79.7 to 80.1 and + * 20.0 to 20.1, depending on the orientation. + */ + @Test + public void testEllipseAxes() { + final int n = 360; + for (final double angle : new double[] { 0, 30, 90, 135 }) { + for (final boolean ccw : new boolean[] { true, false }) { + final double t = Math.toRadians(angle); + final double[] x = new double[n], y = new double[n]; + for (int i = 0; i < n; i++) { + final double phi = (ccw ? 1 : -1) * 2 * Math.PI * i / n; + final double ex = 40 * Math.cos(phi), ey = 10 * Math.sin(phi); + x[i] = 100 + ex * Math.cos(t) - ey * Math.sin(t); + y[i] = 50 + ex * Math.sin(t) + ey * Math.cos(t); + } + final Polygon2D ellipse = new DefaultWritablePolygon2D(x, y); + final double major = ops.op("geom.majorAxis").input(ellipse).outType( + DoubleType.class).apply().get(); + final double minor = ops.op("geom.minorAxis").input(ellipse).outType( + DoubleType.class).apply().get(); + // Note: the 360-gon is slightly smaller than the true ellipse. + assertEquals(80, major, 0.01, "major axis at " + angle + "°, ccw=" + + ccw); + assertEquals(20, minor, 0.01, "minor axis at " + angle + "°, ccw=" + + ccw); + } + } + } + @Test public void testMaximumFeretsAngle() { // ground truth computed with matlab diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/geom/geom3d/InertiaTensor3DTest.java b/scijava-ops-image/src/test/java/org/scijava/ops/image/geom/geom3d/InertiaTensor3DTest.java new file mode 100644 index 000000000..4fca79bd6 --- /dev/null +++ b/scijava-ops-image/src/test/java/org/scijava/ops/image/geom/geom3d/InertiaTensor3DTest.java @@ -0,0 +1,82 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.geom.geom3d; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import net.imglib2.img.array.ArrayImgs; +import net.imglib2.roi.labeling.ImgLabeling; +import net.imglib2.roi.labeling.LabelRegions; +import net.imglib2.type.numeric.integer.IntType; + +import org.apache.commons.math3.linear.RealMatrix; +import org.junit.jupiter.api.Test; +import org.scijava.ops.image.AbstractOpTest; + +/** + * Tests {@link DefaultInertiaTensor3D}. + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +public class InertiaTensor3DTest extends AbstractOpTest { + + /** + * Checks the second moments of a box whose centroid has distinct + * coordinates, against the analytic values for a discrete uniform box: the + * variance along an axis of length n is (n^2 - 1) / 12, and the cross + * moments vanish by symmetry. + */ + @Test + public void testOffCenterBox() { + final var img = ArrayImgs.ints(20, 20, 20); + final ImgLabeling labeling = new ImgLabeling<>(img); + final var ra = labeling.randomAccess(); + final int[] min = { 2, 7, 11 }; + final int[] len = { 3, 5, 7 }; + for (int z = min[2]; z < min[2] + len[2]; z++) { + for (int y = min[1]; y < min[1] + len[1]; y++) { + for (int x = min[0]; x < min[0] + len[0]; x++) { + ra.setPositionAndGet(x, y, z).add(1); + } + } + } + final var region = new LabelRegions<>(labeling).getLabelRegion(1); + final var tensor = ops.op("geom.secondMoment").input(region).outType( + RealMatrix.class).apply(); + for (int i = 0; i < 3; i++) { + for (int j = 0; j < 3; j++) { + final double expected = i == j ? (len[i] * len[i] - 1) / 12.0 : 0; + assertEquals(expected, tensor.getEntry(i, j), 1e-10, "entry (" + i + + ", " + j + ")"); + } + } + } +} diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/image/bleach/BleachCorrectTest.java b/scijava-ops-image/src/test/java/org/scijava/ops/image/image/bleach/BleachCorrectTest.java new file mode 100644 index 000000000..223572771 --- /dev/null +++ b/scijava-ops-image/src/test/java/org/scijava/ops/image/image/bleach/BleachCorrectTest.java @@ -0,0 +1,199 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.image.bleach; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.Random; + +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.img.array.ArrayImgs; +import net.imglib2.type.numeric.RealType; +import net.imglib2.type.numeric.real.DoubleType; +import net.imglib2.view.Views; + +import org.junit.jupiter.api.Test; +import org.scijava.ops.image.AbstractOpTest; + +/** + * Tests {@link BleachCorrect}, {@link BleachFit} and {@link HistogramMatch}. + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +public class BleachCorrectTest extends AbstractOpTest { + + private static final double A = 80, B = 0.15, C = 20; + + /** A textured 2D+t series (time in dimension 1) decaying as A e^-Bt + C. */ + private static RandomAccessibleInterval decaying( + final boolean noisy) + { + final var rnd = new Random(3); + final var img = ArrayImgs.doubles(16, 20, 12); + final var c = img.localizingCursor(); + while (c.hasNext()) { + c.fwd(); + final var t = c.getIntPosition(1); + final var texture = 1 + 0.5 * Math.sin(c.getIntPosition(0) + 2 * c + .getIntPosition(2)); + final var f = A * Math.exp(-B * t) + C; + c.get().set(texture * f * (noisy ? 1 + 0.02 * rnd.nextGaussian() : 1)); + } + return img; + } + + @Test + public void testFit() { + final var p = ops.op("image.bleachFit").input(decaying(false), 1).outType( + double[].class).apply(); + // The mean of the texture is not exactly 1, so only b and a / c are known. + assertEquals(B, p[1], 1e-6); + assertEquals(A / C, p[0] / p[2], 1e-6); + } + + @Test + public void testRatio() { + final var in = decaying(true); + final var out = ArrayImgs.doubles(16, 20, 12); + ops.op("image.bleachCorrect").input(in, 1, "ratio").output(out).compute(); + final var means = Bleach.frameMeans(out, 1); + final var in0 = Bleach.frameMeans(in, 1)[0]; + for (final var m : means) + assertEquals(in0, m, 1e-9); + // The default method is ratio. + final var out2 = ArrayImgs.doubles(16, 20, 12); + ops.op("image.bleachCorrect").input(in, 1).output(out2).compute(); + assertImagesEqual(out, out2, 0); + } + + @Test + public void testExponential() { + final var in = decaying(true); + final var out = ArrayImgs.doubles(16, 20, 12); + ops.op("image.bleachCorrect").input(in, 1, "exponential").output(out) + .compute(); + final var means = Bleach.frameMeans(out, 1); + for (final var m : means) + assertEquals(means[0], m, 0.01 * means[0]); + // Without noise, the corrected image is exactly the first frame. + final var clean = decaying(false); + ops.op("image.bleachCorrect").input(clean, 1, "exponential").output(out) + .compute(); + for (var t = 0; t < 20; t++) + assertImagesEqual(Views.hyperSlice(clean, 1, 0), Views.hyperSlice(out, 1, + t), 1e-6); + } + + @Test + public void testClampsToOutputRange() { + final var in = ArrayImgs.unsignedBytes(new byte[] { (byte) 200, 100, + (byte) 200, 0 }, 2, 2); + final var out = ArrayImgs.unsignedBytes(2, 2); + ops.op("image.bleachCorrect").input(in, 1, "ratio").output(out).compute(); + final var ra = out.randomAccess(); + assertEquals(255, ra.setPositionAndGet(0, 1).get()); + assertEquals(0, ra.setPositionAndGet(1, 1).get()); + assertEquals(100, ra.setPositionAndGet(1, 0).get()); + } + + @Test + public void testHistogramMatchIdentity() { + final var rnd = new Random(9); + final var in = ArrayImgs.unsignedBytes(13, 11); + in.forEach(t -> t.set(rnd.nextInt(40))); + final var out = ArrayImgs.unsignedBytes(13, 11); + ops.op("image.histogramMatch").input(in, in).output(out).compute(); + assertImagesEqual(in, out, 0); + } + + @Test + public void testHistogramMatchMonotoneMap() { + // The reference is a monotone transform of the input, so matching + // recovers the transform exactly. + final var rnd = new Random(10); + final var in = ArrayImgs.unsignedBytes(20, 10); + in.forEach(t -> t.set(rnd.nextInt(256))); + final var expected = ArrayImgs.doubles(20, 10); + final var c = in.cursor(); + final var e = expected.cursor(); + while (c.hasNext()) + e.next().set(Math.sqrt(c.next().get()) * 3 + 1); + // Same distribution as expected, different size: every value twice and + // in another order. + final var big = ArrayImgs.doubles(20, 10, 2); + final var bc = big.cursor(); + for (var k = 0; k < 2; k++) + for (final var v : Views.flatIterable(Views.invertAxis(expected, 0))) + bc.next().set(v.get()); + final var out = ArrayImgs.doubles(20, 10); + ops.op("image.histogramMatch").input(in, big).output(out).compute(); + assertImagesEqual(expected, out, 1e-12); + } + + @Test + public void testHistogramFrames() { + final var in = decaying(true); + final var out = ArrayImgs.doubles(16, 20, 12); + ops.op("image.histogramMatch").input(in, 1, 0).output(out).compute(); + final var out2 = ArrayImgs.doubles(16, 20, 12); + ops.op("image.bleachCorrect").input(in, 1, "histogram").output(out2) + .compute(); + assertImagesEqual(out, out2, 0); + // The noise makes the sort order of each frame differ from frame 0, but + // every frame now has exactly the values of frame 0. + final var ref = sortedValues(Views.hyperSlice(in, 1, 0)); + for (var t = 0; t < 20; t++) + assertArrayEquals(ref, sortedValues(Views.hyperSlice(out, 1, t)), 0); + } + + private static double[] sortedValues( + final RandomAccessibleInterval> img) + { + final var v = new double[(int) img.size()]; + var i = 0; + for (final var t : img) + v[i++] = t.getRealDouble(); + java.util.Arrays.sort(v); + return v; + } + + private static void assertImagesEqual( + final RandomAccessibleInterval> expected, + final RandomAccessibleInterval> actual, + final double tol) + { + final var e = Views.flatIterable(expected).cursor(); + final var a = Views.flatIterable(actual).cursor(); + while (e.hasNext()) + assertEquals(e.next().getRealDouble(), a.next().getRealDouble(), tol); + } +} diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/image/distancetransform/DistanceTransformNonZeroMinTest.java b/scijava-ops-image/src/test/java/org/scijava/ops/image/image/distancetransform/DistanceTransformNonZeroMinTest.java new file mode 100644 index 000000000..2717a01a4 --- /dev/null +++ b/scijava-ops-image/src/test/java/org/scijava/ops/image/image/distancetransform/DistanceTransformNonZeroMinTest.java @@ -0,0 +1,134 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.image.distancetransform; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.Arrays; + +import net.imglib2.Cursor; +import net.imglib2.RandomAccess; +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.img.array.ArrayImgs; +import net.imglib2.loops.LoopBuilder; +import net.imglib2.type.logic.BitType; +import net.imglib2.type.numeric.real.FloatType; +import net.imglib2.view.Views; + +import org.junit.jupiter.api.Test; +import org.scijava.ops.image.AbstractOpTest; +import org.scijava.util.MersenneTwisterFast; + +/** + * Tests {@code image.distanceTransform} on inputs whose min is not zero, for + * the 2D, 3D and n-D implementations, with and without calibration. + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +public class DistanceTransformNonZeroMinTest extends AbstractOpTest { + + private static final double EPSILON = 1e-4; + + @Test + public void test2D() { + check(new long[] { 30, 28 }, new double[] { 2.54, 1.77 }); + } + + @Test + public void test3D() { + check(new long[] { 22, 20, 9 }, new double[] { 1.3, 0.7, 2.1 }); + } + + @Test + public void testND() { + check(new long[] { 14, 12, 7, 5 }, new double[] { 1.1, 0.9, 1.7, 2.3 }); + } + + private void check(final long[] dims, final double[] calibration) { + final var n = dims.length; + final var img = ArrayImgs.bits(dims); + final var random = new MersenneTwisterFast(0x12345678); + // Note: Mostly foreground, so that distances span several pixels. + img.forEach(b -> b.set(random.nextDouble() < 0.85)); + + // A sub-interval of a larger image: the DT must only see the sub-interval. + final var min = new long[n]; + final var max = new long[n]; + for (var d = 0; d < n; d++) { + min[d] = 1 + d % 2; + max[d] = dims[d] - 2; + } + checkView(Views.interval(img, min, max), calibration); + + // A translated image: its positions lie entirely outside [0, dims). + final var offset = new long[n]; + for (var d = 0; d < n; d++) + offset[d] = d % 2 == 0 ? 100 + d : -50 - d; + checkView(Views.translate(img, offset), calibration); + } + + private void checkView(final RandomAccessibleInterval view, + final double[] calibration) + { + // Expected: the DT of a zero-min copy of the view. + final var copy = ArrayImgs.bits(view.dimensionsAsLongArray()); + LoopBuilder.setImages(view, copy).forEachPixel((s, t) -> t.set(s)); + + for (final var cal : Arrays.asList(null, calibration)) { + final var expected = ArrayImgs.floats(view.dimensionsAsLongArray()); + final var actual = Views.translate(ArrayImgs.floats(view + .dimensionsAsLongArray()), view.minAsLongArray()); + if (cal == null) { + ops.op("image.distanceTransform").input(copy).output(expected) + .compute(); + ops.op("image.distanceTransform").input(view).output(actual) + .compute(); + } + else { + ops.op("image.distanceTransform").input(copy, cal).output(expected) + .compute(); + ops.op("image.distanceTransform").input(view, cal).output(actual) + .compute(); + } + assertArrayEquals(view.minAsLongArray(), actual.minAsLongArray()); + final Cursor e = expected.cursor(); + final RandomAccess a = Views.zeroMin(actual).randomAccess(); + while (e.hasNext()) { + e.fwd(); + a.setPosition(e); + assertEquals(e.get().get(), a.get().get(), EPSILON, "at " + Arrays + .toString(e.positionAsLongArray()) + ", calibration " + Arrays + .toString(cal)); + } + } + } +} diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/image/extrema/LocalExtremaTest.java b/scijava-ops-image/src/test/java/org/scijava/ops/image/image/extrema/LocalExtremaTest.java new file mode 100644 index 000000000..e19b035e3 --- /dev/null +++ b/scijava-ops-image/src/test/java/org/scijava/ops/image/image/extrema/LocalExtremaTest.java @@ -0,0 +1,182 @@ +/*- + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.image.extrema; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.ArrayList; +import java.util.List; +import java.util.Random; + +import net.imglib2.Point; +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.algorithm.neighborhood.RectangleShape; +import net.imglib2.algorithm.neighborhood.Shape; +import net.imglib2.img.array.ArrayImgs; +import net.imglib2.type.logic.BitType; +import net.imglib2.type.numeric.integer.UnsignedByteType; +import net.imglib2.view.Views; + +import org.junit.jupiter.api.Test; +import org.scijava.ops.image.AbstractOpTest; +import org.scijava.types.Nil; + +/** + * Tests {@link DefaultLocalExtrema} and {@link LocalExtremaMask}. + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +public class LocalExtremaTest extends AbstractOpTest { + + // This 6x5 image has an isolated peak (4,1)=9, a corner peak (0,0)=5, a + // two-pixel plateau (1,3)=(2,3)=7, a plateau of 1s and a minimum (4,3)=0. + // Some of the 2s are also non-strict local extrema, hence the thresholds. + private static final byte[] DATA = { // + 5, 1, 1, 2, 2, 2, // + 1, 1, 1, 2, 9, 2, // + 1, 2, 3, 2, 2, 2, // + 2, 7, 7, 2, 0, 2, // + 2, 2, 2, 2, 2, 2 // + }; + + private RandomAccessibleInterval image() { + return ArrayImgs.unsignedBytes(DATA.clone(), 6, 5); + } + + private List extrema(final RandomAccessibleInterval img, + final Shape shape, final Double threshold, final Boolean maxima) + { + return ops.op("image.localExtrema").input(img, shape, threshold, maxima) + .outType(new Nil>() + {}).apply(); + } + + @Test + public void testMaxima() { + final List maxima = ops.op("image.localExtrema").input(image(), + null, 4.0).outType(new Nil>() + {}).apply(); + assertPoints(maxima, 0, 0, 4, 1, 1, 3, 2, 3); + } + + @Test + public void testThreshold() { + assertPoints(extrema(image(), null, 6.0, true), 4, 1, 1, 3, 2, 3); + assertPoints(extrema(image(), null, 8.0, true), 4, 1); + } + + @Test + public void testMinima() { + // The 1s plateau (top left) touches no darker pixel; nor does 0 at (4,3). + assertPoints(extrema(image(), null, 1.5, false), 1, 0, 2, 0, 1, 1, 2, 1, + 0, 1, 0, 2, 4, 3); + assertPoints(extrema(image(), null, 0.5, false), 4, 3); + } + + @Test + public void testLargerNeighborhood() { + // With span 3, the corner peak and the plateau see brighter pixels. + assertPoints(extrema(image(), new RectangleShape(3, true), 4.0, true), 4, + 1); + } + + @Test + public void testMask() { + final var mask = ArrayImgs.bits(6, 5); + ops.op("image.localExtrema").input(image(), null, 8.0, true).output(mask) + .compute(); + final var ra = mask.randomAccess(); + var count = 0; + for (final var c = mask.localizingCursor(); c.hasNext();) { + if (c.next().get()) { + count++; + assertArrayEquals(new long[] { 4, 1 }, c.positionAsLongArray()); + } + } + assertEquals(1, count); + ra.setPosition(new long[] { 4, 1 }); + assertEquals(true, ra.get().get()); + } + + @Test + public void testOffsetInput() { + final var img = Views.translate(image(), 10, 20); + assertPoints(extrema(img, null, 8.0, true), 14, 21); + } + + /** Compares against a brute-force search on a random 3D image. */ + @Test + public void testAgainstBruteForce() { + final long[] dims = { 9, 7, 5 }; + final var img = ArrayImgs.unsignedBytes(dims); + final var rng = new Random(0xdeadbeefL); + for (final var t : img) + t.set(rng.nextInt(8)); + + final List expected = new ArrayList<>(); + final var ra = img.randomAccess(); + for (var z = 0; z < dims[2]; z++) + for (var y = 0; y < dims[1]; y++) + for (var x = 0; x < dims[0]; x++) { + ra.setPosition(new int[] { x, y, z }); + final var v = ra.get().get(); + var isMax = v >= 3; + for (var dz = -1; dz <= 1 && isMax; dz++) + for (var dy = -1; dy <= 1 && isMax; dy++) + for (var dx = -1; dx <= 1 && isMax; dx++) { + final int xx = x + dx, yy = y + dy, zz = z + dz; + if (xx < 0 || yy < 0 || zz < 0 || xx >= dims[0] || + yy >= dims[1] || zz >= dims[2]) continue; + ra.setPosition(new int[] { xx, yy, zz }); + if (ra.get().get() > v) isMax = false; + } + if (isMax) expected.add(new Point(x, y, z)); + } + + final var actual = extrema(img, null, 3.0, true); + assertEquals(expected, actual); + } + + private static void assertPoints(final List actual, + final long... xy) + { + final List expected = new ArrayList<>(); + for (var i = 0; i < xy.length; i += 2) + expected.add(new Point(xy[i], xy[i + 1])); + expected.sort((p1, p2) -> { + final var c = Long.compare(p1.getLongPosition(1), p2.getLongPosition(1)); + return c != 0 ? c : Long.compare(p1.getLongPosition(0), p2 + .getLongPosition(0)); + }); + assertEquals(expected, actual); + } +} diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/image/histogram2D/DefaultHistogram2DTest.java b/scijava-ops-image/src/test/java/org/scijava/ops/image/image/histogram2D/DefaultHistogram2DTest.java new file mode 100644 index 000000000..6b9845710 --- /dev/null +++ b/scijava-ops-image/src/test/java/org/scijava/ops/image/image/histogram2D/DefaultHistogram2DTest.java @@ -0,0 +1,99 @@ +/*- + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.image.histogram2D; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import net.imglib2.img.Img; +import net.imglib2.img.array.ArrayImgs; +import net.imglib2.type.numeric.integer.LongType; + +import org.junit.jupiter.api.Test; +import org.scijava.ops.image.AbstractOpTest; +import org.scijava.types.Nil; + +/** + * Tests {@link DefaultHistogram2D}. + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +public class DefaultHistogram2DTest extends AbstractOpTest { + + @Test + public void testJointCounts() { + final var img1 = ArrayImgs.unsignedBytes(new byte[] { 0, 0, 1, 1, 2, 2, 3, + 3 }, 4, 2); + final var img2 = ArrayImgs.floats(new float[] { 10, 20, 10, 20, 10, 10, 20, + 20 }, 4, 2); + final Img histogram = ops.op("image.histogram2D").input(img1, + img2, 4, 2).outType(new Nil>() + {}).apply(); + assertArrayEquals(new long[] { 4, 2 }, histogram.dimensionsAsLongArray()); + final var expected = new long[][] { { 1, 1 }, { 1, 1 }, { 2, 0 }, { 0, + 2 } }; + final var ra = histogram.randomAccess(); + long total = 0; + for (var i = 0; i < 4; i++) { + for (var j = 0; j < 2; j++) { + final var count = ra.setPositionAndGet(i, j).get(); + assertEquals(expected[i][j], count, "bin (" + i + ", " + j + ")"); + total += count; + } + } + assertEquals(8, total); + } + + @Test + public void testDefaultBins() { + final var img = ArrayImgs.unsignedBytes(new byte[] { 0, 1, (byte) 254, + (byte) 255 }, 2, 2); + final Img histogram = ops.op("image.histogram2D").input(img, img) + .outType(new Nil>() + {}).apply(); + assertArrayEquals(new long[] { 256, 256 }, histogram.dimensionsAsLongArray()); + final var ra = histogram.randomAccess(); + for (final var v : new int[] { 0, 1, 254, 255 }) + assertEquals(1, ra.setPositionAndGet(v, v).get()); + } + + @Test + public void testConstantImage() { + final var img1 = ArrayImgs.doubles(new double[] { 5, 5, 5, 5 }, 4); + final var img2 = ArrayImgs.doubles(new double[] { 0, 1, 2, 3 }, 4); + final Img histogram = ops.op("image.histogram2D").input(img1, + img2, 3, 4).outType(new Nil>() + {}).apply(); + final var ra = histogram.randomAccess(); + for (var j = 0; j < 4; j++) + assertEquals(1, ra.setPositionAndGet(0, j).get()); + } +} diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/image/integral/IntegralImgTest.java b/scijava-ops-image/src/test/java/org/scijava/ops/image/image/integral/IntegralImgTest.java index 52599e320..bfad410de 100644 --- a/scijava-ops-image/src/test/java/org/scijava/ops/image/image/integral/IntegralImgTest.java +++ b/scijava-ops-image/src/test/java/org/scijava/ops/image/image/integral/IntegralImgTest.java @@ -105,6 +105,25 @@ public void testIntegralImageSimilarity() { .offsetInterval(out2, interval))); } + @Test + public void testIntegralImage3D() { + final var ones = ArrayImgs.bytes(4, 3, 2); + ones.forEach(t -> t.set((byte) 2)); + final var sums = ArrayImgs.doubles(4, 3, 2); + final var squares = ArrayImgs.doubles(4, 3, 2); + ops.op("image.integral").input(ones).output(sums).compute(); + ops.op("image.squareIntegral").input(ones).output(squares).compute(); + final var sc = sums.localizingCursor(); + final var qc = squares.cursor(); + while (sc.hasNext()) { + final var sum = sc.next().get(); + final long count = (sc.getLongPosition(0) + 1) * (sc.getLongPosition(1) + + 1) * (sc.getLongPosition(2) + 1); + assertEquals(2 * count, sum, 0); + assertEquals(4 * count, qc.next().get(), 0); + } + } + public ArrayImg generateKnownByteArrayTestImgLarge() { final long[] dims = new long[] { 3, 3 }; final byte[] array = new byte[9]; diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/image/kymograph/KymographTest.java b/scijava-ops-image/src/test/java/org/scijava/ops/image/image/kymograph/KymographTest.java new file mode 100644 index 000000000..5e4b8922b --- /dev/null +++ b/scijava-ops-image/src/test/java/org/scijava/ops/image/image/kymograph/KymographTest.java @@ -0,0 +1,217 @@ +/*- + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.image.kymograph; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.Arrays; +import java.util.Locale; + +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.RealPoint; +import net.imglib2.img.Img; +import net.imglib2.img.array.ArrayImgs; +import net.imglib2.roi.geom.real.DefaultWritablePolyline; +import net.imglib2.roi.geom.real.Polyline; +import net.imglib2.type.numeric.integer.UnsignedByteType; +import net.imglib2.type.numeric.real.FloatType; + +import org.junit.jupiter.api.Test; +import org.scijava.ops.image.AbstractOpTest; +import org.scijava.types.Nil; + +/** + * Tests {@link DefaultKymograph}. + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +public class KymographTest extends AbstractOpTest { + + private static Polyline line(final double... xy) { + final var points = new RealPoint[xy.length / 2]; + for (var i = 0; i < points.length; i++) + points[i] = new RealPoint(xy[2 * i], xy[2 * i + 1]); + return new DefaultWritablePolyline(Arrays.asList(points)); + } + + /** Creates a 20x9x6 (x, y, t) movie of a dot moving at 3 pixels/frame. */ + private static Img movingDot(final int y) { + final var img = ArrayImgs.floats(20, 9, 6); + final var ra = img.randomAccess(); + for (var t = 0; t < 6; t++) { + ra.setPosition(new int[] { 2 + 3 * t, y, t }); + ra.get().set(100); + } + return img; + } + + private RandomAccessibleInterval kymograph( + final RandomAccessibleInterval img, final Polyline line, + final Integer timeDim, final Integer width, final String reduce, + final String interpolation) + { + return ops.op("image.kymograph").input(img, line, timeDim, width, reduce, + interpolation).outType(new Nil>() + {}).apply(); + } + + private static float get(final RandomAccessibleInterval img, + final long... pos) + { + return img.getAt(pos).get(); + } + + @Test + public void testMovingDot() { + final RandomAccessibleInterval kymo = ops.op("image.kymograph") + .input(movingDot(4), line(0, 4, 19, 4)).outType( + new Nil>() + {}).apply(); + assertArrayEquals(new long[] { 20, 6 }, kymo.dimensionsAsLongArray()); + // The dot traces a straight line of slope 3 in the kymograph. + for (var t = 0; t < 6; t++) + for (var x = 0; x < 20; x++) + assertEquals(x == 2 + 3 * t ? 100 : 0, get(kymo, x, t), 0); + } + + /** Under a Turkish locale, "LINEAR".toLowerCase() is "l\u0131near". */ + @Test + public void testOptionsIgnoreDefaultLocale() { + final var img = movingDot(4); + final var expected = kymograph(img, line(0, 4, 19, 4), null, null, null, + null); + final var saved = Locale.getDefault(); + try { + Locale.setDefault(Locale.forLanguageTag("tr-TR")); + final var kymo = kymograph(img, line(0, 4, 19, 4), null, null, "MAX", + "LINEAR"); + for (var t = 0; t < 6; t++) + for (var x = 0; x < 20; x++) + assertEquals(get(expected, x, t), get(kymo, x, t), 0); + } + finally { + Locale.setDefault(saved); + } + } + + @Test + public void testFirstRowAndColumnAreSampled() { + // KymographBuilder skipped x = 0 and y = 0; this op must not. + final var img = ArrayImgs.floats(6, 6, 2); + img.getAt(0, 0, 1).set(42); + final var kymo = kymograph(img, line(0, 0, 5, 0), null, null, null, null); + assertEquals(42, get(kymo, 0, 1), 0); + assertEquals(0, get(kymo, 0, 0), 0); + } + + @Test + public void testLineWidth() { + // The dot is at y = 5, one pixel off the line at y = 4. + final var img = movingDot(5); + final var line = line(0, 4, 19, 4); + final var thin = kymograph(img, line, 2, 1, "max", null); + assertEquals(0, get(thin, 5, 1), 0); + + final var max = kymograph(img, line, 2, 3, "max", null); + assertEquals(100, get(max, 5, 1), 0); + final var mean = kymograph(img, line, 2, 3, "mean", null); + assertEquals(100 / 3.0, get(mean, 5, 1), 1e-5); + + final var none = kymograph(img, line, 2, 3, "none", null); + assertArrayEquals(new long[] { 20, 6, 3 }, none.dimensionsAsLongArray()); + // The parallel lines are offset by -1, 0 and +1 along the normal (0, 1). + assertEquals(0, get(none, 5, 1, 0), 0); + assertEquals(0, get(none, 5, 1, 1), 0); + assertEquals(100, get(none, 5, 1, 2), 0); + } + + @Test + public void testPolyline() { + // The pixel value 10x + y identifies each position. + final var img = ArrayImgs.floats(10, 10, 2); + final var c = img.localizingCursor(); + while (c.hasNext()) { + c.fwd(); + c.get().set(10 * c.getIntPosition(0) + c.getIntPosition(1)); + } + // The path goes down 4 pixels, then right 6 pixels. + final var kymo = kymograph(img, line(2, 2, 2, 6, 8, 6), null, null, null, + null); + assertArrayEquals(new long[] { 11, 2 }, kymo.dimensionsAsLongArray()); + for (var t = 0; t < 2; t++) + for (var j = 0; j <= 10; j++) { + final var expected = j <= 4 ? 22 + j : 10 * (j - 2) + 6; + assertEquals(expected, get(kymo, j, t), 1e-4); + } + } + + @Test + public void testInterpolation() { + // A horizontal ramp, sampled along a diagonal between pixel centers. + final var img = ArrayImgs.floats(8, 8, 1); + final var c = img.localizingCursor(); + while (c.hasNext()) { + c.fwd(); + c.get().set(c.getIntPosition(0)); + } + final var s = 0.6; + final var linear = kymograph(img, line(1.2, 1, 1.2 + 5 * s, 1 + 5 * 0.8), + null, null, null, "linear"); + final var nearest = kymograph(img, line(1.2, 1, 1.2 + 5 * s, 1 + 5 * 0.8), + null, null, null, "nearest"); + for (var j = 0; j <= 5; j++) { + final var x = 1.2 + j * s; + assertEquals(x, get(linear, j, 0), 1e-5); + assertEquals(Math.round(x), get(nearest, j, 0), 0); + } + } + + @Test + public void testExtraDimensionsAndIntegerType() { + // This is an (x, y, c, t) image whose value encodes channel and time. + final var img = ArrayImgs.unsignedBytes(5, 3, 2, 4); + final var c = img.localizingCursor(); + while (c.hasNext()) { + c.fwd(); + c.get().set(10 * c.getIntPosition(2) + c.getIntPosition(3)); + } + final RandomAccessibleInterval kymo = ops.op( + "image.kymograph").input(img, line(0, 1, 4, 1), 3).outType( + new Nil>() + {}).apply(); + assertArrayEquals(new long[] { 5, 4, 2 }, kymo.dimensionsAsLongArray()); + for (var ch = 0; ch < 2; ch++) + for (var t = 0; t < 4; t++) + for (var j = 0; j < 5; j++) + assertEquals(10 * ch + t, kymo.getAt(j, t, ch).get()); + } +} diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/image/piv/DefaultPIVTest.java b/scijava-ops-image/src/test/java/org/scijava/ops/image/image/piv/DefaultPIVTest.java new file mode 100644 index 000000000..c3c69c8e0 --- /dev/null +++ b/scijava-ops-image/src/test/java/org/scijava/ops/image/image/piv/DefaultPIVTest.java @@ -0,0 +1,279 @@ +/*- + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.image.piv; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Random; +import java.util.function.Consumer; +import java.util.function.Function; + +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.img.array.ArrayImgs; +import net.imglib2.type.numeric.real.FloatType; +import net.imglib2.view.Views; + +import org.junit.jupiter.api.Test; +import org.scijava.ops.image.AbstractOpTest; + +/** + * Tests {@link DefaultPIV}. + *

+ * The test frames are synthetic particle images: Gaussian particles at random + * real positions, rendered exactly, which move by a known displacement field + * from the first frame to the second. + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +public class DefaultPIVTest extends AbstractOpTest { + + @Test + public void testUniformIntegerDisplacement() { + final double[] u = { 5, -3 }; + final var frames = frames(new long[] { 128, 128 }, 0.02, x -> u, 1); + final var result = piv(frames, new int[] { 32 }, new int[] { 16 }, null, + null, null); + assertArrayEquals(new long[] { 7, 7 }, result.getGridSize()); + forEachGridPoint(result, g -> { + assertArrayEquals(u, result.getDisplacement(g), 0.05); + assertTrue(result.getCorrelation(g) > 0.95); + assertTrue(result.isValid(g)); + }); + } + + @Test + public void testUniformIntegerDisplacementWithoutSubpixel() { + final double[] u = { -7, 4 }; + final var frames = frames(new long[] { 96, 96 }, 0.02, x -> u, 2); + final var result = piv(frames, new int[] { 32 }, null, null, false, null); + assertArrayEquals(new long[] { 3, 3 }, result.getGridSize()); + forEachGridPoint(result, g -> assertArrayEquals(u, result.getDisplacement( + g), 0)); + } + + @Test + public void testUniformSubpixelDisplacement() { + final double[] u = { 2.3, -1.6 }; + final var frames = frames(new long[] { 128, 128 }, 0.02, x -> u, 3); + final var result = piv(frames, new int[] { 32 }, new int[] { 16 }, null, + null, null); + forEachGridPoint(result, g -> assertArrayEquals(u, result.getDisplacement( + g), 0.1)); + } + + @Test + public void testShear() { + // Horizontal flow increasing linearly with y, from -3 to +3 pixels. + final var gamma = 6.0 / 128; + final var frames = frames(new long[] { 128, 128 }, 0.02, x -> new double[] { + gamma * (x[1] - 64), 0 }, 4); + final var result = piv(frames, new int[] { 24 }, new int[] { 12 }, + new int[] { 6 }, null, null); + // Note: the velocity gradient within a window broadens the correlation + // peak, which biases the subpixel fit, so individual vectors may be off + // by a few tenths of a pixel. + final var sumSq = new double[1]; + forEachGridPoint(result, g -> { + final var y = result.getPosition(g)[1]; + final var u = result.getDisplacement(g); + assertEquals(gamma * (y - 64), u[0], 0.3); + assertEquals(0, u[1], 0.3); + sumSq[0] += Math.pow(u[0] - gamma * (y - 64), 2) + u[1] * u[1]; + }); + final var size = result.getGridSize(); + final var rms = Math.sqrt(sumSq[0] / (size[0] * size[1])); + assertTrue(rms < 0.15, "RMS error " + rms); + } + + @Test + public void testPositions() { + final var frames = frames(new long[] { 70, 50 }, 0.03, x -> new double[] { + 1, 1 }, 5); + final var translated = new RandomAccessibleInterval[] { Views.translate( + frames[0], 10, 20), Views.translate(frames[1], 10, 20) }; + final var result = piv(translated, new int[] { 16, 8 }, new int[] { 10, + 12 }, new int[] { 4 }, null, null); + // (70 - 16) / 10 + 1 = 6 by (50 - 8) / 12 + 1 = 4. + assertArrayEquals(new long[] { 6, 4 }, result.getGridSize()); + assertArrayEquals(new double[] { 10 + 7.5, 20 + 3.5 }, result.getPosition( + 0, 0), 0); + assertArrayEquals(new double[] { 10 + 50 + 7.5, 20 + 36 + 3.5 }, result + .getPosition(5, 3), 0); + final var positions = result.getPositions(); + assertArrayEquals(new long[] { 6, 4, 2 }, positions.dimensionsAsLongArray()); + assertEquals(10 + 30 + 7.5, positions.getAt(3, 2, 0).get(), 0); + assertEquals(20 + 24 + 3.5, positions.getAt(3, 2, 1).get(), 0); + final var displacements = result.getDisplacements(); + assertEquals(result.getDisplacement(3, 2)[1], displacements.getAt(3, 2, 1) + .get(), 0); + } + + /** + * In one region of the second frame, the particles are scrambled, so that + * the window covering it gets a spurious vector. The median test must flag + * and replace it. + */ + @Test + public void testMedianValidation() { + final double[] u = { 3, 2 }; + final var frames = frames(new long[] { 128, 128 }, 0.02, x -> u, 6); + final var scrambled = frames(new long[] { 128, 128 }, 0.02, x -> u, 7)[1]; + final var region = Views.interval(scrambled, new long[] { 60, 60 }, + new long[] { 99, 99 }); + final var target = Views.interval(frames[1], region); + final var c = target.cursor(); + final var s = region.cursor(); + while (c.hasNext()) + c.next().set(s.next()); + final int[] window = { 32 }; + + final var raw = piv(frames, window, null, null, null, null); + final var spurious = raw.getDisplacement(2, 2); + assertFalse(Math.abs(spurious[0] - u[0]) < 1 && Math.abs(spurious[1] - + u[1]) < 1, "The scrambled window should give a spurious vector."); + + final var validated = piv(frames, window, null, null, null, 2.0); + assertFalse(validated.isValid(2, 2)); + assertArrayEquals(u, validated.getDisplacement(2, 2), 0.1); + var invalid = 0; + for (final var v : Views.iterable(validated.getValid())) + if (!v.get()) invalid++; + assertEquals(1, invalid); + assertArrayEquals(u, validated.getDisplacement(0, 0), 0.05); + } + + @Test + public void testUniformSubpixelDisplacement3D() { + final double[] u = { 1.4, -2.0, 0.7 }; + final var frames = frames(new long[] { 32, 32, 32 }, 0.01, x -> u, 8); + final var result = piv(frames, new int[] { 16 }, null, new int[] { 4 }, + null, null); + assertArrayEquals(new long[] { 2, 2, 2 }, result.getGridSize()); + forEachGridPoint(result, g -> assertArrayEquals(u, result.getDisplacement( + g), 0.15)); + } + + @Test + public void testFlatWindow() { + final var frame = ArrayImgs.floats(32, 32); + final var result = piv(new RandomAccessibleInterval[] { frame, frame }, + new int[] { 16 }, null, null, null, null); + assertTrue(Double.isNaN(result.getDisplacement(0, 0)[0])); + assertTrue(Double.isNaN(result.getCorrelation(1, 1))); + assertFalse(result.isValid(1, 0)); + } + + private PIVResult piv(final RandomAccessibleInterval[] frames, + final int[] windowSize, final int[] step, final int[] searchRange, + final Boolean subpixel, final Double medianThreshold) + { + return ops.op("image.piv").input(frames[0], frames[1], windowSize, + step, searchRange, subpixel, medianThreshold).outType(PIVResult.class) + .apply(); + } + + private static void forEachGridPoint(final PIVResult result, + final Consumer action) + { + final var size = result.getGridSize(); + final var g = new long[size.length]; + while (true) { + action.accept(g.clone()); + var d = 0; + for (; d < g.length; d++) { + if (++g[d] < size[d]) break; + g[d] = 0; + } + if (d == g.length) return; + } + } + + /** + * Renders two particle images: particles at uniformly random positions, + * which are displaced by the given field, evaluated at their positions in + * the first frame. + */ + @SuppressWarnings("unchecked") + private static RandomAccessibleInterval[] frames( + final long[] dims, final double density, + final Function field, final long seed) + { + final var rnd = new Random(seed); + final var n = dims.length; + var volume = 1.0; + for (final var d : dims) + volume *= d + 20; + final var count = (int) (density * volume); + final var first = new double[count][n]; + final var second = new double[count][n]; + final var amplitudes = new double[count]; + for (var i = 0; i < count; i++) { + // Note: particles also lie around the frame, so that some move in. + for (var d = 0; d < n; d++) + first[i][d] = (dims[d] + 20) * rnd.nextDouble() - 10; + final var u = field.apply(first[i]); + for (var d = 0; d < n; d++) + second[i][d] = first[i][d] + u[d]; + amplitudes[i] = 0.5 + rnd.nextDouble(); + } + return new RandomAccessibleInterval[] { render(dims, first, amplitudes), + render(dims, second, amplitudes) }; + } + + private static RandomAccessibleInterval render(final long[] dims, + final double[][] particles, final double[] amplitudes) + { + final var sigma = 1.2; + final var img = ArrayImgs.floats(dims); + final var cursor = img.localizingCursor(); + final var x = new double[dims.length]; + while (cursor.hasNext()) { + cursor.fwd(); + cursor.localize(x); + var sum = 0.0; + for (var i = 0; i < particles.length; i++) { + var r2 = 0.0; + for (var d = 0; d < x.length && r2 < 50; d++) { + final var dx = x[d] - particles[i][d]; + r2 += dx * dx; + } + if (r2 < 50) sum += amplitudes[i] * Math.exp(-r2 / (2 * sigma * + sigma)); + } + cursor.get().setReal(sum); + } + return img; + } +} diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/image/quantileNormalize/QuantileNormalizeTest.java b/scijava-ops-image/src/test/java/org/scijava/ops/image/image/quantileNormalize/QuantileNormalizeTest.java new file mode 100644 index 000000000..d93c5c040 --- /dev/null +++ b/scijava-ops-image/src/test/java/org/scijava/ops/image/image/quantileNormalize/QuantileNormalizeTest.java @@ -0,0 +1,171 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.image.quantileNormalize; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Random; + +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.img.array.ArrayImgs; +import net.imglib2.type.logic.BitType; +import net.imglib2.type.numeric.real.DoubleType; +import net.imglib2.view.Views; + +import org.junit.jupiter.api.Test; +import org.scijava.ops.image.AbstractOpTest; +import org.scijava.types.Nil; + +/** + * Tests {@link QuantileNormalize}. + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +public class QuantileNormalizeTest extends AbstractOpTest { + + private static final Nil>> IMAGES = + new Nil<>() + {}; + + private static final Nil>> OUT = + new Nil<>() + {}; + + /** + * With one class per pixel, the k-th smallest value of every image becomes + * the mean of the k-th smallest values (Bolstad et al. 2003). + */ + @Test + public void testClassic() { + final var a = ArrayImgs.doubles(new double[] { 5, 2, 3, 4 }, 2, 2); + final var b = ArrayImgs.doubles(new double[] { 4, 1, 4, 2 }, 2, 2); + final var c = ArrayImgs.doubles(new double[] { 3, 4, 6, 8 }, 2, 2); + final List> images = List.of(a, b, c); + final var out = normalize(images, 4); + // Sorted columns: (2, 3, 4, 5), (1, 2, 4, 4), (3, 4, 6, 8). + // Means: 2, 3, 14/3, 17/3. + assertArrayEquals(new double[] { 17. / 3, 2, 3, 14. / 3 }, values(out.get( + 0)), 1e-12); + // Note: The tied 4s rank in flat iteration order. + assertArrayEquals(new double[] { 14. / 3, 2, 17. / 3, 3 }, values(out.get( + 1)), 1e-12); + assertArrayEquals(new double[] { 2, 3, 14. / 3, 17. / 3 }, values(out.get( + 2)), 1e-12); + } + + /** After normalization, all images have the same sorted values. */ + @Test + public void testSameDistribution() { + final var rnd = new Random(1); + final List> images = new ArrayList<>(); + for (var i = 0; i < 3; i++) { + final var img = ArrayImgs.doubles(20, 10); + final var scale = 1 + i; + img.forEach(t -> t.set(scale * rnd.nextGaussian() + 10 * scale)); + images.add(img); + } + final var out = normalize(images, 50); + final var ref = sorted(out.get(0)); + assertEquals(50, Arrays.stream(ref).distinct().count()); + for (var i = 1; i < 3; i++) + assertArrayEquals(ref, sorted(out.get(i)), 1e-12); + // Ranks are preserved. + for (var i = 0; i < 3; i++) { + final var in = values(images.get(i)); + final var o = values(out.get(i)); + for (var p = 0; p < in.length; p++) + for (var r = 0; r < in.length; r++) + if (in[p] < in[r]) assertEquals(true, o[p] <= o[r]); + } + + // A single image with one class per pixel is unchanged. + final var single = normalize(List.of(images.get(0)), + 200); + assertArrayEquals(values(images.get(0)), values(single.get(0)), 1e-12); + } + + /** Pixels outside the masks keep their values and do not contribute. */ + @Test + public void testMasks() { + final var a = ArrayImgs.doubles(new double[] { 1, 2, 100, 3 }, 4); + final var b = ArrayImgs.doubles(new double[] { 10, 20, 30, -5 }, 4); + final var ma = ArrayImgs.bits(4); + final var mb = ArrayImgs.bits(4); + final var ra = ma.randomAccess(); + final var rb = mb.randomAccess(); + for (final var p : new int[] { 0, 1, 3 }) { + ra.setPositionAndGet(p).set(true); + } + for (final var p : new int[] { 0, 1, 2 }) { + rb.setPositionAndGet(p).set(true); + } + final List> images = List.of(a, b); + final List> masks = List.of(ma, mb); + final var out = normalize(images, 3, masks); + assertArrayEquals(new double[] { 5.5, 11, 100, 16.5 }, values(out.get(0)), + 1e-12); + assertArrayEquals(new double[] { 5.5, 11, 16.5, -5 }, values(out.get(1)), + 1e-12); + } + + private List> normalize( + final List> images, final int q) + { + return ops.op("image.quantileNormalize").inType(IMAGES, new Nil() + {}).outType(OUT).function().apply(images, q); + } + + private List> normalize( + final List> images, final int q, + final List> masks) + { + return ops.op("image.quantileNormalize").inType(IMAGES, new Nil() + {}, new Nil>>() + {}).outType(OUT).function().apply(images, q, masks); + } + + private static double[] values(final RandomAccessibleInterval img) { + final var list = new ArrayList(); + for (final var t : Views.flatIterable(img)) + list.add(t.get()); + return list.stream().mapToDouble(Double::doubleValue).toArray(); + } + + private static double[] sorted(final RandomAccessibleInterval img) { + final var v = values(img); + Arrays.sort(v); + return v; + } +} diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/image/sdt/SignedDistanceTransformTest.java b/scijava-ops-image/src/test/java/org/scijava/ops/image/image/sdt/SignedDistanceTransformTest.java new file mode 100644 index 000000000..d5501efb8 --- /dev/null +++ b/scijava-ops-image/src/test/java/org/scijava/ops/image/image/sdt/SignedDistanceTransformTest.java @@ -0,0 +1,138 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.image.sdt; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Random; + +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.img.array.ArrayImgs; +import net.imglib2.loops.LoopBuilder; +import net.imglib2.type.logic.BitType; +import net.imglib2.type.numeric.real.FloatType; +import net.imglib2.view.Views; + +import org.junit.jupiter.api.Test; +import org.scijava.ops.image.AbstractOpTest; +import org.scijava.types.Nil; + +/** + * Tests {@code image.signedDistanceTransform}. + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +public class SignedDistanceTransformTest extends AbstractOpTest { + + private static final Nil> OUT = + new Nil<>() + {}; + + @Test + public void testSquare() { + // A 5 x 5 square at [5, 9]^2 in a 15 x 15 image. + final var mask = ArrayImgs.bits(15, 15); + final var c = mask.localizingCursor(); + while (c.hasNext()) { + c.fwd(); + final long x = c.getLongPosition(0), y = c.getLongPosition(1); + c.get().set(x >= 5 && x <= 9 && y >= 5 && y <= 9); + } + final RandomAccessibleInterval sdt = ops.op( + "image.signedDistanceTransform").input(mask).outType(OUT).apply(); + final var ra = sdt.randomAccess(); + assertEquals(0.5, ra.setPositionAndGet(5, 7).get(), 1e-6); + assertEquals(-0.5, ra.setPositionAndGet(4, 7).get(), 1e-6); + assertEquals(2.5, ra.setPositionAndGet(7, 7).get(), 1e-6); + assertEquals(-(Math.sqrt(50) - 0.5), ra.setPositionAndGet(0, 0).get(), + 1e-5); + // Positive exactly inside the mask. + final var mc = mask.cursor(); + for (final var t : Views.flatIterable(sdt)) + assertEquals(mc.next().get(), t.get() > 0); + + // Anisotropic calibration stretches the x distances. + final RandomAccessibleInterval cal = ops.op( + "image.signedDistanceTransform").input(mask, new double[] { 2, 1 }) + .outType(OUT).apply(); + final var ca = cal.randomAccess(); + assertEquals(-(4 - 0.5), ca.setPositionAndGet(3, 7).get(), 1e-5); + assertEquals(-(2 - 0.5), ca.setPositionAndGet(7, 3).get(), 1e-5); + } + + @Test + public void testUniformMasks() { + final var empty = ArrayImgs.bits(4, 3); + final RandomAccessibleInterval e = ops.op( + "image.signedDistanceTransform").input(empty).outType(OUT).apply(); + for (final var t : Views.iterable(e)) + assertTrue(t.get() < -4); + final var full = ArrayImgs.bits(4, 3); + for (final var b : full) + b.set(true); + final RandomAccessibleInterval f = ops.op( + "image.signedDistanceTransform").input(full).outType(OUT).apply(); + for (final var t : Views.iterable(f)) + assertTrue(t.get() > 4); + } + + @Test + public void testNonZeroMin() { + // A random mask embedded in a larger image with foreground all around it. + final var big = ArrayImgs.bits(24, 21); + final var random = new Random(42); + for (final var b : big) + b.set(random.nextDouble() < 0.6); + final var crop = Views.interval(big, new long[] { 3, 5 }, new long[] { 19, + 17 }); + checkNonZeroMin(crop); + checkNonZeroMin(Views.translate(big, 40, -12)); + } + + private void checkNonZeroMin(final RandomAccessibleInterval view) { + final var copy = ArrayImgs.bits(view.dimensionsAsLongArray()); + LoopBuilder.setImages(view, copy).forEachPixel((s, + t) -> t.set(s)); + for (final var cal : new double[][] { null, { 1.5, 0.75 } }) { + final RandomAccessibleInterval expected = ops.op( + "image.signedDistanceTransform").input(copy, cal).outType(OUT).apply(); + final RandomAccessibleInterval actual = ops.op( + "image.signedDistanceTransform").input(view, cal).outType(OUT).apply(); + assertArrayEquals(view.minAsLongArray(), actual.minAsLongArray()); + assertArrayEquals(view.maxAsLongArray(), actual.maxAsLongArray()); + final var e = Views.flatIterable(expected).cursor(); + for (final var a : Views.flatIterable(actual)) + assertEquals(e.next().get(), a.get(), 1e-5); + } + } +} diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/labeling/distance/LabelDistancesTest.java b/scijava-ops-image/src/test/java/org/scijava/ops/image/labeling/distance/LabelDistancesTest.java new file mode 100644 index 000000000..572e31480 --- /dev/null +++ b/scijava-ops-image/src/test/java/org/scijava/ops/image/labeling/distance/LabelDistancesTest.java @@ -0,0 +1,296 @@ +/* #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.labeling.distance; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.HashMap; +import java.util.Map; +import java.util.Random; +import java.util.Set; + +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.img.array.ArrayImgs; +import net.imglib2.roi.labeling.ImgLabeling; +import net.imglib2.type.logic.BitType; +import net.imglib2.type.numeric.integer.IntType; +import net.imglib2.type.numeric.real.DoubleType; +import net.imglib2.view.Views; + +import org.junit.jupiter.api.Test; +import org.scijava.ops.image.AbstractOpTest; +import org.scijava.types.Nil; + +/** + * Tests {@link LabelDistances}. + *

+ * Expected values are computed by brute force over all pixel pairs, directly + * from the definitions in the op documentation. + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +public class LabelDistancesTest extends AbstractOpTest { + + private static final Nil> LABELING = + new Nil<>() + {}; + + private static final Nil> DOUBLES = + new Nil<>() + {}; + + @Test + public void testDistanceMapTouchingSquares() { + final var labeling = new ImgLabeling(ArrayImgs.ints(12, + 7)); + paint(labeling, 1, 1, 1, 5, 5); + paint(labeling, 2, 6, 1, 10, 5); + final RandomAccessibleInterval d = ops.op( + "labeling.distanceMap").input(labeling).outType(DOUBLES).apply(); + final var ra = d.randomAccess(); + // Touching labels are separated: the shared edge has distance 1. + assertEquals(1, ra.setPositionAndGet(5, 3).get(), 0); + assertEquals(1, ra.setPositionAndGet(6, 3).get(), 0); + assertEquals(3, ra.setPositionAndGet(3, 3).get(), 0); + assertEquals(2, ra.setPositionAndGet(2, 2).get(), 0); + assertEquals(0, ra.setPositionAndGet(0, 0).get(), 0); + } + + @Test + public void testDistanceMapRandom() { + for (final var spacing : new double[][] { null, { 1.5, 0.7, 2 } }) { + final var labeling = randomLabeling(new long[] { 9, 8, 6 }, 4, 0.6, 7); + final RandomAccessibleInterval d = ops.op( + "labeling.distanceMap").input(labeling, spacing).outType(DOUBLES) + .apply(); + final var ids = ids(labeling); + final var s = spacing == null ? new double[] { 1, 1, 1 } : spacing; + final var c = Views.flatIterable(d).cursor(); + for (var i = 0; i < ids.length; i++) { + final var actual = c.next().get(); + if (ids[i] == 0) { + assertEquals(0, actual, 0); + continue; + } + var min = Double.POSITIVE_INFINITY; + for (var j = 0; j < ids.length; j++) + if (ids[j] != ids[i]) min = Math.min(min, dist2(i, j, labeling, s)); + assertEquals(Math.sqrt(min), actual, 1e-9); + } + } + } + + @Test + public void testDilateSinglePixel() { + final var labeling = new ImgLabeling(ArrayImgs.ints(9, + 9)); + paint(labeling, 1, 4, 4, 4, 4); + final ImgLabeling dilated = ops.op("labeling.dilate") + .input(labeling, 2.5).outType(LABELING).apply(); + // Digital disk of radius 2.5: x^2 + y^2 <= 6.25. + assertEquals(Map.of(1, 21L), sizes(dilated)); + final ImgLabeling aniso = ops.op("labeling.dilate") + .input(labeling, 2.0, new double[] { 1, 2 }).outType(LABELING).apply(); + // With spacing 2 along y, the disk spans 5 pixels in x and 3 in y. + assertEquals(Map.of(1, 7L), sizes(aniso)); + } + + @Test + public void testDilateRandom() { + final var labeling = randomLabeling(new long[] { 15, 13 }, 3, 0.05, 3); + final var s = new double[] { 1, 1.3 }; + final var r = 2.7; + final ImgLabeling dilated = ops.op("labeling.dilate") + .input(labeling, r, s).outType(LABELING).apply(); + final var ids = ids(labeling); + final var out = ids(dilated); + for (var i = 0; i < ids.length; i++) { + if (ids[i] != 0) { + // Labels are never overwritten. + assertEquals(ids[i], out[i]); + continue; + } + var min = Double.POSITIVE_INFINITY; + for (var j = 0; j < ids.length; j++) + if (ids[j] != 0) min = Math.min(min, dist2(i, j, labeling, s)); + if (min > r * r) assertEquals(0, out[i]); + else { + // The pixel gets a label at the minimal distance. + assertTrue(out[i] != 0); + var nearest = Double.POSITIVE_INFINITY; + for (var j = 0; j < ids.length; j++) + if (ids[j] == out[i]) nearest = Math.min(nearest, dist2(i, j, + labeling, s)); + assertEquals(min, nearest, 1e-9); + } + } + } + + @Test + public void testErode() { + final var labeling = randomLabeling(new long[] { 14, 12 }, 3, 0.8, 11); + final var s = new double[] { 1, 0.8 }; + final var r = 1.5; + for (final var backgroundOnly : new boolean[] { false, true }) { + final ImgLabeling eroded = ops.op("labeling.erode") + .input(labeling, r, s, backgroundOnly).outType(LABELING).apply(); + final var ids = ids(labeling); + final var out = ids(eroded); + for (var i = 0; i < ids.length; i++) { + var keep = ids[i] != 0; + for (var j = 0; keep && j < ids.length; j++) { + final var outside = backgroundOnly ? ids[j] == 0 : ids[j] != ids[i]; + if (outside && dist2(i, j, labeling, s) <= r * r) keep = false; + } + assertEquals(keep ? ids[i] : 0, out[i]); + } + } + } + + @Test + public void testOpenClose() { + final var labeling = new ImgLabeling(ArrayImgs.ints(16, + 9)); + // A square with a one pixel wide spur. + paint(labeling, 1, 1, 1, 5, 5); + paint(labeling, 1, 6, 3, 8, 3); + // A square separated from it by a gap of one pixel. + paint(labeling, 2, 10, 1, 14, 5); + paint(labeling, 3, 1, 7, 1, 7); + final ImgLabeling opened = ops.op("labeling.open") + .input(labeling, 1.5).outType(LABELING).apply(); + // The spur and the single pixel are removed, the squares are kept. + assertEquals(Map.of(1, 25L, 2, 25L), sizes(opened)); + + final var gap = new ImgLabeling(ArrayImgs.ints(15, 9)); + paint(gap, 1, 2, 2, 6, 6); + paint(gap, 2, 8, 2, 12, 6); + final ImgLabeling closed = ops.op("labeling.close") + .input(gap, 1.5).outType(LABELING).apply(); + // The gap column is filled, and the outer border stays unlabeled. + final var ra = closed.randomAccess(); + for (var y = 2; y <= 6; y++) + assertEquals(1, ra.setPositionAndGet(7, y).size()); + assertTrue(ra.setPositionAndGet(7, 1).isEmpty()); + assertTrue(ra.setPositionAndGet(1, 4).isEmpty()); + assertEquals(Set.of(1), ra.setPositionAndGet(6, 4)); + assertEquals(Set.of(2), ra.setPositionAndGet(8, 4)); + final var size = sizes(closed); + assertEquals(55, size.get(1) + size.get(2)); + } + + @Test + public void testInfluenceZones() { + final var labeling = new ImgLabeling(ArrayImgs.ints(10, + 7)); + paint(labeling, 1, 2, 3, 2, 3); + paint(labeling, 2, 6, 3, 6, 3); + final ImgLabeling zones = ops.op( + "labeling.influenceZones").input(labeling).outType(LABELING).apply(); + final var ra = zones.randomAccess(); + for (var y = 0; y < 7; y++) { + for (var x = 0; x < 10; x++) { + final var labels = ra.setPositionAndGet(x, y); + // The bisector x = 4 is a tie. + if (x < 4) assertEquals(Set.of(1), labels); + else if (x > 4) assertEquals(Set.of(2), labels); + else assertEquals(1, labels.size()); + } + } + final var mask = ArrayImgs.bits(10, 7); + Views.interval(mask, new long[] { 0, 2 }, new long[] { 9, 4 }).forEach( + BitType::setOne); + final ImgLabeling masked = ops.op( + "labeling.influenceZones").input(labeling, null, mask).outType(LABELING) + .apply(); + final var total = sizes(masked).values().stream().mapToLong(v -> v) + .sum(); + assertEquals(30, total); + } + + // -- Helper methods -- + + static void paint(final ImgLabeling labeling, final L label, + final long x0, final long y0, final long x1, final long y1) + { + Views.interval(labeling, new long[] { x0, y0 }, new long[] { x1, y1 }) + .forEach(p -> p.add(label)); + } + + private static ImgLabeling randomLabeling( + final long[] dims, final int labels, final double density, + final long seed) + { + final var random = new Random(seed); + final var labeling = new ImgLabeling(ArrayImgs.ints( + dims)); + for (final var p : Views.flatIterable(labeling)) { + if (random.nextDouble() < density) p.add(1 + random.nextInt(labels)); + } + return labeling; + } + + /** The label of each pixel in raster order, or 0 if unlabeled. */ + private static int[] ids(final ImgLabeling labeling) { + final var result = new int[(int) labeling.size()]; + var i = 0; + for (final var p : Views.flatIterable(labeling)) + result[i++] = p.isEmpty() ? 0 : p.iterator().next(); + return result; + } + + private static double dist2(final int i, final int j, + final ImgLabeling labeling, final double[] spacing) + { + var a = i; + var b = j; + var sum = 0.0; + for (var d = 0; d < labeling.numDimensions(); d++) { + final var n = (int) labeling.dimension(d); + final var delta = (a % n - b % n) * spacing[d]; + sum += delta * delta; + a /= n; + b /= n; + } + return sum; + } + + private static Map sizes( + final ImgLabeling labeling) + { + final var result = new HashMap(); + for (final var p : labeling) + for (final var label : p) + result.merge(label, 1L, Long::sum); + return result; + } +} diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/labeling/edgecorrection/EdgeCorrectionTest.java b/scijava-ops-image/src/test/java/org/scijava/ops/image/labeling/edgecorrection/EdgeCorrectionTest.java new file mode 100644 index 000000000..2981b0df9 --- /dev/null +++ b/scijava-ops-image/src/test/java/org/scijava/ops/image/labeling/edgecorrection/EdgeCorrectionTest.java @@ -0,0 +1,155 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.labeling.edgecorrection; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Map; +import java.util.Random; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +import net.imglib2.img.Img; +import net.imglib2.img.array.ArrayImgs; +import net.imglib2.roi.labeling.ImgLabeling; +import net.imglib2.type.numeric.integer.IntType; + +import org.junit.jupiter.api.Test; +import org.scijava.ops.image.AbstractOpTest; +import org.scijava.types.Nil; + +/** + * Tests {@link EdgeCorrection}. + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +public class EdgeCorrectionTest extends AbstractOpTest { + + /** Paints an axis-aligned box with the given label into an index image. */ + private static void box(final Img indexImg, + final int label, final long[] min, final long[] max) + { + final var c = indexImg.localizingCursor(); + while (c.hasNext()) { + c.fwd(); + var inside = true; + for (var d = 0; d < min.length; d++) { + final var p = c.getLongPosition(d); + if (p < min[d] || p > max[d]) inside = false; + } + if (inside) c.get().set(label); + } + } + + private static ImgLabeling labeling( + final Img indexImg, final int maxLabel) + { + return ImgLabeling.fromImageAndLabels(indexImg, IntStream.rangeClosed(1, + maxLabel).boxed().collect(Collectors.toList())); + } + + @Test + public void testWeights() { + final var index = ArrayImgs.ints(100, 50); + // Label 1 is a 10 x 5 box, label 2 touches the edge, and label 3 is a + // single pixel. + box(index, 1, new long[] { 10, 10 }, new long[] { 19, 14 }); + box(index, 2, new long[] { 0, 30 }, new long[] { 4, 34 }); + box(index, 3, new long[] { 50, 40 }, new long[] { 50, 40 }); + final var labeling = labeling(index, 3); + + final Map weights = ops.op( + "labeling.edgeCorrectionWeights").input(labeling).outType( + new Nil>() + {}).apply(); + assertEquals(100.0 * 50 / (90 * 45), weights.get(1), 1e-12); + assertEquals(0, weights.get(2), 0); + assertEquals(100.0 * 50 / (99 * 49), weights.get(3), 1e-12); + + final var count = ops.op("labeling.edgeCorrectedCount").input(labeling) + .outType(Double.class).apply(); + assertEquals(weights.get(1) + weights.get(3), count, 1e-12); + } + + @Test + public void testUnbiasedCount3D() { + // Place cubes of side 6 uniformly at random (by their minimum corner) in + // an extended region, so that each cube intersecting the 40^3 field of + // view is (partially) visible. The expected number of cubes whose + // corner lies inside the field of view is the true density times the + // field volume; the edge-corrected count of the fully visible cubes + // estimates it without bias. + final var random = new Random(1234); + final int size = 40, side = 6, cubes = 400; + final var index = ArrayImgs.ints(size, size, size); + long trueCount = 0; + int label = 0; + final var extent = size + 2 * side; + final var occupied = new boolean[extent][extent][extent]; + for (var i = 0; i < cubes; i++) { + // The corner lies in [-side + 1, size - 1], so every cube is visible. + final var min = new long[3]; + for (var d = 0; d < 3; d++) + min[d] = random.nextInt(size + side - 1) - side + 1; + // Keep cubes disjoint, so that each label is a whole cube. + final int ox = (int) min[0] + side, oy = (int) min[1] + side, + oz = (int) min[2] + side; + var free = true; + for (var x = 0; x < side && free; x++) + for (var y = 0; y < side && free; y++) + for (var z = 0; z < side && free; z++) + free = !occupied[ox + x][oy + y][oz + z]; + if (!free) continue; + for (var x = 0; x < side; x++) + for (var y = 0; y < side; y++) + for (var z = 0; z < side; z++) + occupied[ox + x][oy + y][oz + z] = true; + label++; + box(index, label, min, new long[] { min[0] + side - 1, min[1] + side - + 1, min[2] + side - 1 }); + if (min[0] >= 0 && min[1] >= 0 && min[2] >= 0) trueCount++; + } + final var labeling = labeling(index, label); + + final var count = ops.op("labeling.edgeCorrectedCount").input(labeling) + .outType(Double.class).apply(); + // Only interior cubes count, each with weight (40 / 34)^3, about 1.63. + final var interior = count / Math.pow(40.0 / 34, 3); + assertEquals(Math.rint(interior), interior, 1e-9); + // The corrected count is close to the number of cubes "belonging" to the + // field of view, and much closer than the naive interior count. + assertTrue(Math.abs(count - trueCount) < 0.25 * trueCount, "count=" + + count + ", true=" + trueCount); + assertTrue(Math.abs(count - trueCount) < Math.abs(interior - trueCount), + "count=" + count + ", interior=" + interior + ", true=" + trueCount); + } +} diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/labeling/featuremap/FeatureMapTest.java b/scijava-ops-image/src/test/java/org/scijava/ops/image/labeling/featuremap/FeatureMapTest.java new file mode 100644 index 000000000..28ecc6d19 --- /dev/null +++ b/scijava-ops-image/src/test/java/org/scijava/ops/image/labeling/featuremap/FeatureMapTest.java @@ -0,0 +1,90 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.labeling.featuremap; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.HashMap; +import java.util.Map; + +import net.imglib2.img.array.ArrayImgs; +import net.imglib2.roi.labeling.ImgLabeling; +import net.imglib2.roi.labeling.LabelRegions; +import net.imglib2.type.numeric.integer.IntType; + +import org.junit.jupiter.api.Test; +import org.scijava.ops.image.AbstractOpTest; + +/** + * Tests {@link DefaultFeatureMap}. + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +public class FeatureMapTest extends AbstractOpTest { + + @Test + public void testFeatureMap() { + final var labeling = new ImgLabeling(ArrayImgs.ints(6, 4, + 2)); + final var ra = labeling.randomAccess(); + ra.setPositionAndGet(0, 0, 0).add("a"); + ra.setPositionAndGet(1, 0, 0).add("a"); + ra.setPositionAndGet(5, 3, 1).add("b"); + ra.setPositionAndGet(2, 2, 1).add("c"); + // This pixel belongs to both a and b. + ra.setPositionAndGet(3, 3, 0).add("a"); + ra.get().add("b"); + + // Use each label's size as its feature. + final Map sizes = new HashMap<>(); + for (final var region : new LabelRegions<>(labeling)) + sizes.put(region.getLabel(), region.inside().size()); + sizes.remove("c"); + + final var out = ArrayImgs.doubles(6, 4, 2); + ops.op("labeling.featureMap").input(labeling, sizes, -1.0).output(out) + .compute(); + final var o = out.randomAccess(); + assertEquals(3, o.setPositionAndGet(0, 0, 0).get()); + assertEquals(3, o.setPositionAndGet(1, 0, 0).get()); + assertEquals(2, o.setPositionAndGet(5, 3, 1).get()); + assertEquals(2.5, o.setPositionAndGet(3, 3, 0).get()); + // Label c has no value, so it gets the background value. + assertEquals(-1, o.setPositionAndGet(2, 2, 1).get()); + assertEquals(-1, o.setPositionAndGet(4, 1, 0).get()); + + final var outDefault = ArrayImgs.floats(6, 4, 2); + ops.op("labeling.featureMap").input(labeling, sizes).output(outDefault) + .compute(); + assertEquals(0, outDefault.randomAccess().setPositionAndGet(4, 1, 0).get()); + assertEquals(3, outDefault.randomAccess().setPositionAndGet(0, 0, 0).get()); + } +} diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/labeling/measure/RegionMeasuresTest.java b/scijava-ops-image/src/test/java/org/scijava/ops/image/labeling/measure/RegionMeasuresTest.java new file mode 100644 index 000000000..8d08ed271 --- /dev/null +++ b/scijava-ops-image/src/test/java/org/scijava/ops/image/labeling/measure/RegionMeasuresTest.java @@ -0,0 +1,240 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.labeling.measure; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.Map; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +import net.imglib2.RealLocalizable; +import net.imglib2.img.Img; +import net.imglib2.img.array.ArrayImgs; +import net.imglib2.roi.geom.real.Sphere; +import net.imglib2.roi.labeling.ImgLabeling; +import net.imglib2.type.numeric.integer.IntType; +import net.imglib2.util.Pair; + +import org.junit.jupiter.api.Test; +import org.scijava.ops.image.AbstractOpTest; +import org.scijava.types.Nil; + +/** + * Tests {@link InscribedBalls}, {@link EquivalentEllipsoids} and + * {@link FeretDiameters}. + *

+ * Unless noted otherwise, expected values were produced by running + * MorphoLibJ 1.6.4 ({@code LargestInscribedCircle}, {@code EquivalentEllipse}, + * {@code EquivalentEllipsoid}, {@code MaxFeretDiameter3D}) on the same images. + * MorphoLibJ places pixel centers at half-integer positions for ellipses, + * whereas these ops place them at integer positions. + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +public class RegionMeasuresTest extends AbstractOpTest { + + private static final Nil> BALLS = new Nil<>() {}; + private static final Nil> ELLIPSOIDS = + new Nil<>() + {}; + private static final Nil> DIAMETERS = new Nil<>() {}; + private static final Nil>> FERETS = + new Nil<>() + {}; + + /** + * A disk of radius 5 at (8, 8) (1), a 6 x 3 rectangle (2), an ellipse with + * semi-axes 7 and 3 rotated by 0.5 radians around (22, 15) (3) and a single + * pixel (4). + */ + private static final String[] SHAPES = { // + "00000000000000000000000000000000", // + "00000000000000000000000000000000", // + "00000000000000000022222200000000", // + "00000000100000000022222200000000", // + "00000111111100000022222200000000", // + "00001111111110000000000000000000", // + "00001111111110000000000000000000", // + "00001111111110000000000000000000", // + "00011111111111000000000000000000", // + "00001111111110000000000000000000", // + "00001111111110000000000000000000", // + "00001111111110000333300000000000", // + "00000111111100003333333000000000", // + "00000000100000003333333330000000", // + "00000000000000000333333333000000", // + "00000000000000000033333333300000", // + "00000000000000000003333333330000", // + "00000000000000000000333333333000", // + "00000000000000000000003333333000", // + "00000000000000000000000033330000", // + "00400000000000000000000000000000", // + "00000000000000000000000000000000", // + "00000000000000000000000000000000", // + "00000000000000000000000000000000" }; + + private static ImgLabeling labeling(final Img index, + final int max) + { + return ImgLabeling.fromImageAndLabels(index, IntStream.rangeClosed(1, max) + .boxed().collect(Collectors.toList())); + } + + private static ImgLabeling shapes2D() { + final var index = ArrayImgs.ints(SHAPES[0].length(), SHAPES.length); + final var ra = index.randomAccess(); + for (var y = 0; y < SHAPES.length; y++) + for (var x = 0; x < SHAPES[y].length(); x++) + ra.setPositionAndGet(x, y).set(SHAPES[y].charAt(x) - '0'); + return labeling(index, 4); + } + + /** A 7 x 4 x 3 box (1), a ball of radius 4 (2) and a single voxel (3). */ + private static ImgLabeling shapes3D() { + final var index = ArrayImgs.ints(20, 13, 12); + final var ra = index.randomAccess(); + for (var z = 0; z < 12; z++) { + for (var y = 0; y < 13; y++) { + for (var x = 0; x < 20; x++) { + if (x >= 1 && x <= 7 && y >= 1 && y <= 4 && z >= 1 && z <= 3) { + ra.setPositionAndGet(x, y, z).set(1); + } + final var r2 = (x - 14) * (x - 14) + (y - 6) * (y - 6) + (z - 6) * + (z - 6); + if (r2 <= 16) ra.setPositionAndGet(x, y, z).set(2); + } + } + } + ra.setPositionAndGet(2, 10, 9).set(3); + return labeling(index, 3); + } + + private static void assertBall(final Sphere ball, final double radius, + final double... center) + { + assertEquals(radius, ball.radius(), 1e-9); + final var c = new double[center.length]; + ball.center().localize(c); + assertArrayEquals(center, c, 1e-9); + } + + @Test + public void testInscribedBall() { + final Map balls = ops.op("labeling.inscribedBall").input( + shapes2D()).outType(BALLS).apply(); + // Note: MorphoLibJ uses chamfer distances (giving radii 5 and 3 for the + // disk and the ellipse); these exact values come from a brute-force + // search for the nearest outside pixel center. + assertBall(balls.get(1), Math.sqrt(26), 8, 8); + assertBall(balls.get(2), 2, 19, 3); + assertBall(balls.get(3), Math.sqrt(10), 22, 15); + assertBall(balls.get(4), 1, 2, 20); + + // Analytic values. + final Map balls3 = ops.op("labeling.inscribedBall").input( + shapes3D(), new double[] { 1, 1, 0.5 }).outType(BALLS).apply(); + assertBall(balls3.get(1), 1, 1, 1, 1); + final Map balls3u = ops.op("labeling.inscribedBall") + .input(shapes3D()).outType(BALLS).apply(); + assertBall(balls3u.get(1), 2, 2, 2, 2); + assertBall(balls3u.get(2), Math.sqrt(17), 14, 6, 6); + assertBall(balls3u.get(3), 1, 2, 10, 9); + } + + @Test + public void testEquivalentEllipse() { + final Map e = ops.op( + "labeling.equivalentEllipsoid").input(shapes2D()).outType(ELLIPSOIDS) + .apply(); + assertArrayEquals(new double[] { 8, 8 }, e.get(1).center(), 1e-12); + assertArrayEquals(new double[] { 5.129195061148351, 5.129195061148351 }, e + .get(1).radii(), 1e-9); + assertArrayEquals(new double[] { 20.5, 3 }, e.get(2).center(), 1e-12); + assertArrayEquals(new double[] { 3.4641016151377544, 1.7320508075688772 }, + e.get(2).radii(), 1e-9); + assertArrayEquals(new double[] { 1, 0 }, e.get(2).axis(0), 1e-12); + assertArrayEquals(new double[] { 22, 15 }, e.get(3).center(), 1e-12); + assertArrayEquals(new double[] { 7.273360524644183, 2.9831563323807426 }, e + .get(3).radii(), 1e-9); + final var major = e.get(3).axis(0); + assertEquals(31.439348297920663, Math.toDegrees(Math.atan2(major[1], + major[0])), 1e-9); + assertArrayEquals(new double[] { 0.5773502691896258, 0.5773502691896258 }, + e.get(4).radii(), 1e-12); + } + + @Test + public void testEquivalentEllipsoid() { + final Map e = ops.op( + "labeling.equivalentEllipsoid").input(shapes3D()).outType(ELLIPSOIDS) + .apply(); + assertArrayEquals(new double[] { 4.51848057057532, 2.581988897471611, + 1.9364916731037085 }, e.get(1).radii(), 1e-9); + assertArrayEquals(new double[] { 4, 2.5, 2 }, e.get(1).center(), 1e-12); + assertArrayEquals(new double[] { 0, 0, 1 }, e.get(1).axis(2), 1e-12); + assertArrayEquals(new double[] { 3.987862584094877, 3.987862584094877, + 3.987862584094877 }, e.get(2).radii(), 1e-9); + assertArrayEquals(new double[] { 0.6454972243679028, 0.6454972243679028, + 0.6454972243679028 }, e.get(3).radii(), 1e-12); + // Anisotropic spacing scales the radii (analytic). + final Map c = ops.op( + "labeling.equivalentEllipsoid").input(shapes3D(), new double[] { 1, 1, + 4 }).outType(ELLIPSOIDS).apply(); + assertArrayEquals(new double[] { 4 * 1.9364916731037085, + 4.51848057057532, 2.581988897471611 }, c.get(1).radii(), 1e-9); + } + + @Test + public void testMaximumFeretsDiameter() { + final Map d = ops.op("labeling.maximumFeretsDiameter") + .input(shapes3D()).outType(DIAMETERS).apply(); + assertEquals(8.602325267042627, d.get(1), 1e-9); + assertEquals(9.1104335791443, d.get(2), 1e-9); + assertEquals(1.7320508075688774, d.get(3), 1e-9); + + final Map> f = ops.op( + "labeling.maximumFeret").input(shapes3D(), new double[] { 2, 1, 1 }) + .outType(FERETS).apply(); + final var a = f.get(1).getA().positionAsDoubleArray(); + final var b = f.get(1).getB().positionAsDoubleArray(); + assertArrayEquals(new double[] { 1, 0.5, 0.5 }, a, 1e-12); + assertArrayEquals(new double[] { 15, 4.5, 3.5 }, b, 1e-12); + + // 2D, analytic: the diagonal of the rectangle and of a pixel. + final Map d2 = ops.op("labeling.maximumFeretsDiameter") + .input(shapes2D()).outType(DIAMETERS).apply(); + assertEquals(Math.hypot(6, 3), d2.get(2), 1e-12); + assertEquals(Math.sqrt(2), d2.get(4), 1e-12); + } +} diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/labeling/nearestneighbor/NearestNeighborsTest.java b/scijava-ops-image/src/test/java/org/scijava/ops/image/labeling/nearestneighbor/NearestNeighborsTest.java new file mode 100644 index 000000000..5cc5fb576 --- /dev/null +++ b/scijava-ops-image/src/test/java/org/scijava/ops/image/labeling/nearestneighbor/NearestNeighborsTest.java @@ -0,0 +1,193 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.labeling.nearestneighbor; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Map; +import java.util.Random; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +import net.imglib2.img.Img; +import net.imglib2.img.array.ArrayImgs; +import net.imglib2.roi.labeling.ImgLabeling; +import net.imglib2.type.numeric.integer.IntType; + +import org.junit.jupiter.api.Test; +import org.scijava.ops.image.AbstractOpTest; +import org.scijava.types.Nil; + +/** + * Tests {@link NearestNeighbors}. + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +public class NearestNeighborsTest extends AbstractOpTest { + + private static final Nil> DISTANCES = new Nil<>() {}; + private static final Nil> STATS = new Nil<>() {}; + + private static ImgLabeling labeling( + final Img indexImg, final int maxLabel) + { + return ImgLabeling.fromImageAndLabels(indexImg, IntStream.rangeClosed(1, + maxLabel).boxed().collect(Collectors.toList())); + } + + /** Creates one single-pixel label per grid node, with the given spacing. */ + private static ImgLabeling grid(final int spacing, + final int count) + { + final var index = ArrayImgs.ints(spacing * count, spacing * count); + final var ra = index.randomAccess(); + var label = 0; + for (var y = 0; y < count; y++) + for (var x = 0; x < count; x++) + ra.setPositionAndGet(x * spacing + spacing / 2, y * spacing + + spacing / 2).set(++label); + return labeling(index, label); + } + + /** Places single-pixel labels at random positions. */ + private static ImgLabeling randomPoints( + final Random random, final int size, final int count, final int cluster) + { + final var index = ArrayImgs.ints(size, size); + final var ra = index.randomAccess(); + var label = 0; + while (label < count) { + final long x, y; + if (cluster > 0) { + // Points cluster in a cluster x cluster square at the center. + x = (size - cluster) / 2 + random.nextInt(cluster); + y = (size - cluster) / 2 + random.nextInt(cluster); + } + else { + x = random.nextInt(size); + y = random.nextInt(size); + } + final var t = ra.setPositionAndGet(x, y); + if (t.get() == 0) t.set(++label); + } + return labeling(index, label); + } + + @Test + public void testDistances() { + final var index = ArrayImgs.ints(30, 20); + final var ra = index.randomAccess(); + // Label 1 is a 3 x 3 square centered at (5, 5). + for (var x = 4; x <= 6; x++) + for (var y = 4; y <= 6; y++) + ra.setPositionAndGet(x, y).set(1); + ra.setPositionAndGet(8, 9).set(2); + ra.setPositionAndGet(25, 9).set(3); + final var distances = ops.op("labeling.nearestNeighborDistances").input( + labeling(index, 3)).outType(DISTANCES).apply(); + assertEquals(5, distances.get(1), 1e-12); + assertEquals(5, distances.get(2), 1e-12); + assertEquals(17, distances.get(3), 1e-12); + + // With a calibration, centroids are compared in calibrated units. + final var calibrated = ops.op("labeling.nearestNeighborDistances").input( + labeling(index, 3), new double[] { 2, 0.5 }).outType(DISTANCES).apply(); + assertEquals(Math.sqrt(40), calibrated.get(1), 1e-12); + assertEquals(Math.sqrt(40), calibrated.get(2), 1e-12); + assertEquals(34, calibrated.get(3), 1e-12); + } + + @Test + public void testRegularGrid() { + // On a square grid with spacing a, every distance is a and the density + // is 1 / a^2, so the expected random distance is a / 2 and R = 2. + final var stats = ops.op("labeling.clarkEvans").input(grid(10, 10)) + .outType(STATS).apply(); + assertEquals(100, stats.get("n"), 0); + assertEquals(10, stats.get("observedMeanDistance"), 1e-12); + assertEquals(5, stats.get("expectedMeanDistance"), 1e-12); + assertEquals(2, stats.get("R"), 1e-12); + assertEquals(0.26136 * 10 / Math.sqrt(100), stats.get("standardError"), + 1e-5); + assertTrue(stats.get("z") > 10); + assertTrue(stats.get("p") < 1e-10); + } + + @Test + public void testExplicitVolume() { + // Quadrupling the area doubles the expected distance and halves R. + final var stats = ops.op("labeling.clarkEvans").input(grid(10, 10), + 40000.0).outType(STATS).apply(); + assertEquals(10, stats.get("expectedMeanDistance"), 1e-12); + assertEquals(1, stats.get("R"), 1e-12); + assertEquals(0, stats.get("z"), 1e-12); + assertEquals(1, stats.get("p"), 1e-12); + } + + @Test + public void testCalibration() { + // Doubling the spacing doubles all distances, and the default volume is + // calibrated too, so R is unchanged. + final var stats = ops.op("labeling.clarkEvans").input(grid(10, 10), null, + new double[] { 2, 2 }).outType(STATS).apply(); + assertEquals(20, stats.get("observedMeanDistance"), 1e-12); + assertEquals(10, stats.get("expectedMeanDistance"), 1e-12); + assertEquals(2, stats.get("R"), 1e-12); + } + + @Test + public void testRandomAndClustered() { + final var random = new Random(0xC1A4C); + final var randomStats = ops.op("labeling.clarkEvans").input(randomPoints( + random, 500, 500, 0)).outType(STATS).apply(); + assertEquals(1, randomStats.get("R"), 0.1); + final var clusteredStats = ops.op("labeling.clarkEvans").input( + randomPoints(random, 500, 500, 100)).outType(STATS).apply(); + assertTrue(clusteredStats.get("R") < 0.3); + assertTrue(clusteredStats.get("p") < 1e-10); + } + + @Test + public void test3DExpectedDistance() { + // In 3D, the expected distance for density lambda is + // Gamma(4/3) / (4 pi lambda / 3)^(1/3) ~= 0.55396 lambda^(-1/3). + final var index = ArrayImgs.ints(20, 20, 20); + index.randomAccess().setPositionAndGet(2, 2, 2).set(1); + index.randomAccess().setPositionAndGet(12, 2, 2).set(2); + final var stats = ops.op("labeling.clarkEvans").input(labeling(index, 2)) + .outType(STATS).apply(); + final var lambda = 2.0 / 8000; + assertEquals(0.55396 * Math.pow(lambda, -1.0 / 3), stats.get( + "expectedMeanDistance"), 1e-3); + assertEquals(10, stats.get("observedMeanDistance"), 1e-12); + } +} diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/labeling/overlap/OverlapMeasuresTest.java b/scijava-ops-image/src/test/java/org/scijava/ops/image/labeling/overlap/OverlapMeasuresTest.java new file mode 100644 index 000000000..f28e05b5e --- /dev/null +++ b/scijava-ops-image/src/test/java/org/scijava/ops/image/labeling/overlap/OverlapMeasuresTest.java @@ -0,0 +1,147 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.labeling.overlap; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.Map; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +import net.imglib2.img.array.ArrayImgs; +import net.imglib2.roi.labeling.ImgLabeling; +import net.imglib2.type.numeric.integer.IntType; + +import org.junit.jupiter.api.Test; +import org.scijava.ops.image.AbstractOpTest; +import org.scijava.types.Nil; + +/** + * Tests {@link OverlapMeasures}. + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +public class OverlapMeasuresTest extends AbstractOpTest { + + private static final Nil TOTAL = new Nil<>() {}; + private static final Nil> BY_LABEL = new Nil<>() {}; + + private static final String[] SOURCE = { // + "1112200", // + "1112200", // + "1100233", // + "0003333", // + "4400333" }; + private static final String[] TARGET = { // + "1111220", // + "1102220", // + "1102200", // + "0033330", // + "0000333" }; + + private static ImgLabeling labeling(final String... rows) { + final var index = ArrayImgs.ints(rows[0].length(), rows.length); + final var ra = index.randomAccess(); + for (var y = 0; y < rows.length; y++) + for (var x = 0; x < rows[y].length(); x++) + ra.setPositionAndGet(x, y).set(rows[y].charAt(x) - '0'); + return ImgLabeling.fromImageAndLabels(index, IntStream.rangeClosed(1, 9) + .boxed().collect(Collectors.toList())); + } + + private double total(final String name, final String[] s, + final String[] t) + { + return ops.op("labeling.overlap." + name).input(labeling(s), labeling(t)) + .outType(TOTAL).apply(); + } + + private Map byLabel(final String name) { + return ops.op("labeling.overlap." + name + "ByLabel").input(labeling( + SOURCE), labeling(TARGET)).outType(BY_LABEL).apply(); + } + + /** + * Compares with the label overlap measures of MorphoLibJ 1.6.4 + * ({@code LabelImages.getJaccardIndex} etc.), run on the same images. + * Label 4 occurs only in the source; for it, MorphoLibJ reports 0 rather + * than the undefined (NaN) target overlap and false negative error, and 0 + * rather than 2 for the volume similarity, so it is checked separately. + */ + @Test + public void testRegression() { + assertEquals(0.7727272727272727, total("targetOverlap", SOURCE, TARGET), + 1e-12); + assertEquals(0.5862068965517241, total("jaccard", SOURCE, TARGET), 1e-12); + assertEquals(0.7391304347826086, total("dice", SOURCE, TARGET), 1e-12); + assertEquals(0.08695652173913043, total("volumeSimilarity", SOURCE, + TARGET), 1e-12); + assertEquals(0.2272727272727273, total("falseNegativeError", SOURCE, + TARGET), 1e-12); + assertEquals(0.2916666666666667, total("falsePositiveError", SOURCE, + TARGET), 1e-12); + + assertByLabel(byLabel("targetOverlap"), 0.875, 0.5714285714285714, + 0.8571428571428571, Double.NaN); + assertByLabel(byLabel("jaccard"), 0.7777777777777778, 0.5, 0.6, 0); + assertByLabel(byLabel("dice"), 0.875, 0.6666666666666666, 0.75, 0); + assertByLabel(byLabel("volumeSimilarity"), 0, -0.3333333333333333, 0.25, + 2); + assertByLabel(byLabel("falseNegativeError"), 0.125, 0.4285714285714286, + 0.1428571428571429, Double.NaN); + assertByLabel(byLabel("falsePositiveError"), 0.125, 0.2, + 0.3333333333333333, 1); + } + + private static void assertByLabel(final Map actual, + final double... expected) + { + assertEquals(expected.length, actual.size()); + for (var i = 0; i < expected.length; i++) + assertEquals(expected[i], actual.get(i + 1), 1e-12); + } + + @Test + public void testIdenticalAndDisjoint() { + final String[] a = { "1100", "1122" }; + final String[] b = { "0011", "2200" }; + assertEquals(1, total("jaccard", a, a)); + assertEquals(1, total("dice", a, a)); + assertEquals(0, total("volumeSimilarity", a, a)); + assertEquals(0, total("falseNegativeError", a, a)); + assertEquals(0, total("falsePositiveError", a, a)); + assertEquals(0, total("jaccard", a, b)); + assertEquals(0, total("dice", a, b)); + assertEquals(0, total("targetOverlap", a, b)); + assertEquals(1, total("falseNegativeError", a, b)); + assertEquals(1, total("falsePositiveError", a, b)); + } +} diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/labeling/shapeBasedAverage/ShapeBasedAverageTest.java b/scijava-ops-image/src/test/java/org/scijava/ops/image/labeling/shapeBasedAverage/ShapeBasedAverageTest.java new file mode 100644 index 000000000..06d15b51c --- /dev/null +++ b/scijava-ops-image/src/test/java/org/scijava/ops/image/labeling/shapeBasedAverage/ShapeBasedAverageTest.java @@ -0,0 +1,185 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.labeling.shapeBasedAverage; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.List; +import java.util.Random; + +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.img.array.ArrayImgs; +import net.imglib2.type.numeric.integer.UnsignedByteType; +import net.imglib2.view.Views; + +import org.junit.jupiter.api.Test; +import org.scijava.ops.image.AbstractOpTest; +import org.scijava.types.Nil; + +/** + * Tests {@link ShapeBasedAverage}. + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +public class ShapeBasedAverageTest extends AbstractOpTest { + + private static final Nil>> IN = + new Nil<>() + {}; + + private static final Nil> OUT = + new Nil<>() + {}; + + /** Averaging copies of the same labeling returns it. */ + @Test + public void testIdentical() { + final var rnd = new Random(1); + final var img = ArrayImgs.unsignedBytes(15, 12); + img.forEach(t -> t.set(rnd.nextInt(4))); + final var out = average(List.of(img, img, img)); + final var a = img.cursor(); + final var b = out.cursor(); + while (a.hasNext()) + assertEquals(a.next().get(), b.next().get()); + } + + /** Two half-plane labelings average to the mean boundary. */ + @Test + public void testHalfPlanes() { + final var a = halves(10); + final var b = halves(14); + final var out = average(List.of(a, b)); + final var c = out.localizingCursor(); + while (c.hasNext()) { + c.fwd(); + assertEquals(c.getIntPosition(0) < 12 ? 1 : 2, c.get().get()); + } + } + + /** + * Two disks of radius 6 shifted by ±4 pixels average to a symmetric lens + * around the midpoint: with mean signed distance + * {@code (|p - c1| + |p - c2|) / 2 - 6}, it extends 6 pixels along the + * shift and {@code sqrt(20) ≈ 4.5} pixels across it. + */ + @Test + public void testShiftedDisks() { + final var a = disk(12, 16, 6); + final var b = disk(20, 16, 6); + final var out = average(List.of(a, b)); + final var ra = out.randomAccess(); + assertEquals(1, ra.setPositionAndGet(16, 16).get()); + assertEquals(1, ra.setPositionAndGet(21, 16).get()); + assertEquals(1, ra.setPositionAndGet(11, 16).get()); + assertEquals(0, ra.setPositionAndGet(23, 16).get()); + assertEquals(0, ra.setPositionAndGet(9, 16).get()); + assertEquals(1, ra.setPositionAndGet(16, 20).get()); + assertEquals(1, ra.setPositionAndGet(16, 12).get()); + assertEquals(0, ra.setPositionAndGet(16, 22).get()); + assertEquals(0, ra.setPositionAndGet(16, 10).get()); + for (var x = 0; x < 32; x++) { + for (var y = 0; y < 32; y++) { + assertEquals(ra.setPositionAndGet(x, y).get(), ra.setPositionAndGet( + 32 - x, y).get(), x + "," + y); + assertEquals(ra.setPositionAndGet(x, y).get(), ra.setPositionAndGet(x, + 32 - y).get(), x + "," + y); + } + } + } + + /** + * The distances to a boundary across dimension 0 scale with the spacing + * along dimension 0 only, and equally for all labels, so a calibration does + * not move the mean boundary of two half planes. + */ + @Test + public void testCalibration() { + final var a = halves(10); + final var b = halves(14); + final var out = ops.op("labeling.shapeBasedAverage").inType(IN, Nil.of( + double[].class)).outType(OUT).function().apply(List.of(a, b), + new double[] { 2, 0.5 }); + final var c = out.localizingCursor(); + while (c.hasNext()) { + c.fwd(); + assertEquals(c.getIntPosition(0) < 12 ? 1 : 2, c.get().get()); + } + } + + /** Translated inputs give the translated result. */ + @Test + public void testNonZeroMin() { + final var a = disk(12, 16, 6); + final var b = disk(20, 16, 6); + final var expected = average(List.of(a, b)); + final var actual = average(List.of(Views.translate(a, 7, -5), Views + .translate(b, 7, -5))); + assertArrayEquals(new long[] { 7, -5 }, actual.minAsLongArray()); + final var e = Views.flatIterable(expected).cursor(); + for (final var t : Views.flatIterable(actual)) + assertEquals(e.next().get(), t.get()); + } + + private RandomAccessibleInterval average( + final List> inputs) + { + return ops.op("labeling.shapeBasedAverage").inType(IN).outType(OUT) + .function().apply(inputs); + } + + private static RandomAccessibleInterval halves( + final int boundary) + { + final var img = ArrayImgs.unsignedBytes(24, 6); + final var c = img.localizingCursor(); + while (c.hasNext()) { + c.fwd(); + c.get().set(c.getIntPosition(0) < boundary ? 1 : 2); + } + return img; + } + + private static RandomAccessibleInterval disk( + final double cx, final double cy, final double r) + { + final var img = ArrayImgs.unsignedBytes(33, 33); + final var c = img.localizingCursor(); + while (c.hasNext()) { + c.fwd(); + final var dx = c.getDoublePosition(0) - cx; + final var dy = c.getDoublePosition(1) - cy; + c.get().set(dx * dx + dy * dy <= r * r ? 1 : 0); + } + return img; + } +} diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/labeling/utils/LabelEditingTest.java b/scijava-ops-image/src/test/java/org/scijava/ops/image/labeling/utils/LabelEditingTest.java new file mode 100644 index 000000000..7165dcb13 --- /dev/null +++ b/scijava-ops-image/src/test/java/org/scijava/ops/image/labeling/utils/LabelEditingTest.java @@ -0,0 +1,219 @@ +/* #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.labeling.utils; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.scijava.ops.image.labeling.utils.LabelFilteringTest.paint; +import static org.scijava.ops.image.labeling.utils.LabelFilteringTest.sizes; + +import java.util.List; +import java.util.Map; +import java.util.Set; + +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.algorithm.neighborhood.DiamondShape; +import net.imglib2.algorithm.neighborhood.RectangleShape; +import net.imglib2.img.array.ArrayImgs; +import net.imglib2.roi.labeling.ImgLabeling; +import net.imglib2.type.logic.BitType; +import net.imglib2.type.numeric.integer.IntType; + +import org.junit.jupiter.api.Test; +import org.scijava.ops.image.AbstractOpTest; +import org.scijava.types.Nil; + +/** + * Tests {@link LabelEditing}. + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +public class LabelEditingTest extends AbstractOpTest { + + private static final Nil> LABELING = + new Nil<>() + {}; + + @Test + public void testRelabel() { + final var labeling = new ImgLabeling(ArrayImgs.ints(6, + 2)); + paint(labeling, 9, 0, 0, 0, 0); + paint(labeling, 5, 1, 0, 2, 0); + paint(labeling, 2, 3, 0, 5, 1); + paint(labeling, 9, 5, 1, 5, 1); + final ImgLabeling relabeled = ops.op("labeling.relabel") + .input(labeling).outType(LABELING).apply(); + // Numeric labels are renumbered in their natural order. + assertEquals(Map.of(1, 6L, 2, 2L, 3, 2L), sizes(relabeled)); + assertEquals(Set.of(1, 3), relabeled.randomAccess().setPositionAndGet(5, + 1)); + final ImgLabeling fromZero = ops.op("labeling.relabel") + .input(labeling, 0).outType(LABELING).apply(); + assertEquals(Map.of(0, 6L, 1, 2L, 2, 2L), sizes(fromZero)); + } + + @Test + public void testRelabelRasterOrder() { + // Labels without a natural ordering are numbered in raster order. + final var labeling = new ImgLabeling(ArrayImgs.ints(3, + 2)); + final var a = new Object(); + final var b = new Object(); + paint(labeling, b, 1, 0, 1, 0); + paint(labeling, a, 0, 1, 2, 1); + final ImgLabeling relabeled = ops.op("labeling.relabel") + .input(labeling).outType(LABELING).apply(); + assertEquals(Map.of(1, 1L, 2, 3L), sizes(relabeled)); + assertEquals(Set.of(1), relabeled.randomAccess().setPositionAndGet(1, 0)); + } + + @Test + public void testMergeLabels() { + final var labeling = new ImgLabeling(ArrayImgs.ints(5, + 5)); + paint(labeling, 3, 0, 0, 1, 1); + paint(labeling, 4, 3, 3, 4, 4); + paint(labeling, 7, 1, 1, 2, 2); + paint(labeling, 8, 0, 4, 0, 4); + final ImgLabeling merged = ops.op("labeling.mergeLabels") + .input(labeling, List.of(7, 4, 3)).outType(LABELING).apply(); + // Label 3 overlaps label 7 at (1, 1): the union has 4 + 4 + 4 - 1 pixels. + assertEquals(Map.of(3, 11L, 8, 1L), sizes(merged)); + final ImgLabeling target = ops.op("labeling.mergeLabels") + .input(labeling, List.of(4, 8), 42).outType(LABELING).apply(); + assertEquals(Map.of(3, 4L, 7, 4L, 42, 5L), sizes(target)); + } + + @Test + public void testFillHoles() { + final var labeling = new ImgLabeling(ArrayImgs.ints(12, + 7)); + // A ring with a 3x3 hole. + paint(labeling, 1, 0, 0, 4, 4); + removeBox(labeling, 1, 1, 3, 3); + // Two half rings of different labels around a 1x3 hole. + paint(labeling, 2, 6, 0, 7, 4); + paint(labeling, 3, 8, 0, 8, 4); + removeBox(labeling, 7, 1, 7, 3); + // A C shape open to the border. + paint(labeling, 4, 9, 0, 11, 4); + removeBox(labeling, 10, 1, 11, 3); + + final ImgLabeling filled = ops.op("labeling.fillHoles") + .input(labeling).outType(LABELING).apply(); + assertEquals(Map.of(1, 25L, 2, 7L, 3, 5L, 4, 9L), sizes(filled)); + assertEquals(Set.of(1), filled.randomAccess().setPositionAndGet(2, 2)); + assertTrue(filled.randomAccess().setPositionAndGet(7, 2).isEmpty()); + } + + @Test + public void testFillHolesConnectivity() { + // A diamond outline encloses a hole only for 4-connected background. + final var labeling = new ImgLabeling(ArrayImgs.ints(5, + 5)); + final var ra = labeling.randomAccess(); + for (final var p : new int[][] { { 2, 0 }, { 1, 1 }, { 3, 1 }, { 0, 2 }, { + 4, 2 }, { 1, 3 }, { 3, 3 }, { 2, 4 } }) + { + ra.setPositionAndGet(p[0], p[1]).add(1); + } + final ImgLabeling four = ops.op("labeling.fillHoles") + .input(labeling, new DiamondShape(1)).outType(LABELING) + .apply(); + assertEquals(Map.of(1, 13L), sizes(four)); + final ImgLabeling eight = ops.op("labeling.fillHoles") + .input(labeling, new RectangleShape(1, false)).outType(LABELING) + .apply(); + assertEquals(Map.of(1, 8L), sizes(eight)); + // The default is full connectivity, so the outline is not filled. + final ImgLabeling defaults = ops.op("labeling.fillHoles") + .input(labeling).outType(LABELING).apply(); + assertEquals(Map.of(1, 8L), sizes(defaults)); + } + + @Test + public void testFillHoles3D() { + final var labeling = new ImgLabeling(ArrayImgs.ints(5, 5, + 5)); + final var ra = labeling.randomAccess(); + for (var z = 0; z < 5; z++) + for (var y = 0; y < 5; y++) + for (var x = 0; x < 5; x++) { + final var shell = x == 0 || x == 4 || y == 0 || y == 4 || z == 0 || + z == 4; + if (shell) ra.setPositionAndGet(x, y, z).add("cube"); + } + final ImgLabeling filled = ops.op("labeling.fillHoles") + .input(labeling).outType(new Nil>() + {}).apply(); + assertEquals(Map.of("cube", 125L), sizes(filled)); + } + + @Test + public void testCropLabel() { + final var labeling = new ImgLabeling(ArrayImgs.ints(6, + 6)); + paint(labeling, 1, 1, 2, 3, 3); + paint(labeling, 2, 3, 3, 5, 5); + final RandomAccessibleInterval crop = ops.op("labeling.cropLabel") + .input(labeling, 1).outType(new Nil>() + {}).apply(); + assertArrayEquals(new long[] { 1, 2 }, crop.minAsLongArray()); + assertArrayEquals(new long[] { 3, 3 }, crop.maxAsLongArray()); + for (final var b : crop) + assertTrue(b.get()); + + final RandomAccessibleInterval padded = ops.op( + "labeling.cropLabel").input(labeling, 2, 1L).outType( + new Nil>() + {}).apply(); + assertArrayEquals(new long[] { 2, 2 }, padded.minAsLongArray()); + assertArrayEquals(new long[] { 6, 6 }, padded.maxAsLongArray()); + var count = 0; + for (final var b : padded) + if (b.get()) count++; + assertEquals(9, count); + final var p = padded.randomAccess(); + assertTrue(p.setPositionAndGet(3, 3).get()); + assertTrue(!p.setPositionAndGet(2, 2).get()); + assertTrue(!p.setPositionAndGet(6, 6).get()); + } + + private static void removeBox(final ImgLabeling labeling, + final long x0, final long y0, final long x1, final long y1) + { + final var ra = labeling.randomAccess(); + for (var y = y0; y <= y1; y++) + for (var x = x0; x <= x1; x++) + ra.setPositionAndGet(x, y).clear(); + } +} diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/labeling/utils/LabelFilteringTest.java b/scijava-ops-image/src/test/java/org/scijava/ops/image/labeling/utils/LabelFilteringTest.java new file mode 100644 index 000000000..d4b7feaa3 --- /dev/null +++ b/scijava-ops-image/src/test/java/org/scijava/ops/image/labeling/utils/LabelFilteringTest.java @@ -0,0 +1,250 @@ +/* #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.labeling.utils; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Predicate; + +import net.imglib2.img.array.ArrayImgs; +import net.imglib2.roi.labeling.ImgLabeling; +import net.imglib2.roi.labeling.LabelRegions; +import net.imglib2.type.numeric.integer.IntType; +import net.imglib2.type.numeric.integer.UnsignedByteType; + +import org.junit.jupiter.api.Test; +import org.scijava.ops.image.AbstractOpTest; +import org.scijava.types.Nil; + +/** + * Tests {@link LabelFiltering}. + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +public class LabelFilteringTest extends AbstractOpTest { + + /** + * Creates a 10x10 labeling: label 1 is a 2x2 box at (0,0) touching the + * minimum borders, label 2 a 3x3 box at (4,4), label 3 a single pixel at + * (9,5) touching the maximum x border, and label 4 a 2x1 box at (5,9) + * touching the maximum y border. + */ + static ImgLabeling boxes() { + final var labeling = new ImgLabeling(ArrayImgs.ints(10, + 10)); + paint(labeling, 1, 0, 0, 1, 1); + paint(labeling, 2, 4, 4, 6, 6); + paint(labeling, 3, 9, 5, 9, 5); + paint(labeling, 4, 5, 9, 6, 9); + return labeling; + } + + static void paint(final ImgLabeling labeling, final L label, + final long x0, final long y0, final long x1, final long y1) + { + final var ra = labeling.randomAccess(); + for (var y = y0; y <= y1; y++) + for (var x = x0; x <= x1; x++) + ra.setPositionAndGet(x, y).add(label); + } + + static Map sizes(final ImgLabeling labeling) { + final var sizes = new HashMap(); + for (final var region : new LabelRegions<>(labeling)) { + final var size = region.inside().size(); + if (size > 0) sizes.put(region.getLabel(), size); + } + return sizes; + } + + static Set labels(final ImgLabeling labeling) { + return sizes(labeling).keySet(); + } + + @SafeVarargs + static Set set(final L... labels) { + return new HashSet<>(Arrays.asList(labels)); + } + + private static final Nil> LABELING = + new Nil<>() + {}; + + private ImgLabeling apply(final String name, + final Object in1) + { + return ops.op(name).input(in1).outType(LABELING).apply(); + } + + private ImgLabeling apply(final String name, + final Object in1, final Object in2) + { + return ops.op(name).input(in1, in2).outType(LABELING).apply(); + } + + private ImgLabeling apply(final String name, + final Object in1, final Object in2, final Object in3) + { + return ops.op(name).input(in1, in2, in3).outType(LABELING).apply(); + } + + private ImgLabeling apply(final String name, + final Object in1, final Object in2, final Object in3, final Object in4) + { + return ops.op(name).input(in1, in2, in3, in4).outType(LABELING).apply(); + } + + @Test + public void testFilterBySize() { + final var labeling = boxes(); + assertEquals(set(1, 2), labels(apply("labeling.filterBySize", labeling, + 3L))); + assertEquals(set(1, 3, 4), labels(apply("labeling.filterBySize", labeling, + 0L, 4L))); + assertEquals(set(1, 4), labels(apply("labeling.filterBySize", labeling, 2L, + 4L))); + final var all = apply("labeling.filterBySize", labeling); + assertEquals(sizes(labeling), sizes(all)); + // The input is not modified. + assertEquals(set(1, 2, 3, 4), labels(labeling)); + } + + @Test + public void testKeepRemoveLargest() { + final var labeling = boxes(); + assertEquals(set(2), labels(apply("labeling.keepLargest", labeling))); + assertEquals(set(1, 3, 4), labels(apply("labeling.removeLargest", + labeling))); + // Ties are all kept or removed. + paint(labeling, 5, 0, 7, 2, 9); + assertEquals(set(2, 5), labels(apply("labeling.keepLargest", labeling))); + assertEquals(set(1, 3, 4), labels(apply("labeling.removeLargest", + labeling))); + } + + @Test + public void testKeepRemoveLabels() { + final var labeling = boxes(); + // Label 5 overlaps label 2 in one pixel. + paint(labeling, 5, 6, 6, 7, 7); + final var kept = apply("labeling.keepLabels", labeling, List.of(2, 3, 42)); + assertEquals(Map.of(2, 9L, 3, 1L), sizes(kept)); + final var removed = apply("labeling.removeLabels", labeling, List.of(2)); + assertEquals(Map.of(1, 4L, 3, 1L, 4, 2L, 5, 4L), sizes(removed)); + assertEquals(Set.of(5), removed.randomAccess().setPositionAndGet(6, 6)); + } + + @Test + public void testFilter() { + final var labeling = boxes(); + final var even = new Predicate() { + + @Override + public boolean test(final Integer label) { + return label % 2 == 0; + } + }; + assertEquals(set(2, 4), labels(apply("labeling.filter", labeling, even))); + } + + @Test + public void testFilterByValue() { + final var labeling = boxes(); + final Map values = Map.of(1, 0.5, 2, 1.5, 3, 2.5); + assertEquals(set(2, 3), labels(apply("labeling.filterByValue", labeling, + values, 1.0))); + assertEquals(set(1, 2), labels(apply("labeling.filterByValue", labeling, + values, Double.NEGATIVE_INFINITY, 2.0))); + // Label 4 has no value, so it is removed. + assertEquals(set(1, 2, 3), labels(apply("labeling.filterByValue", labeling, + values))); + } + + @Test + public void testRemoveBorderLabels() { + final var labeling = boxes(); + assertEquals(set(2), labels(apply("labeling.removeBorderLabels", + labeling))); + // Per side: min x, max x, min y, max y. + assertEquals(set(2, 3, 4), labels(apply("labeling.removeBorderLabels", + labeling, new boolean[] { true, false, false, false }))); + assertEquals(set(1, 2, 3), labels(apply("labeling.removeBorderLabels", + labeling, new boolean[] { false, false, false, true }))); + // Per dimension: both x borders. + assertEquals(set(2, 4), labels(apply("labeling.removeBorderLabels", + labeling, new boolean[] { true, false }))); + } + + @Test + public void testRemoveBorderLabels3D() { + final var labeling = new ImgLabeling(ArrayImgs + .unsignedBytes(5, 5, 5)); + final var ra = labeling.randomAccess(); + ra.setPositionAndGet(2, 2, 2).add("inner"); + ra.setPositionAndGet(2, 2, 4).add("top"); + ra.setPositionAndGet(2, 2, 3).add("top"); + ra.get().add("inner"); + final ImgLabeling result = ops.op( + "labeling.removeBorderLabels").input(labeling).outType( + new Nil>() + {}).apply(); + assertEquals(Map.of("inner", 2L), sizes(result)); + final ImgLabeling xyOnly = ops.op( + "labeling.removeBorderLabels").input(labeling, new boolean[] { true, + true, false }).outType(new Nil>() + {}).apply(); + assertEquals(Map.of("inner", 2L, "top", 2L), sizes(xyOnly)); + } + + @Test + public void testSelectByOverlap() { + final var labeling = boxes(); + final var mask = ArrayImgs.bits(10, 10); + final var m = mask.randomAccess(); + // One pixel of label 1 (1/4), three of label 2 (1/3), all of label 3. + m.setPositionAndGet(1, 1).set(true); + for (var x = 4; x <= 6; x++) + m.setPositionAndGet(x, 4).set(true); + m.setPositionAndGet(9, 5).set(true); + m.setPositionAndGet(8, 8).set(true); + assertEquals(set(1, 2, 3), labels(apply("labeling.selectByOverlap", + labeling, mask))); + assertEquals(set(2, 3), labels(apply("labeling.selectByOverlap", labeling, + mask, 0.3))); + assertEquals(set(3), labels(apply("labeling.selectByOverlap", labeling, + mask, 1.0))); + } +} diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/labeling/utils/LabelTopologyTest.java b/scijava-ops-image/src/test/java/org/scijava/ops/image/labeling/utils/LabelTopologyTest.java new file mode 100644 index 000000000..f227f6688 --- /dev/null +++ b/scijava-ops-image/src/test/java/org/scijava/ops/image/labeling/utils/LabelTopologyTest.java @@ -0,0 +1,147 @@ +/* #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.labeling.utils; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.scijava.ops.image.labeling.utils.LabelFilteringTest.paint; +import static org.scijava.ops.image.labeling.utils.LabelFilteringTest.set; + +import java.util.Map; +import java.util.Set; + +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.algorithm.neighborhood.DiamondShape; +import net.imglib2.algorithm.neighborhood.RectangleShape; +import net.imglib2.img.array.ArrayImgs; +import net.imglib2.roi.labeling.ImgLabeling; +import net.imglib2.type.logic.BitType; +import net.imglib2.type.numeric.integer.IntType; + +import org.junit.jupiter.api.Test; +import org.scijava.ops.image.AbstractOpTest; +import org.scijava.types.Nil; + +/** + * Tests {@link LabelTopology}. + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +public class LabelTopologyTest extends AbstractOpTest { + + private static final Nil>> GRAPH = new Nil<>() {}; + + private static final Nil> MASK = + new Nil<>() + {}; + + /** A 6x6 labeling of a 3x3 grid of 2x2 blocks, labeled 0 to 8. */ + private static ImgLabeling grid() { + final var labeling = new ImgLabeling(ArrayImgs.ints(6, + 6)); + for (var j = 0; j < 3; j++) + for (var i = 0; i < 3; i++) + paint(labeling, 3 * j + i, 2 * i, 2 * j, 2 * i + 1, 2 * j + 1); + return labeling; + } + + @Test + public void testBoundaries() { + final var labeling = new ImgLabeling(ArrayImgs.ints(7, + 7)); + paint(labeling, 1, 1, 1, 5, 5); + final RandomAccessibleInterval boundaries = ops.op( + "labeling.boundaries").input(labeling).outType(MASK).apply(); + // The perimeter of the 5x5 square. + assertEquals(16, count(boundaries)); + final var b = boundaries.randomAccess(); + assertEquals(true, b.setPositionAndGet(1, 3).get()); + assertEquals(false, b.setPositionAndGet(2, 3).get()); + assertEquals(false, b.setPositionAndGet(0, 3).get()); + + // Touching regions: both sides of the shared edge are boundaries. + final var g = grid(); + final RandomAccessibleInterval all = ops.op("labeling.boundaries") + .input(g, new DiamondShape(1)).outType(MASK).apply(); + // All pixels except the corners of the field of view. + assertEquals(32, count(all)); + + // Pixel (0, 0) differs from a neighbor only diagonally. + final var corner = new ImgLabeling(ArrayImgs.ints(2, 2)); + paint(corner, 1, 0, 0, 1, 0); + paint(corner, 1, 0, 1, 0, 1); + paint(corner, 2, 1, 1, 1, 1); + final RandomAccessibleInterval face = ops.op("labeling.boundaries") + .input(corner, new DiamondShape(1)).outType(MASK).apply(); + assertEquals(3, count(face)); + final RandomAccessibleInterval full = ops.op("labeling.boundaries") + .input(corner, new RectangleShape(1, false)).outType(MASK).apply(); + assertEquals(4, count(full)); + } + + @Test + public void testAdjacency() { + final var g = grid(); + final Map> four = ops.op("labeling.adjacency").input( + g, new DiamondShape(1)).outType(GRAPH).apply(); + assertEquals(9, four.size()); + assertEquals(set(1, 3, 5, 7), four.get(4)); + assertEquals(set(1, 3), four.get(0)); + assertEquals(set(0, 2, 4), four.get(1)); + final Map> eight = ops.op("labeling.adjacency").input( + g, new RectangleShape(1, false)).outType(GRAPH).apply(); + assertEquals(set(0, 1, 2, 3, 5, 6, 7, 8), eight.get(4)); + assertEquals(set(1, 3, 4), eight.get(0)); + // The default is full connectivity. + assertEquals(eight, ops.op("labeling.adjacency").input(g).outType(GRAPH) + .apply()); + for (final var entry : eight.entrySet()) + for (final var neighbor : entry.getValue()) + assertEquals(true, eight.get(neighbor).contains(entry.getKey())); + } + + @Test + public void testAdjacencyOverlapAndIsolation() { + final var labeling = new ImgLabeling(ArrayImgs.ints(8, + 3)); + paint(labeling, 1, 0, 0, 2, 2); + paint(labeling, 2, 2, 2, 3, 2); + paint(labeling, 3, 6, 0, 7, 0); + final Map> graph = ops.op("labeling.adjacency") + .input(labeling).outType(GRAPH).apply(); + assertEquals(Map.of(1, set(2), 2, set(1), 3, set()), graph); + } + + private static long count(final RandomAccessibleInterval mask) { + var count = 0L; + for (final var b : mask) + if (b.get()) count++; + return count; + } +} diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/morphology/attribute/AttributeFilterOracleTest.java b/scijava-ops-image/src/test/java/org/scijava/ops/image/morphology/attribute/AttributeFilterOracleTest.java new file mode 100644 index 000000000..3a4cac853 --- /dev/null +++ b/scijava-ops-image/src/test/java/org/scijava/ops/image/morphology/attribute/AttributeFilterOracleTest.java @@ -0,0 +1,633 @@ +/* #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.morphology.attribute; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; + +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.algorithm.neighborhood.DiamondShape; +import net.imglib2.algorithm.neighborhood.RectangleShape; +import net.imglib2.algorithm.neighborhood.Shape; +import net.imglib2.img.Img; +import net.imglib2.img.array.ArrayImgs; +import net.imglib2.type.numeric.RealType; +import net.imglib2.type.numeric.integer.UnsignedByteType; +import net.imglib2.view.Views; + +import org.junit.jupiter.api.Test; +import org.scijava.ops.image.AbstractOpTest; + +/** + * Compares the attribute openings and closings with MorphoLibJ on small 8-bit + * images. + *

+ * The expected arrays were produced by running MorphoLibJ 1.6.4 as a black box + * on the same pseudo-random images ({@code F2}: 12x10 and {@code F3}: 6x5x4, + * values 0 to 9, so with many plateaus and ties): {@code AreaOpeningQueue} and + * {@code BoxDiagonalOpeningQueue} in 2D, and + * {@code AttributeFiltering.volumeOpening} (the 3D size opening) in 3D. The + * closings were obtained by applying the openings to the inverted image + * ({@code 255 - f}) and inverting the result. + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +public class AttributeFilterOracleTest extends AbstractOpTest { + + private static final long[] DIMS2 = { 12, 10 }; + private static final long[] DIMS3 = { 6, 5, 4 }; + + @Test + public void testAreaOpeningClosing2D() { + check("morphology.areaOpening", DIMS2, F2, 3L, face(), AREA_OPEN3_C4); + check("morphology.areaOpening", DIMS2, F2, 8L, face(), AREA_OPEN8_C4); + check("morphology.areaOpening", DIMS2, F2, 20L, face(), AREA_OPEN20_C4); + check("morphology.areaOpening", DIMS2, F2, 3L, full(), AREA_OPEN3_C8); + check("morphology.areaOpening", DIMS2, F2, 8L, full(), AREA_OPEN8_C8); + check("morphology.areaOpening", DIMS2, F2, 20L, full(), AREA_OPEN20_C8); + check("morphology.areaClosing", DIMS2, F2, 3L, face(), AREA_CLOSE3_C4); + check("morphology.areaClosing", DIMS2, F2, 8L, face(), AREA_CLOSE8_C4); + check("morphology.areaClosing", DIMS2, F2, 20L, face(), AREA_CLOSE20_C4); + check("morphology.areaClosing", DIMS2, F2, 3L, full(), AREA_CLOSE3_C8); + check("morphology.areaClosing", DIMS2, F2, 8L, full(), AREA_CLOSE8_C8); + check("morphology.areaClosing", DIMS2, F2, 20L, full(), AREA_CLOSE20_C8); + } + + @Test + public void testBoxDiagonalOpeningClosing2D() { + check("morphology.boxDiagonalOpening", DIMS2, F2, 3.0, face(), + BOX_OPEN3_C4); + check("morphology.boxDiagonalOpening", DIMS2, F2, 5.0, face(), + BOX_OPEN5_C4); + check("morphology.boxDiagonalOpening", DIMS2, F2, 3.0, full(), + BOX_OPEN3_C8); + check("morphology.boxDiagonalOpening", DIMS2, F2, 5.0, full(), + BOX_OPEN5_C8); + check("morphology.boxDiagonalClosing", DIMS2, F2, 3.0, face(), + BOX_CLOSE3_C4); + check("morphology.boxDiagonalClosing", DIMS2, F2, 5.0, face(), + BOX_CLOSE5_C4); + check("morphology.boxDiagonalClosing", DIMS2, F2, 3.0, full(), + BOX_CLOSE3_C8); + check("morphology.boxDiagonalClosing", DIMS2, F2, 5.0, full(), + BOX_CLOSE5_C8); + } + + @Test + public void testAreaOpeningClosing3D() { + check("morphology.areaOpening", DIMS3, F3, 4L, face(), AREA3_OPEN4_C6); + check("morphology.areaOpening", DIMS3, F3, 15L, face(), AREA3_OPEN15_C6); + check("morphology.areaOpening", DIMS3, F3, 4L, full(), AREA3_OPEN4_C26); + check("morphology.areaOpening", DIMS3, F3, 15L, full(), AREA3_OPEN15_C26); + check("morphology.areaClosing", DIMS3, F3, 4L, face(), AREA3_CLOSE4_C6); + check("morphology.areaClosing", DIMS3, F3, 15L, face(), AREA3_CLOSE15_C6); + check("morphology.areaClosing", DIMS3, F3, 4L, full(), AREA3_CLOSE4_C26); + check("morphology.areaClosing", DIMS3, F3, 15L, full(), + AREA3_CLOSE15_C26); + } + + private static Shape face() { + return new DiamondShape(1); + } + + private static Shape full() { + return new RectangleShape(1, false); + } + + private void check(final String name, final long[] dims, final int[] input, + final Object threshold, final Shape connectivity, final int[] expected) + { + final var out = image(dims, new int[input.length]); + ops.op(name).input(image(dims, input), threshold, connectivity).output( + out).compute(); + assertArrayEquals(expected, values(out), name + " " + threshold); + } + + private static Img image(final long[] dims, + final int[] values) + { + final var bytes = new byte[values.length]; + for (var i = 0; i < values.length; i++) + bytes[i] = (byte) values[i]; + return ArrayImgs.unsignedBytes(bytes, dims); + } + + private static int[] values( + final RandomAccessibleInterval> image) + { + final var result = new int[(int) Views.iterable(image).size()]; + var i = 0; + for (final var t : Views.flatIterable(image)) + result[i++] = (int) t.getRealDouble(); + return result; + } + + // -- Expected values (MorphoLibJ 1.6.4) -- + + private static final int[] F2 = { + 0, 3, 7, 1, 2, 1, 9, 0, 4, 1, 7, 5, + 9, 6, 0, 4, 9, 3, 7, 5, 3, 1, 5, 8, + 3, 3, 6, 8, 7, 2, 0, 7, 8, 3, 1, 5, + 5, 9, 0, 5, 6, 1, 4, 5, 0, 1, 8, 3, + 0, 4, 4, 4, 2, 7, 9, 8, 1, 3, 2, 2, + 1, 6, 0, 7, 2, 5, 3, 4, 1, 3, 7, 2, + 8, 9, 0, 5, 7, 7, 2, 2, 7, 1, 3, 9, + 0, 5, 9, 1, 2, 8, 0, 5, 9, 8, 2, 2, + 5, 5, 6, 7, 8, 5, 6, 3, 8, 9, 6, 3, + 7, 4, 2, 1, 2, 3, 8, 2, 5, 1, 5, 9, + }; + + private static final int[] AREA_OPEN3_C4 = { + 0, 3, 3, 1, 2, 1, 5, 0, 3, 1, 5, 5, + 3, 3, 0, 4, 7, 3, 5, 5, 3, 1, 5, 5, + 3, 3, 6, 7, 7, 2, 0, 5, 5, 3, 1, 5, + 4, 4, 0, 5, 6, 1, 4, 5, 0, 1, 3, 3, + 0, 4, 4, 4, 2, 7, 7, 7, 1, 3, 2, 2, + 1, 6, 0, 5, 2, 5, 3, 4, 1, 3, 3, 2, + 6, 6, 0, 5, 7, 7, 2, 2, 7, 1, 3, 3, + 0, 5, 6, 1, 2, 7, 0, 5, 8, 8, 2, 2, + 5, 5, 6, 6, 6, 5, 5, 3, 8, 8, 6, 3, + 5, 4, 2, 1, 2, 3, 5, 2, 5, 1, 5, 5, + }; + + private static final int[] AREA_OPEN8_C4 = { + 0, 3, 3, 1, 2, 1, 5, 0, 3, 1, 2, 2, + 3, 3, 0, 4, 4, 3, 5, 5, 3, 1, 2, 2, + 3, 3, 4, 4, 4, 2, 0, 5, 5, 3, 1, 2, + 4, 4, 0, 4, 4, 1, 4, 5, 0, 1, 2, 2, + 0, 4, 4, 4, 2, 5, 5, 5, 1, 2, 2, 2, + 1, 5, 0, 5, 2, 5, 3, 4, 1, 2, 2, 2, + 5, 5, 0, 5, 5, 5, 2, 2, 5, 1, 2, 2, + 0, 5, 5, 1, 2, 5, 0, 5, 5, 5, 2, 2, + 5, 5, 5, 5, 5, 5, 5, 3, 5, 5, 5, 3, + 5, 4, 2, 1, 2, 3, 5, 2, 5, 1, 5, 5, + }; + + private static final int[] AREA_OPEN20_C4 = { + 0, 3, 3, 1, 2, 1, 5, 0, 3, 1, 2, 2, + 3, 3, 0, 4, 4, 3, 5, 5, 3, 1, 2, 2, + 3, 3, 4, 4, 4, 2, 0, 5, 5, 3, 1, 2, + 4, 4, 0, 4, 4, 1, 4, 5, 0, 1, 2, 2, + 0, 4, 4, 4, 2, 5, 5, 5, 1, 2, 2, 2, + 1, 5, 0, 5, 2, 5, 3, 4, 1, 2, 2, 2, + 5, 5, 0, 5, 5, 5, 2, 2, 3, 1, 2, 2, + 0, 5, 5, 1, 2, 5, 0, 3, 3, 3, 2, 2, + 5, 5, 5, 5, 5, 5, 5, 3, 3, 3, 3, 3, + 5, 4, 2, 1, 2, 3, 5, 2, 3, 1, 3, 3, + }; + + private static final int[] BOX_OPEN3_C4 = { + 0, 3, 3, 1, 2, 1, 5, 0, 3, 1, 3, 3, + 3, 3, 0, 4, 4, 3, 5, 5, 3, 1, 3, 3, + 3, 3, 4, 4, 4, 2, 0, 5, 5, 3, 1, 3, + 4, 4, 0, 4, 4, 1, 4, 5, 0, 1, 3, 3, + 0, 4, 4, 4, 2, 5, 5, 5, 1, 2, 2, 2, + 1, 5, 0, 5, 2, 5, 3, 4, 1, 2, 2, 2, + 5, 5, 0, 5, 5, 5, 2, 2, 5, 1, 2, 2, + 0, 5, 5, 1, 2, 5, 0, 5, 5, 5, 2, 2, + 5, 5, 5, 5, 5, 5, 5, 3, 5, 5, 5, 3, + 5, 4, 2, 1, 2, 3, 5, 2, 5, 1, 5, 5, + }; + + private static final int[] BOX_OPEN5_C4 = { + 0, 3, 3, 1, 2, 1, 5, 0, 3, 1, 2, 2, + 3, 3, 0, 4, 4, 3, 5, 5, 3, 1, 2, 2, + 3, 3, 4, 4, 4, 2, 0, 5, 5, 3, 1, 2, + 4, 4, 0, 4, 4, 1, 4, 5, 0, 1, 2, 2, + 0, 4, 4, 4, 2, 5, 5, 5, 1, 2, 2, 2, + 1, 5, 0, 5, 2, 5, 3, 4, 1, 2, 2, 2, + 5, 5, 0, 5, 5, 5, 2, 2, 5, 1, 2, 2, + 0, 5, 5, 1, 2, 5, 0, 5, 5, 5, 2, 2, + 5, 5, 5, 5, 5, 5, 5, 3, 5, 5, 5, 3, + 5, 4, 2, 1, 2, 3, 5, 2, 5, 1, 5, 5, + }; + + private static final int[] AREA_OPEN3_C8 = { + 0, 3, 6, 1, 2, 1, 7, 0, 4, 1, 5, 5, + 6, 6, 0, 4, 7, 3, 7, 5, 3, 1, 5, 5, + 3, 3, 6, 7, 7, 2, 0, 7, 7, 3, 1, 5, + 5, 6, 0, 5, 6, 1, 4, 5, 0, 1, 5, 3, + 0, 4, 4, 4, 2, 7, 7, 7, 1, 3, 2, 2, + 1, 6, 0, 7, 2, 5, 3, 4, 1, 3, 3, 2, + 8, 8, 0, 5, 7, 7, 2, 2, 7, 1, 3, 3, + 0, 5, 8, 1, 2, 7, 0, 5, 8, 8, 2, 2, + 5, 5, 6, 7, 7, 5, 6, 3, 8, 8, 6, 3, + 5, 4, 2, 1, 2, 3, 6, 2, 5, 1, 5, 6, + }; + + private static final int[] AREA_OPEN8_C8 = { + 0, 3, 6, 1, 2, 1, 5, 0, 4, 1, 3, 3, + 6, 6, 0, 4, 6, 3, 5, 5, 3, 1, 3, 3, + 3, 3, 6, 6, 6, 2, 0, 5, 5, 3, 1, 3, + 5, 6, 0, 5, 6, 1, 4, 5, 0, 1, 3, 3, + 0, 4, 4, 4, 2, 6, 6, 6, 1, 3, 2, 2, + 1, 6, 0, 7, 2, 5, 3, 4, 1, 3, 3, 2, + 7, 7, 0, 5, 7, 7, 2, 2, 5, 1, 3, 3, + 0, 5, 7, 1, 2, 7, 0, 5, 5, 5, 2, 2, + 5, 5, 6, 7, 7, 5, 6, 3, 5, 5, 5, 3, + 5, 4, 2, 1, 2, 3, 6, 2, 5, 1, 5, 5, + }; + + private static final int[] AREA_OPEN20_C8 = { + 0, 3, 5, 1, 2, 1, 5, 0, 4, 1, 3, 3, + 5, 5, 0, 4, 5, 3, 5, 5, 3, 1, 3, 3, + 3, 3, 5, 5, 5, 2, 0, 5, 5, 3, 1, 3, + 5, 5, 0, 5, 5, 1, 4, 5, 0, 1, 3, 3, + 0, 4, 4, 4, 2, 5, 5, 5, 1, 3, 2, 2, + 1, 5, 0, 5, 2, 5, 3, 4, 1, 3, 3, 2, + 5, 5, 0, 5, 5, 5, 2, 2, 5, 1, 3, 3, + 0, 5, 5, 1, 2, 5, 0, 5, 5, 5, 2, 2, + 5, 5, 5, 5, 5, 5, 5, 3, 5, 5, 5, 3, + 5, 4, 2, 1, 2, 3, 5, 2, 5, 1, 5, 5, + }; + + private static final int[] BOX_OPEN3_C8 = { + 0, 3, 6, 1, 2, 1, 5, 0, 4, 1, 5, 5, + 6, 6, 0, 4, 6, 3, 5, 5, 3, 1, 5, 5, + 3, 3, 6, 6, 6, 2, 0, 5, 5, 3, 1, 5, + 5, 6, 0, 5, 6, 1, 4, 5, 0, 1, 5, 3, + 0, 4, 4, 4, 2, 6, 6, 6, 1, 3, 2, 2, + 1, 6, 0, 7, 2, 5, 3, 4, 1, 3, 3, 2, + 7, 7, 0, 5, 7, 7, 2, 2, 6, 1, 3, 3, + 0, 5, 7, 1, 2, 7, 0, 5, 6, 6, 2, 2, + 5, 5, 6, 7, 7, 5, 6, 3, 6, 6, 6, 3, + 5, 4, 2, 1, 2, 3, 6, 2, 5, 1, 5, 6, + }; + + private static final int[] BOX_OPEN5_C8 = { + 0, 3, 6, 1, 2, 1, 5, 0, 4, 1, 3, 3, + 6, 6, 0, 4, 6, 3, 5, 5, 3, 1, 3, 3, + 3, 3, 6, 6, 6, 2, 0, 5, 5, 3, 1, 3, + 5, 6, 0, 5, 6, 1, 4, 5, 0, 1, 3, 3, + 0, 4, 4, 4, 2, 6, 6, 6, 1, 3, 2, 2, + 1, 6, 0, 7, 2, 5, 3, 4, 1, 3, 3, 2, + 7, 7, 0, 5, 7, 7, 2, 2, 5, 1, 3, 3, + 0, 5, 7, 1, 2, 7, 0, 5, 5, 5, 2, 2, + 5, 5, 6, 7, 7, 5, 6, 3, 5, 5, 5, 3, + 5, 4, 2, 1, 2, 3, 6, 2, 5, 1, 5, 5, + }; + + private static final int[] AREA_CLOSE3_C4 = { + 6, 6, 7, 2, 2, 2, 9, 4, 4, 3, 7, 7, + 9, 6, 4, 4, 9, 3, 7, 5, 3, 3, 5, 8, + 5, 5, 6, 8, 7, 2, 2, 7, 8, 3, 3, 5, + 5, 9, 4, 5, 6, 2, 4, 5, 1, 1, 8, 3, + 4, 4, 4, 4, 4, 7, 9, 8, 1, 3, 2, 2, + 4, 6, 4, 7, 4, 5, 3, 4, 1, 3, 7, 2, + 8, 9, 4, 5, 7, 7, 2, 2, 7, 3, 3, 9, + 5, 5, 9, 5, 5, 8, 2, 5, 9, 8, 3, 3, + 5, 5, 6, 7, 8, 5, 6, 5, 8, 9, 6, 3, + 7, 4, 2, 2, 2, 3, 8, 5, 5, 5, 5, 9, + }; + + private static final int[] AREA_CLOSE8_C4 = { + 6, 6, 7, 4, 4, 4, 9, 4, 4, 3, 7, 7, + 9, 6, 4, 4, 9, 4, 7, 5, 3, 3, 5, 8, + 5, 5, 6, 8, 7, 4, 4, 7, 8, 3, 3, 5, + 5, 9, 4, 5, 6, 4, 4, 5, 3, 3, 8, 3, + 4, 4, 4, 4, 4, 7, 9, 8, 3, 3, 3, 3, + 4, 6, 4, 7, 4, 5, 4, 4, 3, 3, 7, 3, + 8, 9, 4, 5, 7, 7, 4, 4, 7, 3, 3, 9, + 5, 5, 9, 5, 5, 8, 4, 5, 9, 8, 3, 3, + 5, 5, 6, 7, 8, 5, 6, 5, 8, 9, 6, 3, + 7, 5, 5, 5, 5, 5, 8, 5, 5, 5, 5, 9, + }; + + private static final int[] AREA_CLOSE20_C4 = { + 6, 6, 7, 5, 5, 5, 9, 4, 4, 3, 7, 7, + 9, 6, 5, 5, 9, 5, 7, 5, 3, 3, 5, 8, + 5, 5, 6, 8, 7, 5, 5, 7, 8, 3, 3, 5, + 5, 9, 5, 5, 6, 5, 5, 5, 3, 3, 8, 3, + 5, 5, 5, 5, 5, 7, 9, 8, 3, 3, 3, 3, + 5, 6, 5, 7, 5, 5, 4, 4, 3, 3, 7, 3, + 8, 9, 5, 5, 7, 7, 4, 4, 7, 3, 3, 9, + 6, 6, 9, 5, 5, 8, 4, 5, 9, 8, 3, 3, + 6, 6, 6, 7, 8, 6, 6, 5, 8, 9, 6, 3, + 7, 6, 6, 6, 6, 6, 8, 5, 5, 5, 5, 9, + }; + + private static final int[] BOX_CLOSE3_C4 = { + 6, 6, 7, 3, 3, 3, 9, 4, 4, 3, 7, 7, + 9, 6, 4, 4, 9, 3, 7, 5, 3, 3, 5, 8, + 5, 5, 6, 8, 7, 3, 3, 7, 8, 3, 3, 5, + 5, 9, 4, 5, 6, 3, 4, 5, 3, 3, 8, 3, + 4, 4, 4, 4, 4, 7, 9, 8, 3, 3, 3, 3, + 4, 6, 4, 7, 4, 5, 4, 4, 3, 3, 7, 3, + 8, 9, 4, 5, 7, 7, 4, 4, 7, 3, 3, 9, + 5, 5, 9, 5, 5, 8, 4, 5, 9, 8, 3, 3, + 5, 5, 6, 7, 8, 5, 6, 5, 8, 9, 6, 3, + 7, 4, 3, 3, 3, 3, 8, 5, 5, 5, 5, 9, + }; + + private static final int[] BOX_CLOSE5_C4 = { + 6, 6, 7, 4, 4, 4, 9, 4, 4, 3, 7, 7, + 9, 6, 4, 4, 9, 4, 7, 5, 3, 3, 5, 8, + 5, 5, 6, 8, 7, 4, 4, 7, 8, 3, 3, 5, + 5, 9, 4, 5, 6, 4, 4, 5, 3, 3, 8, 3, + 4, 4, 4, 4, 4, 7, 9, 8, 3, 3, 3, 3, + 4, 6, 4, 7, 4, 5, 4, 4, 3, 3, 7, 3, + 8, 9, 4, 5, 7, 7, 4, 4, 7, 3, 3, 9, + 5, 5, 9, 5, 5, 8, 4, 5, 9, 8, 3, 3, + 5, 5, 6, 7, 8, 5, 6, 5, 8, 9, 6, 3, + 7, 5, 5, 5, 5, 5, 8, 5, 5, 5, 5, 9, + }; + + private static final int[] AREA_CLOSE3_C8 = { + 3, 3, 7, 2, 2, 2, 9, 3, 4, 1, 7, 5, + 9, 6, 2, 4, 9, 3, 7, 5, 3, 1, 5, 8, + 3, 3, 6, 8, 7, 2, 2, 7, 8, 3, 1, 5, + 5, 9, 3, 5, 6, 2, 4, 5, 1, 1, 8, 3, + 4, 4, 4, 4, 2, 7, 9, 8, 1, 3, 2, 2, + 4, 6, 1, 7, 2, 5, 3, 4, 1, 3, 7, 2, + 8, 9, 1, 5, 7, 7, 2, 2, 7, 1, 3, 9, + 5, 5, 9, 1, 2, 8, 2, 5, 9, 8, 2, 2, + 5, 5, 6, 7, 8, 5, 6, 3, 8, 9, 6, 3, + 7, 4, 2, 2, 2, 3, 8, 3, 5, 5, 5, 9, + }; + + private static final int[] AREA_CLOSE8_C8 = { + 3, 3, 7, 3, 3, 3, 9, 3, 4, 1, 7, 5, + 9, 6, 3, 4, 9, 3, 7, 5, 3, 1, 5, 8, + 3, 3, 6, 8, 7, 3, 3, 7, 8, 3, 1, 5, + 5, 9, 3, 5, 6, 3, 4, 5, 1, 1, 8, 3, + 4, 4, 4, 4, 3, 7, 9, 8, 1, 3, 2, 2, + 4, 6, 4, 7, 3, 5, 3, 4, 1, 3, 7, 2, + 8, 9, 4, 5, 7, 7, 2, 2, 7, 1, 3, 9, + 5, 5, 9, 4, 4, 8, 2, 5, 9, 8, 2, 2, + 5, 5, 6, 7, 8, 5, 6, 3, 8, 9, 6, 3, + 7, 5, 5, 5, 5, 5, 8, 3, 5, 5, 5, 9, + }; + + private static final int[] AREA_CLOSE20_C8 = { + 4, 4, 7, 4, 4, 4, 9, 3, 4, 3, 7, 5, + 9, 6, 4, 4, 9, 4, 7, 5, 3, 3, 5, 8, + 4, 4, 6, 8, 7, 4, 4, 7, 8, 3, 3, 5, + 5, 9, 4, 5, 6, 4, 4, 5, 3, 3, 8, 3, + 4, 4, 4, 4, 4, 7, 9, 8, 3, 3, 3, 3, + 4, 6, 4, 7, 4, 5, 3, 4, 3, 3, 7, 3, + 8, 9, 4, 5, 7, 7, 3, 3, 7, 3, 3, 9, + 5, 5, 9, 4, 4, 8, 3, 5, 9, 8, 3, 3, + 5, 5, 6, 7, 8, 5, 6, 3, 8, 9, 6, 3, + 7, 5, 5, 5, 5, 5, 8, 3, 5, 5, 5, 9, + }; + + private static final int[] BOX_CLOSE3_C8 = { + 3, 3, 7, 2, 2, 2, 9, 3, 4, 1, 7, 5, + 9, 6, 2, 4, 9, 3, 7, 5, 3, 1, 5, 8, + 3, 3, 6, 8, 7, 2, 2, 7, 8, 3, 1, 5, + 5, 9, 3, 5, 6, 2, 4, 5, 1, 1, 8, 3, + 4, 4, 4, 4, 2, 7, 9, 8, 1, 3, 2, 2, + 4, 6, 4, 7, 2, 5, 3, 4, 1, 3, 7, 2, + 8, 9, 4, 5, 7, 7, 2, 2, 7, 1, 3, 9, + 5, 5, 9, 4, 4, 8, 2, 5, 9, 8, 2, 2, + 5, 5, 6, 7, 8, 5, 6, 3, 8, 9, 6, 3, + 7, 4, 3, 3, 3, 3, 8, 3, 5, 5, 5, 9, + }; + + private static final int[] BOX_CLOSE5_C8 = { + 3, 3, 7, 3, 3, 3, 9, 3, 4, 1, 7, 5, + 9, 6, 3, 4, 9, 3, 7, 5, 3, 1, 5, 8, + 3, 3, 6, 8, 7, 3, 3, 7, 8, 3, 1, 5, + 5, 9, 3, 5, 6, 3, 4, 5, 1, 1, 8, 3, + 4, 4, 4, 4, 3, 7, 9, 8, 1, 3, 2, 2, + 4, 6, 4, 7, 3, 5, 3, 4, 1, 3, 7, 2, + 8, 9, 4, 5, 7, 7, 2, 2, 7, 1, 3, 9, + 5, 5, 9, 4, 4, 8, 2, 5, 9, 8, 2, 2, + 5, 5, 6, 7, 8, 5, 6, 3, 8, 9, 6, 3, + 7, 5, 5, 5, 5, 5, 8, 3, 5, 5, 5, 9, + }; + + private static final int[] F3 = { + 0, 0, 3, 9, 9, 9, + 5, 6, 2, 3, 2, 8, + 4, 4, 4, 6, 4, 5, + 6, 2, 5, 9, 2, 4, + 9, 9, 5, 9, 0, 0, + 5, 1, 0, 1, 0, 4, + 8, 7, 4, 5, 7, 4, + 6, 5, 2, 9, 8, 0, + 7, 1, 9, 9, 1, 9, + 9, 6, 1, 6, 3, 3, + 9, 7, 4, 5, 5, 6, + 7, 4, 9, 3, 5, 9, + 7, 2, 5, 5, 6, 0, + 5, 2, 9, 1, 2, 9, + 7, 5, 2, 9, 8, 1, + 0, 0, 0, 6, 9, 8, + 8, 6, 6, 7, 5, 2, + 9, 8, 7, 8, 9, 1, + 1, 3, 5, 9, 5, 3, + 2, 2, 2, 6, 9, 6, + }; + + private static final int[] AREA3_OPEN4_C6 = { + 0, 0, 3, 8, 8, 8, + 5, 6, 2, 3, 2, 8, + 4, 4, 4, 6, 4, 5, + 6, 2, 5, 9, 2, 4, + 7, 7, 5, 9, 0, 0, + 5, 1, 0, 1, 0, 4, + 7, 7, 4, 5, 7, 4, + 6, 5, 2, 9, 8, 0, + 7, 1, 9, 9, 1, 4, + 7, 6, 1, 6, 3, 3, + 7, 7, 4, 5, 5, 6, + 7, 4, 6, 3, 5, 6, + 7, 2, 5, 5, 6, 0, + 5, 2, 9, 1, 2, 4, + 7, 5, 2, 6, 6, 1, + 0, 0, 0, 6, 6, 6, + 7, 6, 6, 7, 5, 2, + 7, 7, 7, 7, 7, 1, + 1, 3, 5, 7, 5, 3, + 2, 2, 2, 6, 6, 6, + }; + + private static final int[] AREA3_OPEN15_C6 = { + 0, 0, 3, 4, 4, 4, + 5, 6, 2, 3, 2, 4, + 4, 4, 4, 6, 4, 4, + 6, 2, 5, 6, 2, 4, + 6, 6, 5, 6, 0, 0, + 5, 1, 0, 1, 0, 4, + 6, 6, 4, 5, 6, 4, + 6, 5, 2, 6, 6, 0, + 6, 1, 6, 6, 1, 4, + 6, 6, 1, 6, 3, 3, + 6, 6, 4, 5, 5, 6, + 6, 4, 6, 3, 5, 6, + 6, 2, 5, 5, 6, 0, + 5, 2, 6, 1, 2, 4, + 6, 5, 2, 6, 6, 1, + 0, 0, 0, 6, 6, 6, + 6, 6, 6, 6, 5, 2, + 6, 6, 6, 6, 6, 1, + 1, 3, 5, 6, 5, 3, + 2, 2, 2, 6, 6, 6, + }; + + private static final int[] AREA3_OPEN4_C26 = { + 0, 0, 3, 8, 8, 8, + 5, 6, 2, 3, 2, 8, + 4, 4, 4, 6, 4, 5, + 6, 2, 5, 9, 2, 4, + 9, 9, 5, 9, 0, 0, + 5, 1, 0, 1, 0, 4, + 8, 7, 4, 5, 7, 4, + 6, 5, 2, 9, 8, 0, + 7, 1, 9, 9, 1, 9, + 9, 6, 1, 6, 3, 3, + 8, 7, 4, 5, 5, 6, + 7, 4, 9, 3, 5, 9, + 7, 2, 5, 5, 6, 0, + 5, 2, 9, 1, 2, 9, + 7, 5, 2, 9, 8, 1, + 0, 0, 0, 6, 9, 8, + 8, 6, 6, 7, 5, 2, + 8, 8, 7, 8, 9, 1, + 1, 3, 5, 9, 5, 3, + 2, 2, 2, 6, 9, 6, + }; + + private static final int[] AREA3_OPEN15_C26 = { + 0, 0, 3, 8, 8, 8, + 5, 6, 2, 3, 2, 8, + 4, 4, 4, 6, 4, 5, + 6, 2, 5, 9, 2, 4, + 9, 9, 5, 9, 0, 0, + 5, 1, 0, 1, 0, 4, + 8, 7, 4, 5, 7, 4, + 6, 5, 2, 9, 8, 0, + 7, 1, 9, 9, 1, 9, + 9, 6, 1, 6, 3, 3, + 8, 7, 4, 5, 5, 6, + 7, 4, 9, 3, 5, 9, + 7, 2, 5, 5, 6, 0, + 5, 2, 9, 1, 2, 9, + 7, 5, 2, 9, 8, 1, + 0, 0, 0, 6, 9, 8, + 8, 6, 6, 7, 5, 2, + 8, 8, 7, 8, 9, 1, + 1, 3, 5, 9, 5, 3, + 2, 2, 2, 6, 9, 6, + }; + + private static final int[] AREA3_CLOSE4_C6 = { + 1, 1, 3, 9, 9, 9, + 5, 6, 3, 3, 3, 8, + 4, 4, 4, 6, 4, 5, + 6, 2, 5, 9, 2, 4, + 9, 9, 5, 9, 2, 2, + 5, 1, 1, 1, 1, 4, + 8, 7, 4, 5, 7, 4, + 6, 5, 4, 9, 8, 2, + 7, 2, 9, 9, 2, 9, + 9, 6, 2, 6, 3, 3, + 9, 7, 4, 5, 5, 6, + 7, 4, 9, 5, 5, 9, + 7, 2, 5, 5, 6, 2, + 5, 2, 9, 2, 2, 9, + 7, 5, 2, 9, 8, 3, + 4, 4, 4, 6, 9, 8, + 8, 6, 6, 7, 5, 2, + 9, 8, 7, 8, 9, 2, + 2, 3, 5, 9, 5, 3, + 2, 2, 2, 6, 9, 6, + }; + + private static final int[] AREA3_CLOSE15_C6 = { + 4, 4, 4, 9, 9, 9, + 5, 6, 4, 4, 4, 8, + 4, 4, 4, 6, 4, 5, + 6, 4, 5, 9, 4, 4, + 9, 9, 5, 9, 4, 4, + 5, 4, 4, 4, 4, 4, + 8, 7, 4, 5, 7, 4, + 6, 5, 4, 9, 8, 4, + 7, 4, 9, 9, 4, 9, + 9, 6, 4, 6, 4, 4, + 9, 7, 4, 5, 5, 6, + 7, 4, 9, 5, 5, 9, + 7, 4, 5, 5, 6, 4, + 5, 4, 9, 4, 4, 9, + 7, 5, 4, 9, 8, 4, + 4, 4, 4, 6, 9, 8, + 8, 6, 6, 7, 5, 4, + 9, 8, 7, 8, 9, 4, + 4, 4, 5, 9, 5, 4, + 4, 4, 4, 6, 9, 6, + }; + + private static final int[] AREA3_CLOSE4_C26 = { + 1, 1, 3, 9, 9, 9, + 5, 6, 2, 3, 2, 8, + 4, 4, 4, 6, 4, 5, + 6, 2, 5, 9, 2, 4, + 9, 9, 5, 9, 1, 1, + 5, 1, 1, 1, 1, 4, + 8, 7, 4, 5, 7, 4, + 6, 5, 2, 9, 8, 1, + 7, 1, 9, 9, 1, 9, + 9, 6, 1, 6, 3, 3, + 9, 7, 4, 5, 5, 6, + 7, 4, 9, 3, 5, 9, + 7, 2, 5, 5, 6, 1, + 5, 2, 9, 1, 2, 9, + 7, 5, 2, 9, 8, 1, + 3, 3, 3, 6, 9, 8, + 8, 6, 6, 7, 5, 2, + 9, 8, 7, 8, 9, 1, + 2, 3, 5, 9, 5, 3, + 2, 2, 2, 6, 9, 6, + }; + + private static final int[] AREA3_CLOSE15_C26 = { + 2, 2, 3, 9, 9, 9, + 5, 6, 2, 3, 2, 8, + 4, 4, 4, 6, 4, 5, + 6, 2, 5, 9, 2, 4, + 9, 9, 5, 9, 2, 2, + 5, 2, 2, 2, 2, 4, + 8, 7, 4, 5, 7, 4, + 6, 5, 2, 9, 8, 2, + 7, 2, 9, 9, 2, 9, + 9, 6, 2, 6, 3, 3, + 9, 7, 4, 5, 5, 6, + 7, 4, 9, 3, 5, 9, + 7, 2, 5, 5, 6, 2, + 5, 2, 9, 2, 2, 9, + 7, 5, 2, 9, 8, 2, + 3, 3, 3, 6, 9, 8, + 8, 6, 6, 7, 5, 2, + 9, 8, 7, 8, 9, 2, + 2, 3, 5, 9, 5, 3, + 2, 2, 2, 6, 9, 6, + }; +} diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/morphology/attribute/AttributeFilterTest.java b/scijava-ops-image/src/test/java/org/scijava/ops/image/morphology/attribute/AttributeFilterTest.java new file mode 100644 index 000000000..f8745db69 --- /dev/null +++ b/scijava-ops-image/src/test/java/org/scijava/ops/image/morphology/attribute/AttributeFilterTest.java @@ -0,0 +1,450 @@ +/* #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.morphology.attribute; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Locale; +import java.util.Random; + +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.algorithm.neighborhood.DiamondShape; +import net.imglib2.algorithm.neighborhood.HorizontalLineShape; +import net.imglib2.algorithm.neighborhood.RectangleShape; +import net.imglib2.algorithm.neighborhood.Shape; +import net.imglib2.img.Img; +import net.imglib2.img.array.ArrayImgs; +import net.imglib2.type.numeric.RealType; +import net.imglib2.type.numeric.real.DoubleType; +import net.imglib2.util.Intervals; +import net.imglib2.view.Views; + +import org.junit.jupiter.api.Test; +import org.scijava.ops.image.AbstractOpTest; + +/** + * Tests the attribute openings and closings against their definition by + * threshold decomposition, computed by brute force. + *

+ * For an increasing attribute, the opening of {@code f} at a pixel {@code p} + * is the highest level {@code t} such that the connected component of + * {@code {f >= t}} containing {@code p} has an attribute of at least the + * threshold (or covers a whole connected component of the image); for the + * area attribute this is the maximum, over all connected sets of at least + * {@code minArea} pixels containing {@code p}, of the minimum of {@code f} on + * the set. The reference implementation floods each such component + * explicitly, independently of the component tree. + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +public class AttributeFilterTest extends AbstractOpTest { + + private static final String[] ATTRIBUTES = { "area", "boxDiagonal", + "grayVolume" }; + + @Test + public void testRandom2D() { + final var random = new Random(1); + for (var trial = 0; trial < 6; trial++) { + final var img = randomImage(random, new long[] { 9, 7 }, 4); + for (final var shape : shapes()) { + check(img, "area", new double[] { 1, 2, 4, 7, 15, 40, 64 }, shape); + check(img, "boxDiagonal", new double[] { 0.5, 1, 1.5, 2.5, 4, 7, 20 }, + shape); + check(img, "grayVolume", new double[] { 0.5, 2, 5, 12, 30, 100 }, + shape); + } + } + } + + @Test + public void testRandom3D() { + final var random = new Random(2); + for (var trial = 0; trial < 3; trial++) { + final var img = randomImage(random, new long[] { 5, 4, 3 }, 5); + for (final var shape : shapes()) { + check(img, "area", new double[] { 2, 5, 11, 30 }, shape); + check(img, "boxDiagonal", new double[] { 1, 2, 3.2, 6 }, shape); + check(img, "grayVolume", new double[] { 3, 10, 50 }, shape); + } + } + } + + @Test + public void testRandomFloat() { + // Note: continuous values make (almost) every pixel its own level. + final var random = new Random(3); + final var img = ArrayImgs.doubles(8, 6); + for (final var t : img) + t.set(random.nextGaussian()); + for (final var shape : shapes()) { + check(img, "area", new double[] { 3, 9 }, shape); + check(img, "boxDiagonal", new double[] { 2, 3.5 }, shape); + check(img, "grayVolume", new double[] { 0.5, 4 }, shape); + } + } + + @Test + public void testPlateaus() { + // A plateau of 4 pixels at 5 holding a 2-pixel peak at 8, a lone peak + // of 1 pixel at 7 and a 6-pixel dark hole at 0, on a background of 3. + final var img = image(new long[] { 8, 4 }, // + 3, 3, 3, 3, 3, 3, 3, 3, // + 3, 5, 8, 3, 7, 0, 0, 0, // + 3, 5, 8, 3, 3, 0, 0, 0, // + 3, 5, 5, 3, 3, 3, 3, 3); + assertArrayEquals(new double[] { // + 3, 3, 3, 3, 3, 3, 3, 3, // + 3, 5, 5, 3, 3, 0, 0, 0, // + 3, 5, 5, 3, 3, 0, 0, 0, // + 3, 5, 5, 3, 3, 3, 3, 3 }, values(filter("morphology.areaOpening", img, + 3L, new DiamondShape(1))), 0); + assertArrayEquals(new double[] { // + 3, 3, 3, 3, 3, 3, 3, 3, // + 3, 5, 8, 3, 7, 3, 3, 3, // + 3, 5, 8, 3, 3, 3, 3, 3, // + 3, 5, 5, 3, 3, 3, 3, 3 }, values(filter("morphology.areaClosing", img, + 7L, new DiamondShape(1))), 0); + } + + @Test + public void testTrivialThresholds() { + final var img = randomImage(new Random(4), new long[] { 7, 6 }, 6); + final var expected = values(img); + assertArrayEquals(expected, values(filter("morphology.areaOpening", img, + 1L, null)), 0); + assertArrayEquals(expected, values(filter("morphology.areaClosing", img, + 0L, null)), 0); + assertArrayEquals(expected, values(filter("morphology.boxDiagonalOpening", + img, 0.0, null)), 0); + assertArrayEquals(expected, values(filter( + "morphology.grayVolumeClosing", img, 0.0, null)), 0); + + // A size larger than the image flattens it to its minimum or maximum. + final var min = Arrays.stream(expected).min().getAsDouble(); + final var max = Arrays.stream(expected).max().getAsDouble(); + final var flatMin = new double[expected.length]; + final var flatMax = new double[expected.length]; + Arrays.fill(flatMin, min); + Arrays.fill(flatMax, max); + assertArrayEquals(flatMin, values(filter("morphology.areaOpening", img, + 43L, null)), 0); + assertArrayEquals(flatMax, values(filter("morphology.areaClosing", img, + 43L, null)), 0); + } + + @Test + public void testOpeningProperties() { + final var img = randomImage(new Random(5), new long[] { 10, 8 }, 8); + final var f = values(img); + for (final var name : new String[] { "morphology.areaOpening", + "morphology.areaClosing" }) + { + final var once = filter(name, img, 6L, null); + final var twice = filter(name, once, 6L, null); + assertArrayEquals(values(once), values(twice), 0, "idempotence"); + final var g = values(once); + for (var i = 0; i < f.length; i++) { + assertTrue(name.endsWith("Opening") ? g[i] <= f[i] : g[i] >= f[i]); + } + } + } + + @Test + public void testBinary() { + // Components of 1, 3 and 10 pixels (8-connected), the largest one + // enclosing a hole of 2 pixels (4-connected). + final var img = ArrayImgs.bits(7, 5); + final var bits = new int[] { // + 1, 0, 0, 1, 1, 1, 0, // + 0, 0, 0, 1, 0, 1, 0, // + 0, 0, 0, 1, 0, 1, 0, // + 1, 0, 0, 1, 1, 1, 0, // + 1, 1, 0, 0, 0, 0, 0 }; + var i = 0; + for (final var t : img) + t.set(bits[i++] != 0); + final var opened = ArrayImgs.bits(7, 5); + ops.op("morphology.areaOpening").input(img, 4L).output(opened).compute(); + assertArrayEquals(new double[] { // + 0, 0, 0, 1, 1, 1, 0, // + 0, 0, 0, 1, 0, 1, 0, // + 0, 0, 0, 1, 0, 1, 0, // + 0, 0, 0, 1, 1, 1, 0, // + 0, 0, 0, 0, 0, 0, 0 }, values(opened), 0); + final var closed = ArrayImgs.bits(7, 5); + ops.op("morphology.areaClosing").input(img, 3L, new DiamondShape(1)) + .output(closed).compute(); + assertArrayEquals(new double[] { // + 1, 0, 0, 1, 1, 1, 0, // + 0, 0, 0, 1, 1, 1, 0, // + 0, 0, 0, 1, 1, 1, 0, // + 1, 0, 0, 1, 1, 1, 0, // + 1, 1, 0, 0, 0, 0, 0 }, values(closed), 0); + } + + @Test + public void testGenericAttributeOps() { + final var img = randomImage(new Random(6), new long[] { 8, 7 }, 5); + final var shape = new DiamondShape(1); + assertArrayEquals(values(filter("morphology.areaOpening", img, 5L, + shape)), values(attribute("morphology.attributeOpening", img, "area", 5, + shape)), 0); + assertArrayEquals(values(filter("morphology.boxDiagonalClosing", img, 2.5, + shape)), values(attribute("morphology.attributeClosing", img, + "BOXDIAGONAL", 2.5, shape)), 0); + assertArrayEquals(values(filter("morphology.grayVolumeOpening", img, 6.0, + shape)), values(attribute("morphology.attributeOpening", img, + "grayVolume", 6, shape)), 0); + assertThrows(IllegalArgumentException.class, () -> attribute( + "morphology.attributeOpening", img, "perimeter", 1, shape)); + + // Note: "volume" means the voxel count in MorphoLibJ, so it is rejected. + final var e = assertThrows(IllegalArgumentException.class, + () -> attribute("morphology.attributeClosing", img, "Volume", 1, shape)); + assertTrue(e.getMessage().contains("\"area\"")); + assertTrue(e.getMessage().contains("\"grayVolume\"")); + } + + @Test + public void testDisconnectingShape() { + // Note: with horizontal neighbors only, each row is filtered on its own. + final var img = image(new long[] { 5, 2 }, // + 1, 4, 4, 2, 6, // + 0, 0, 3, 0, 0); + final var horizontal = new HorizontalLineShape(1, 0, true); + check(img, "area", new double[] { 2, 3 }, horizontal); + check(img, "grayVolume", new double[] { 1, 5 }, horizontal); + assertArrayEquals(new double[] { // + 1, 4, 4, 2, 2, // + 0, 0, 0, 0, 0 }, values(filter("morphology.areaOpening", img, 2L, + horizontal)), 0); + } + + // -- Helper methods -- + + private static Shape[] shapes() { + return new Shape[] { new DiamondShape(1), new RectangleShape(1, false) }; + } + + private void check(final RandomAccessibleInterval img, + final String attribute, final double[] thresholds, final Shape shape) + { + final var name = attribute.substring(0, 1).toUpperCase(Locale.ROOT) + + attribute.substring(1); + for (final var closing : new boolean[] { false, true }) { + final var op = "morphology." + attribute + (closing ? "Closing" + : "Opening"); + for (final var threshold : thresholds) { + final var expected = reference(img, attribute, threshold, shape, + closing); + final var actual = values(attribute.equals("area") ? filter(op, img, + (long) Math.ceil(threshold), shape) : filter(op, img, threshold, + shape)); + assertArrayEquals(expected, actual, 1e-9, op + " " + threshold + + " with " + shape); + assertArrayEquals(expected, values(attribute("morphology.attribute" + + (closing ? "Closing" : "Opening"), img, name, threshold, shape)), + 1e-9); + } + } + } + + private Img filter(final String op, + final RandomAccessibleInterval img, final Object threshold, + final Shape shape) + { + final var out = ArrayImgs.doubles(img.dimensionsAsLongArray()); + if (shape == null) ops.op(op).input(img, threshold).output(out).compute(); + else ops.op(op).input(img, threshold, shape).output(out).compute(); + return out; + } + + private Img attribute(final String op, + final RandomAccessibleInterval img, final String attribute, + final double threshold, final Shape shape) + { + final var out = ArrayImgs.doubles(img.dimensionsAsLongArray()); + ops.op(op).input(img, attribute, threshold, shape).output(out).compute(); + return out; + } + + private static Img randomImage(final Random random, + final long[] dims, final int levels) + { + final var img = ArrayImgs.doubles(dims); + for (final var t : img) + t.set(random.nextInt(levels)); + return img; + } + + private static Img image(final long[] dims, + final double... values) + { + return ArrayImgs.doubles(values, dims); + } + + private static double[] values( + final RandomAccessibleInterval> img) + { + final var values = new double[(int) Intervals.numElements(img)]; + var i = 0; + for (final var t : Views.flatIterable(img)) + values[i++] = t.getRealDouble(); + return values; + } + + /** + * Computes an attribute opening (or closing) by threshold decomposition, + * flooding the level set component of each pixel at each level. + */ + private static double[] reference( + final RandomAccessibleInterval img, final String attribute, + final double threshold, final Shape shape, final boolean closing) + { + final var dims = img.dimensionsAsLongArray(); + final var n = dims.length; + final var f = values(img); + if (closing) for (var i = 0; i < f.length; i++) + f[i] = -f[i]; + final var offsets = offsets(shape, n); + final var levels = Arrays.stream(f).distinct().sorted().toArray(); + final var out = new double[f.length]; + for (var p = 0; p < f.length; p++) { + for (var l = levels.length - 1; l >= 0; l--) { + final var t = levels[l]; + if (t > f[p]) continue; + if (accept(f, dims, offsets, p, t, attribute, threshold)) { + out[p] = t; + break; + } + } + } + if (closing) for (var i = 0; i < out.length; i++) + out[i] = -out[i]; + return out; + } + + private static boolean accept(final double[] f, final long[] dims, + final int[][] offsets, final int seed, final double t, + final String attribute, final double threshold) + { + final var n = dims.length; + final var inside = new boolean[f.length]; + final var queue = new ArrayDeque(); + inside[seed] = true; + queue.add(seed); + var area = 0; + var sum = 0.0; + var outside = Double.NEGATIVE_INFINITY; + final var min = new long[n]; + final var max = new long[n]; + Arrays.fill(min, Long.MAX_VALUE); + Arrays.fill(max, Long.MIN_VALUE); + final var pos = new long[n]; + while (!queue.isEmpty()) { + final int p = queue.poll(); + area++; + sum += f[p]; + position(p, dims, pos); + for (var d = 0; d < n; d++) { + min[d] = Math.min(min[d], pos[d]); + max[d] = Math.max(max[d], pos[d]); + } + for (final var o : offsets) { + var q = 0L; + var stride = 1L; + var valid = true; + for (var d = 0; d < n; d++) { + final var c = pos[d] + o[d]; + if (c < 0 || c >= dims[d]) valid = false; + q += c * stride; + stride *= dims[d]; + } + if (!valid || inside[(int) q]) continue; + if (f[(int) q] >= t) { + inside[(int) q] = true; + queue.add((int) q); + } + else outside = Math.max(outside, f[(int) q]); + } + } + if (outside == Double.NEGATIVE_INFINITY) return true; + switch (attribute) { + case "area": + return area >= threshold; + case "boxDiagonal": + var d2 = 0.0; + for (var d = 0; d < n; d++) + d2 += (double) (max[d] - min[d]) * (max[d] - min[d]); + return Math.sqrt(d2) >= threshold; + default: + return sum - area * outside >= threshold; + } + } + + private static void position(long index, final long[] dims, + final long[] pos) + { + for (var d = 0; d < dims.length; d++) { + pos[d] = index % dims[d]; + index /= dims[d]; + } + } + + private static int[][] offsets(final Shape shape, final int n) { + if (shape instanceof HorizontalLineShape) { + final var o = new int[2][n]; + o[0][0] = -1; + o[1][0] = 1; + return o; + } + final var diamond = shape instanceof DiamondShape; + final var list = new ArrayList(); + final var o = new int[n]; + Arrays.fill(o, -1); + while (true) { + var l1 = 0; + for (final var v : o) + l1 += Math.abs(v); + if (l1 > 0 && (!diamond || l1 == 1)) list.add(o.clone()); + var d = 0; + while (d < n && ++o[d] > 1) o[d++] = -1; + if (d == n) break; + } + return list.toArray(new int[0][]); + } +} diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/morphology/directional/DirectionalFilterTest.java b/scijava-ops-image/src/test/java/org/scijava/ops/image/morphology/directional/DirectionalFilterTest.java new file mode 100644 index 000000000..a2fbec663 --- /dev/null +++ b/scijava-ops-image/src/test/java/org/scijava/ops/image/morphology/directional/DirectionalFilterTest.java @@ -0,0 +1,265 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.morphology.directional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.HashSet; +import java.util.Random; + +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.algorithm.neighborhood.HorizontalLineShape; +import net.imglib2.img.array.ArrayImgs; +import net.imglib2.outofbounds.OutOfBoundsBorderFactory; +import net.imglib2.type.numeric.real.DoubleType; +import net.imglib2.view.Views; + +import org.junit.jupiter.api.Test; +import org.scijava.ops.image.AbstractOpTest; + +/** + * Tests {@link DirectionalFilter} and {@link OrientedLineShape}. + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +public class DirectionalFilterTest extends AbstractOpTest { + + @Test + public void testShapeOffsets() { + final var horizontal = new OrientedLineShape(5, 0).getOffsets(); + assertEquals(5, horizontal.length); + for (var i = 0; i < 5; i++) { + assertEquals(i - 2, horizontal[i][0]); + assertEquals(0, horizontal[i][1]); + } + final var vertical = new OrientedLineShape(7, Math.PI / 2).getOffsets(); + assertEquals(7, vertical.length); + for (final var o : vertical) + assertEquals(0, o[0]); + + // A diagonal segment of length 21 spans about 21 / sqrt(2) pixels. + final var diagonal = new OrientedLineShape(21, Math.PI / 4).getOffsets(); + assertEquals(15, diagonal.length); + for (final var o : diagonal) + assertEquals(o[0], o[1]); + + // Every segment is symmetric about the center and contains it. + final var rnd = new Random(5); + for (var t = 0; t < 50; t++) { + final var offsets = new OrientedLineShape(1 + 30 * rnd.nextDouble(), rnd + .nextDouble() * 2 * Math.PI).getOffsets(); + final var set = new HashSet(); + for (final var o : offsets) + set.add(o[0] + "," + o[1]); + assertTrue(set.contains("0,0")); + assertEquals(offsets.length, set.size()); + for (final var o : offsets) + assertTrue(set.contains(-o[0] + "," + -o[1])); + } + } + + @Test + public void testShapeNeighborhoods() { + final var img = ArrayImgs.doubles(9, 9); + final var c = img.localizingCursor(); + while (c.hasNext()) { + c.fwd(); + c.get().set(10 * c.getIntPosition(1) + c.getIntPosition(0)); + } + final var shape = new OrientedLineShape(7, Math.PI / 4); + final var ra = shape.neighborhoodsRandomAccessible(img).randomAccess(); + final var nbh = ra.setPositionAndGet(4, 4); + assertEquals(5, nbh.size()); + var sum = 0.0; + for (final var v : nbh) + sum += v.get(); + // Pixels (2,2), (3,3), ..., (6,6). + assertEquals(5 * 44, sum, 0); + assertEquals(2, nbh.min(0)); + assertEquals(6, nbh.max(1)); + } + + /** With one horizontal direction, erosion is a horizontal minimum filter. */ + @Test + public void testSingleDirectionErosion() { + final var in = random(20, 15, 1); + final var out = ArrayImgs.doubles(20, 15); + ops.op("morphology.directionalFilter").input(in, "erosion", 7.0, 1) + .output(out).compute(); + final var expected = ArrayImgs.doubles(20, 15); + ops.op("filter.min").input(in, new HorizontalLineShape(3, 0, false), + new OutOfBoundsBorderFactory<>()).output(expected).compute(); + assertImagesEqual(expected, out, 0); + } + + /** + * The maximum of directional openings keeps thin lines of any orientation + * that are longer than the segment, and removes small spots. + */ + @Test + public void testOpeningKeepsLines() { + final var in = ArrayImgs.doubles(40, 40); + final var ra = in.randomAccess(); + for (var i = 5; i < 35; i++) { + ra.setPositionAndGet(i, 30).set(80); // horizontal line + ra.setPositionAndGet(i, i).set(100); // diagonal line + ra.setPositionAndGet(30 + (i - 5) / 3, i).set(60); // steep line + } + for (var x = 3; x < 6; x++) + for (var y = 20; y < 23; y++) + ra.setPositionAndGet(x + 10, y).set(200); // 3x3 spot + final var out = ArrayImgs.doubles(40, 40); + ops.op("morphology.directionalFilter").input(in, "opening", 9.0, 32) + .output(out).compute(); + final var o = out.randomAccess(); + for (var i = 5; i < 35; i++) { + if (i == 30) continue; // The crossing of the two lines. + assertEquals(100, o.setPositionAndGet(i, i).get(), 0); + assertEquals(80, o.setPositionAndGet(i, 30).get(), 0); + } + // The steep line is a digital line of slope 3 at a direction between the + // sampled orientations, so it is mostly but not exactly preserved. + var kept = 0; + for (var i = 5; i < 35; i++) + if (o.setPositionAndGet(30 + (i - 5) / 3, i).get() == 60) kept++; + assertTrue(kept >= 24, "kept " + kept); + for (var x = 3; x < 6; x++) + for (var y = 20; y < 23; y++) + assertEquals(0, o.setPositionAndGet(x + 10, y).get(), 0); + // Opening is anti-extensive. + final var ic = in.cursor(); + final var oc = out.cursor(); + while (ic.hasNext()) + assertTrue(oc.next().get() <= ic.next().get()); + } + + /** Closing and min-combination are dual to opening and max-combination. */ + @Test + public void testDuality() { + final var in = random(25, 20, 2); + final var neg = ArrayImgs.doubles(25, 20); + negate(in, neg); + final var open = ArrayImgs.doubles(25, 20); + ops.op("morphology.directionalFilter").input(in, "opening", 6.0, 8, "max") + .output(open).compute(); + final var close = ArrayImgs.doubles(25, 20); + ops.op("morphology.directionalFilter").input(neg, "closing", 6.0, 8, "min") + .output(close).compute(); + final var negClose = ArrayImgs.doubles(25, 20); + negate(close, negClose); + assertImagesEqual(open, negClose, 1e-12); + } + + @Test + public void testMeanAndMedian() { + final var in = ArrayImgs.doubles(12, 10); + in.forEach(t -> t.set(7)); + for (final var op : new String[] { "mean", "median" }) { + final var out = ArrayImgs.doubles(12, 10); + ops.op("morphology.directionalFilter").input(in, op, 5.0, 4).output(out) + .compute(); + out.forEach(t -> assertEquals(7, t.get(), 1e-12)); + } + + // The max of directional means is at least the isotropic mean along any + // sampled line, e.g. the horizontal one. + final var rnd = random(16, 16, 3); + final var out = ArrayImgs.doubles(16, 16); + ops.op("morphology.directionalFilter").input(rnd, "mean", 5.0, 4).output( + out).compute(); + final var horiz = ArrayImgs.doubles(16, 16); + ops.op("filter.mean").input(rnd, new HorizontalLineShape(2, 0, false), + new OutOfBoundsBorderFactory<>()).output(horiz).compute(); + final var hc = horiz.cursor(); + final var oc = out.cursor(); + while (hc.hasNext()) + assertTrue(oc.next().get() >= hc.next().get() - 1e-12); + } + + /** On a 3D image, each plane is filtered independently. */ + @Test + public void testPlaneWise() { + final var in = ArrayImgs.doubles(10, 9, 3); + final var rnd = new Random(4); + in.forEach(t -> t.set(rnd.nextInt(50))); + final var out = ArrayImgs.doubles(10, 9, 3); + ops.op("morphology.directionalFilter").input(in, "opening", 5.0, 6) + .output(out).compute(); + for (var z = 0; z < 3; z++) { + final var plane = ArrayImgs.doubles(10, 9); + ops.op("morphology.directionalFilter").input(Views.hyperSlice(in, 2, z), + "opening", 5.0, 6).output(plane).compute(); + assertImagesEqual(plane, Views.hyperSlice(out, 2, z), 0); + } + } + + @Test + public void testInvalidArguments() { + final var in = ArrayImgs.doubles(5, 5); + final var out = ArrayImgs.doubles(5, 5); + assertThrows(IllegalArgumentException.class, () -> ops.op( + "morphology.directionalFilter").input(in, "blur", 5.0, 4).output(out) + .compute()); + assertThrows(IllegalArgumentException.class, () -> ops.op( + "morphology.directionalFilter").input(in, "opening", 5.0, 4, "mean") + .output(out).compute()); + } + + private static RandomAccessibleInterval random(final long w, + final long h, final long seed) + { + final var rnd = new Random(seed); + final var img = ArrayImgs.doubles(w, h); + img.forEach(t -> t.set(rnd.nextInt(100))); + return img; + } + + private static void negate(final RandomAccessibleInterval in, + final RandomAccessibleInterval out) + { + final var ic = Views.flatIterable(in).cursor(); + final var oc = Views.flatIterable(out).cursor(); + while (ic.hasNext()) + oc.next().set(-ic.next().get()); + } + + private static void assertImagesEqual( + final RandomAccessibleInterval expected, + final RandomAccessibleInterval actual, final double tol) + { + final var ec = Views.flatIterable(expected).cursor(); + final var ac = Views.flatIterable(actual).cursor(); + while (ec.hasNext()) + assertEquals(ec.next().get(), ac.next().get(), tol); + } +} diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/morphology/geodesic/GeodesicDiametersTest.java b/scijava-ops-image/src/test/java/org/scijava/ops/image/morphology/geodesic/GeodesicDiametersTest.java new file mode 100644 index 000000000..736410691 --- /dev/null +++ b/scijava-ops-image/src/test/java/org/scijava/ops/image/morphology/geodesic/GeodesicDiametersTest.java @@ -0,0 +1,230 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.morphology.geodesic; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +import net.imglib2.Point; +import net.imglib2.img.Img; +import net.imglib2.img.array.ArrayImgs; +import net.imglib2.roi.labeling.ImgLabeling; +import net.imglib2.type.numeric.integer.IntType; + +import org.junit.jupiter.api.Test; +import org.scijava.ops.image.AbstractOpTest; +import org.scijava.types.Nil; + +/** + * Tests {@link GeodesicDiameters}. + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +public class GeodesicDiametersTest extends AbstractOpTest { + + private static final Nil> DIAMETERS = new Nil<>() {}; + private static final Nil>> PATHS = new Nil<>() {}; + + /** An L shape (1), a U shape (2), a disk (3) and a staircase line (4). */ + private static final String[] SHAPES = { // + "000000000000000000000000", // + "001100000000000022000220", // + "001100003330000022000220", // + "001100033333000022000220", // + "001100333333300022000220", // + "001100333333300022000220", // + "001100333333300022000220", // + "001100033333000022000220", // + "001100003330000022222220", // + "001100000000000022222220", // + "001100000000000000000000", // + "001111111111111000000000", // + "001111111111111000000000", // + "000000000000000000000000", // + "000044400000000000000000", // + "000000444000000000000000", // + "000000004440000000000000", // + "000000000044400000000000", // + "000000000000400000000000", // + "000000000000000000000000" }; + + static ImgLabeling labeling(final String... rows) { + final var index = ArrayImgs.ints(rows[0].length(), rows.length); + final var ra = index.randomAccess(); + var max = 0; + for (var y = 0; y < rows.length; y++) { + for (var x = 0; x < rows[y].length(); x++) { + final var v = rows[y].charAt(x) - '0'; + ra.setPositionAndGet(x, y).set(v); + max = Math.max(max, v); + } + } + return labeling(index, max); + } + + static ImgLabeling labeling(final Img index, + final int max) + { + return ImgLabeling.fromImageAndLabels(index, IntStream.rangeClosed(1, max) + .boxed().collect(Collectors.toList())); + } + + private Map diameters( + final ImgLabeling labeling, final double[] spacing, + final Integer radius) + { + return ops.op("labeling.geodesicDiameter").input(labeling, spacing, + radius).outType(DIAMETERS).apply(); + } + + /** + * Compares with MorphoLibJ 1.6.4 ({@code GeodesicDiameter} with the + * {@code QUASI_EUCLIDEAN} chamfer mask, whose weights 1 and √2 equal the + * radius 1 neighborhood), run on the same image. MorphoLibJ computes in + * single precision. + */ + @Test + public void testRegression() { + final var d = diameters(labeling(SHAPES), null, 1); + assertEquals(22.656853103999072, d.get(1), 1e-5); + assertEquals(19.071066284541065, d.get(2), 1e-5); + assertEquals(8.242640877131397, d.get(3), 1e-5); + assertEquals(11.071067238215381, d.get(4), 1e-5); + // MorphoLibJ with pixel size 0.5 gives half of each value. + final var c = diameters(labeling(SHAPES), new double[] { 0.5, 0.5 }, 1); + assertEquals(11.328426551999536, c.get(1), 1e-5); + assertEquals(4.121320438565698, c.get(3), 1e-5); + } + + @Test + public void testAnalytic() { + final var d = diameters(labeling(SHAPES), null, null); + // L shape: a path from (2, 1) to (14, 12) must pass the inner corner at + // (3.5, 10.5), which bounds its length from below; knight moves shorten it + // compared to the radius 1 neighborhood. + final var lower = Math.hypot(1.5, 9.5) + Math.hypot(10.5, 1.5) + Math.sqrt( + 2); + assertTrue(d.get(1) > lower && d.get(1) < 22.656853103999072, // + () -> "L shape: " + d.get(1)); + // U shape: geodesic diameter far exceeds the Euclidean distance (6) of + // its extremities. + assertTrue(d.get(2) > 18); + // Anisotropic spacing stretches a horizontal bar. + final var bar = labeling("000000000000", "011111111110", "000000000000"); + assertEquals(9 + Math.sqrt(2), diameters(bar, null, null).get(1), 1e-9); + assertEquals(18 + Math.sqrt(4 + 9), diameters(bar, new double[] { 2, 3 }, + null).get(1), 1e-9); + } + + @Test + public void testSinglePixelAndDisconnected() { + final var d = diameters(labeling("0000000", "0100202", "0000000"), null, + null); + assertEquals(Math.sqrt(2), d.get(1), 1e-12); + assertEquals(Double.POSITIVE_INFINITY, d.get(2)); + } + + /** Compares with MorphoLibJ 1.6.4 {@code GeodesicDiameter3D}. */ + @Test + public void test3D() { + final var index = ArrayImgs.ints(12, 5, 4); + final var ra = index.randomAccess(); + for (var x = 1; x <= 8; x++) + ra.setPositionAndGet(x, 2, 1).set(1); + ra.setPositionAndGet(10, 4, 3).set(2); + final var d = diameters(labeling(index, 2), null, null); + assertEquals(8.732050807568877, d.get(1), 1e-9); + assertEquals(1.7320508075688772, d.get(2), 1e-9); + } + + @Test + public void testPaths() { + final var labeling = labeling(SHAPES); + final Map> paths = ops.op( + "labeling.geodesicDiameterPath").input(labeling, null, 1).outType(PATHS) + .apply(); + final var diameters = diameters(labeling, null, 1); + for (final var label : paths.keySet()) { + final var path = paths.get(label); + var length = Math.sqrt(2); + final var ra = labeling.randomAccess(); + for (var i = 0; i < path.size(); i++) { + final var p = path.get(i); + assertTrue(ra.setPositionAndGet(p).contains(label)); + if (i == 0) continue; + final var q = path.get(i - 1); + final var dx = Math.abs(p.getLongPosition(0) - q.getLongPosition(0)); + final var dy = Math.abs(p.getLongPosition(1) - q.getLongPosition(1)); + assertTrue(dx <= 1 && dy <= 1 && dx + dy > 0); + length += Math.hypot(dx, dy); + } + assertEquals(diameters.get(label), length, 1e-9); + } + // The extremities of the L shape, as found by MorphoLibJ. + final var l = paths.get(1); + final var ends = List.of(l.get(0), l.get(l.size() - 1)); + assertTrue(ends.contains(new Point(14, 12))); + assertTrue(ends.contains(new Point(2, 1))); + } + + /** + * A square with a thin tail: the central pixel is the center of the + * square, so the first extremity (farthest from it) is the tip of the tail, + * and the second one is a corner of the square on the opposite side. + */ + @Test + public void testPathOrder() { + final var labeling = labeling( // + "000000000000000000000000", // + "011111110000000000000000", // + "011111110000000000000000", // + "011111110000000000000000", // + "011111111111111111111110", // + "011111110000000000000000", // + "011111110000000000000000", // + "011111110000000000000000", // + "000000000000000000000000"); + final Map> paths = ops.op( + "labeling.geodesicDiameterPath").input(labeling, null, 1).outType(PATHS) + .apply(); + final var path = paths.get(1); + assertEquals(new Point(22, 4), path.get(0)); + final var last = path.get(path.size() - 1); + assertEquals(1, last.getLongPosition(0)); + assertTrue(last.getLongPosition(1) == 1 || last.getLongPosition(1) == 7, + last.toString()); + } +} diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/morphology/geodesic/GeodesicDistanceMapsTest.java b/scijava-ops-image/src/test/java/org/scijava/ops/image/morphology/geodesic/GeodesicDistanceMapsTest.java new file mode 100644 index 000000000..059ed57e1 --- /dev/null +++ b/scijava-ops-image/src/test/java/org/scijava/ops/image/morphology/geodesic/GeodesicDistanceMapsTest.java @@ -0,0 +1,168 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.morphology.geodesic; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.img.array.ArrayImgs; +import net.imglib2.type.logic.BitType; +import net.imglib2.type.numeric.real.DoubleType; + +import org.junit.jupiter.api.Test; +import org.scijava.ops.image.AbstractOpTest; +import org.scijava.types.Nil; + +/** + * Tests {@link GeodesicDistanceMaps}. + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +public class GeodesicDistanceMapsTest extends AbstractOpTest { + + private static final Nil> MAP = + new Nil<>() + {}; + + private static double at(final RandomAccessibleInterval map, + final long... pos) + { + return map.getAt(pos).get(); + } + + private RandomAccessibleInterval map(final Object marker, + final Object mask, final double[] spacing, final Integer radius) + { + return ops.op("morphology.geodesicDistanceMap").input(marker, mask, + spacing, radius).outType(MAP).apply(); + } + + /** Without obstacles, the radius 1 map is the octagonal chamfer distance. */ + @Test + public void testOpenSquare() { + final var mask = ArrayImgs.bits(9, 7); + mask.forEach(BitType::setOne); + final var marker = ArrayImgs.bits(9, 7); + marker.getAt(0, 0).setOne(); + final var map = map(marker, mask, null, 1); + for (var y = 0; y < 7; y++) { + for (var x = 0; x < 9; x++) { + final var lo = Math.min(x, y); + final var hi = Math.max(x, y); + assertEquals(hi - lo + Math.sqrt(2) * lo, at(map, x, y), 1e-9); + } + } + // With radius 2, knight moves give the exact Euclidean distance along + // their direction. + final var map2 = map(marker, mask, null, null); + assertEquals(3 * Math.sqrt(5), at(map2, 6, 3), 1e-9); + assertEquals(Math.sqrt(2) * 6, at(map2, 6, 6), 1e-9); + } + + /** Paths go around a wall; pixels outside the mask are NaN. */ + @Test + public void testWall() { + final var mask = ArrayImgs.bits(7, 5); + mask.forEach(BitType::setOne); + for (var y = 1; y < 5; y++) + mask.getAt(3, y).setZero(); + final var marker = ArrayImgs.bits(7, 5); + marker.getAt(0, 4).setOne(); + final var map = map(marker, mask, null, 1); + assertTrue(Double.isNaN(at(map, 3, 2))); + // Through the gap at (3, 0): 1 step + 3 diagonals, then 3 steps + 1 + // diagonal. + assertEquals(4 + 4 * Math.sqrt(2), at(map, 4, 4), 1e-9); + // Knight moves cannot jump over the wall, so the path still passes + // through the gap. + final var map2 = map(marker, mask, null, 2); + assertTrue(at(map2, 4, 4) > Math.hypot(3, 3.5) + Math.hypot(1, 3.5)); + assertTrue(at(map2, 4, 4) < at(map, 4, 4)); + } + + /** Distances from the center of a disk approximate Euclidean distances. */ + @Test + public void testIsotropy() { + final var size = 61; + final var c = size / 2; + final var mask = ArrayImgs.bits(size, size); + mask.forEach(BitType::setOne); + final var marker = ArrayImgs.bits(size, size); + marker.getAt(c, c).setOne(); + final var map1 = map(marker, mask, null, 1); + final var map2 = map(marker, mask, null, 2); + double max1 = 0, max2 = 0; + for (var y = 0; y < size; y++) { + for (var x = 0; x < size; x++) { + final var e = Math.hypot(x - c, y - c); + if (e < 10) continue; + max1 = Math.max(max1, at(map1, x, y) / e - 1); + max2 = Math.max(max2, at(map2, x, y) / e - 1); + assertTrue(at(map1, x, y) >= e - 1e-9); + assertTrue(at(map2, x, y) >= e - 1e-9); + } + } + assertTrue(max1 < 0.083 && max1 > 0.07, "radius 1: " + max1); + assertTrue(max2 < 0.028, "radius 2: " + max2); + } + + @Test + public void testSpacing3D() { + final var mask = ArrayImgs.bits(4, 3, 5); + mask.forEach(BitType::setOne); + final var marker = ArrayImgs.bits(4, 3, 5); + marker.getAt(0, 0, 0).setOne(); + final var map = map(marker, mask, new double[] { 1, 1, 2.5 }, 1); + assertEquals(10, at(map, 0, 0, 4), 1e-9); + assertEquals(Math.sqrt(1 + 1 + 6.25), at(map, 1, 1, 1), 1e-9); + assertEquals(3, at(map, 3, 0, 0), 1e-9); + } + + /** Paths never cross from one label into a touching one. */ + @Test + public void testLabeling() { + final var labeling = GeodesicDiametersTest.labeling( // + "1111222", // + "1111222", // + "0000222"); + final var marker = ArrayImgs.bits(7, 3); + marker.getAt(0, 0).setOne(); + final var map = map(marker, labeling, null, 1); + assertEquals(3, at(map, 3, 0), 1e-9); + assertEquals(Double.POSITIVE_INFINITY, at(map, 4, 0)); + assertTrue(Double.isNaN(at(map, 0, 2))); + marker.getAt(6, 2).setOne(); + final var map2 = map(marker, labeling, null, 1); + assertEquals(2, at(map2, 6, 0), 1e-9); + assertEquals(3, at(map2, 3, 0), 1e-9); + } +} diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/morphology/gradient/MorphologicalGradientsTest.java b/scijava-ops-image/src/test/java/org/scijava/ops/image/morphology/gradient/MorphologicalGradientsTest.java new file mode 100644 index 000000000..cd371c1c9 --- /dev/null +++ b/scijava-ops-image/src/test/java/org/scijava/ops/image/morphology/gradient/MorphologicalGradientsTest.java @@ -0,0 +1,122 @@ +/* #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.morphology.gradient; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.Random; + +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.algorithm.morphology.Dilation; +import net.imglib2.algorithm.morphology.Erosion; +import net.imglib2.algorithm.neighborhood.HyperSphereShape; +import net.imglib2.algorithm.neighborhood.RectangleShape; +import net.imglib2.algorithm.neighborhood.Shape; +import net.imglib2.img.Img; +import net.imglib2.img.array.ArrayImgs; +import net.imglib2.type.numeric.integer.UnsignedByteType; +import net.imglib2.type.numeric.real.FloatType; +import net.imglib2.view.Views; + +import org.junit.jupiter.api.Test; +import org.scijava.ops.image.AbstractOpTest; + +/** + * Tests {@link MorphologicalGradients}. + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +public class MorphologicalGradientsTest extends AbstractOpTest { + + @Test + public void testStepEdge() { + final var img = ArrayImgs.unsignedBytes(8, 5); + Views.interval(img, new long[] { 4, 0 }, new long[] { 7, 4 }).forEach( + p -> p.set(10)); + final Shape shape = new RectangleShape(1, false); + final var gradient = apply("morphology.gradient", img, shape); + final var internal = apply("morphology.internalGradient", img, shape); + final var external = apply("morphology.externalGradient", img, shape); + final var laplacian = apply("morphology.laplacian", img, shape); + final var g = gradient.randomAccess(); + final var i = internal.randomAccess(); + final var e = external.randomAccess(); + final var l = laplacian.randomAccess(); + for (var y = 0; y < 5; y++) { + for (var x = 0; x < 8; x++) { + final var edge = x == 3 || x == 4; + assertEquals(edge ? 10 : 0, g.setPositionAndGet(x, y).get()); + assertEquals(x == 4 ? 10 : 0, i.setPositionAndGet(x, y).get()); + assertEquals(x == 3 ? 10 : 0, e.setPositionAndGet(x, y).get()); + assertEquals(x == 3 ? 10 : x == 4 ? -10 : 0, l.setPositionAndGet(x, y) + .get()); + } + } + } + + @Test + public void testRandom() { + final var img = ArrayImgs.unsignedBytes(13, 11, 4); + final var random = new Random(42); + img.forEach(p -> p.set(random.nextInt(256))); + final Shape shape = new HyperSphereShape(2); + // Reference dilation and erosion, ignoring the pixels outside the image. + final Img dil = Dilation.dilate(img, shape, 1); + final Img ero = Erosion.erode(img, shape, 1); + final var gradient = apply("morphology.gradient", img, shape); + final var internal = apply("morphology.internalGradient", img, shape); + final var external = apply("morphology.externalGradient", img, shape); + final var laplacian = apply("morphology.laplacian", img, shape); + final var c = img.localizingCursor(); + final var d = dil.randomAccess(); + final var e = ero.randomAccess(); + final var g = gradient.randomAccess(); + final var in = internal.randomAccess(); + final var ex = external.randomAccess(); + final var l = laplacian.randomAccess(); + while (c.hasNext()) { + final var f = c.next().get(); + final var dv = d.setPositionAndGet(c).get(); + final var ev = e.setPositionAndGet(c).get(); + assertEquals(dv - ev, g.setPositionAndGet(c).get()); + assertEquals(f - ev, in.setPositionAndGet(c).get()); + assertEquals(dv - f, ex.setPositionAndGet(c).get()); + assertEquals(dv + ev - 2 * f, l.setPositionAndGet(c).get()); + } + } + + private RandomAccessibleInterval apply(final String name, + final Img img, final Shape shape) + { + final var out = ArrayImgs.floats(img.dimensionsAsLongArray()); + ops.op(name).input(img, shape).output(out).compute(); + return out; + } +} diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/morphology/interpolate/InterpolateSlicesTest.java b/scijava-ops-image/src/test/java/org/scijava/ops/image/morphology/interpolate/InterpolateSlicesTest.java new file mode 100644 index 000000000..fc1e4feb9 --- /dev/null +++ b/scijava-ops-image/src/test/java/org/scijava/ops/image/morphology/interpolate/InterpolateSlicesTest.java @@ -0,0 +1,229 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.morphology.interpolate; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.img.array.ArrayImg; +import net.imglib2.img.array.ArrayImgs; +import net.imglib2.img.basictypeaccess.array.LongArray; +import net.imglib2.loops.LoopBuilder; +import net.imglib2.type.logic.BitType; +import net.imglib2.view.Views; + +import org.junit.jupiter.api.Test; +import org.scijava.ops.image.AbstractOpTest; +import org.scijava.types.Nil; + +/** + * Tests {@code morphology.interpolateSlices}. + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +public class InterpolateSlicesTest extends AbstractOpTest { + + private static final Nil> OUT = + new Nil<>() + {}; + + private static ArrayImg sphere(final int size, + final double radius) + { + final var img = ArrayImgs.bits(size, size, size); + final var c = img.localizingCursor(); + final double m = (size - 1) / 2.0; + while (c.hasNext()) { + c.fwd(); + var r2 = 0.0; + for (var d = 0; d < 3; d++) + r2 += Math.pow(c.getDoublePosition(d) - m, 2); + c.get().set(r2 <= radius * radius); + } + return img; + } + + @Test + public void testSphere() { + final var size = 41; + final var truth = sphere(size, 18); + // Keep every fourth slice along z. + final var sparse = ArrayImgs.bits(size, size, size); + final var sc = sparse.localizingCursor(); + final var tr = truth.randomAccess(); + while (sc.hasNext()) { + sc.fwd(); + if (sc.getLongPosition(2) % 4 == 0) sc.get().set(tr.setPositionAndGet(sc) + .get()); + } + final RandomAccessibleInterval out = ops.op( + "morphology.interpolateSlices").input(sparse).outType(OUT).apply(); + + long both = 0, a = 0, b = 0; + final var oc = Views.flatIterable(out).cursor(); + for (final var t : truth) { + final var o = oc.next().get(); + if (t.get()) a++; + if (o) b++; + if (o && t.get()) both++; + } + final var dice = 2.0 * both / (a + b); + assertTrue(dice > 0.95, "Dice " + dice); + + // Annotated slices are unchanged. + final var or = out.randomAccess(); + final var sr = sparse.randomAccess(); + for (var x = 0; x < size; x++) + for (var y = 0; y < size; y++) + assertEquals(sr.setPositionAndGet(x, y, 20).get(), or + .setPositionAndGet(x, y, 20).get()); + } + + @Test + public void testMorphBetweenSquares() { + // A 2 x 2 square on slice 0 and a 6 x 6 square on slice 4 (both centred, + // interpolated along dimension 0); the middle slice gets a 4 x 4 square. + final var mask = ArrayImgs.bits(5, 10, 10); + final var ra = mask.randomAccess(); + for (var y = 0; y < 10; y++) { + for (var x = 0; x < 10; x++) { + ra.setPositionAndGet(0, x, y).set(x >= 4 && x <= 5 && y >= 4 && + y <= 5); + ra.setPositionAndGet(4, x, y).set(x >= 2 && x <= 7 && y >= 2 && + y <= 7); + } + } + final RandomAccessibleInterval out = ops.op( + "morphology.interpolateSlices").input(mask, 0).outType(OUT).apply(); + final var oa = out.randomAccess(); + for (var y = 0; y < 10; y++) { + for (var x = 0; x < 10; x++) { + final var inside = x >= 3 && x <= 6 && y >= 3 && y <= 6; + assertEquals(inside, oa.setPositionAndGet(2, x, y).get(), "at " + x + + ", " + y); + } + } + } + + /** + * The calibration has one value per dimension of the mask; the value along + * the interpolation dimension does not matter, and a uniform scaling within + * the slices does not change the result. + */ + @Test + public void testCalibration() { + final var mask = ArrayImgs.bits(5, 10, 10); + final var ra = mask.randomAccess(); + for (var y = 0; y < 10; y++) { + for (var x = 0; x < 10; x++) { + ra.setPositionAndGet(0, x, y).set(x >= 4 && x <= 5 && y >= 4 && + y <= 5); + ra.setPositionAndGet(4, x, y).set(x >= 2 && x <= 7 && y >= 2 && + y <= 7); + } + } + final RandomAccessibleInterval plain = ops.op( + "morphology.interpolateSlices").input(mask, 0).outType(OUT).apply(); + for (final var calibration : new double[][] { { 7, 1, 1 }, { 1, 2, 2 } }) { + final RandomAccessibleInterval calibrated = ops.op( + "morphology.interpolateSlices").input(mask, 0, calibration).outType( + OUT).apply(); + final var c = Views.flatIterable(calibrated).cursor(); + for (final var p : Views.flatIterable(plain)) + assertEquals(p.get(), c.next().get()); + } + assertThrows(IllegalArgumentException.class, () -> ops.op( + "morphology.interpolateSlices").input(mask, 0, new double[] { 1, 1 }) + .outType(OUT).apply()); + } + + @Test + public void testSingleAnnotatedSlice() { + final var mask = ArrayImgs.bits(6, 6, 4); + mask.randomAccess().setPositionAndGet(2, 3, 1).set(true); + final RandomAccessibleInterval out = ops.op( + "morphology.interpolateSlices").input(mask).outType(OUT).apply(); + final var mc = mask.cursor(); + for (final var t : Views.flatIterable(out)) + assertEquals(mc.next().get(), t.get()); + } + + @Test + public void testNonZeroMin() { + // Squares of growing size on every third slice along z, in a larger + // image with foreground outside of the cropped region. + final var big = ArrayImgs.bits(18, 16, 13); + final var c = big.localizingCursor(); + while (c.hasNext()) { + c.fwd(); + final long x = c.getLongPosition(0), y = c.getLongPosition(1), z = c + .getLongPosition(2); + final var r = 1 + z / 3; + final var inCrop = x >= 2 && x <= 15 && y >= 3 && y <= 14 && z >= 1; + if (!inCrop) c.get().set((x + y) % 2 == 0); + else if ((z - 1) % 3 == 0) c.get().set(Math.abs(x - 8) <= r && Math.abs( + y - 9) <= r); + } + final var crop = Views.interval(big, new long[] { 2, 3, 1 }, new long[] { + 15, 14, 12 }); + checkNonZeroMin(crop, null); + checkNonZeroMin(crop, 2); + checkNonZeroMin(Views.translate(crop, 30, -20, 7), null); + } + + private void checkNonZeroMin(final RandomAccessibleInterval view, + final Integer dim) + { + final var copy = ArrayImgs.bits(view.dimensionsAsLongArray()); + LoopBuilder.setImages(view, copy).forEachPixel((s, t) -> t.set(s)); + final RandomAccessibleInterval expected = ops.op( + "morphology.interpolateSlices").input(copy, dim).outType(OUT).apply(); + final RandomAccessibleInterval actual = ops.op( + "morphology.interpolateSlices").input(view, dim).outType(OUT).apply(); + assertArrayEquals(view.minAsLongArray(), actual.minAsLongArray()); + assertArrayEquals(view.maxAsLongArray(), actual.maxAsLongArray()); + final var e = Views.flatIterable(expected).cursor(); + var filled = 0; + for (final var a : Views.flatIterable(actual)) { + final var ev = e.next().get(); + assertEquals(ev, a.get()); + if (ev) filled++; + } + // Sanity check: the interpolation filled more than the annotated slices. + var annotated = 0; + for (final var b : copy) + if (b.get()) annotated++; + assertTrue(filled > annotated, filled + " <= " + annotated); + } +} diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/morphology/reconstruction/ReconstructionOracleTest.java b/scijava-ops-image/src/test/java/org/scijava/ops/image/morphology/reconstruction/ReconstructionOracleTest.java new file mode 100644 index 000000000..edd425e8b --- /dev/null +++ b/scijava-ops-image/src/test/java/org/scijava/ops/image/morphology/reconstruction/ReconstructionOracleTest.java @@ -0,0 +1,448 @@ +/* #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.morphology.reconstruction; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; + +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.algorithm.neighborhood.DiamondShape; +import net.imglib2.algorithm.neighborhood.RectangleShape; +import net.imglib2.algorithm.neighborhood.Shape; +import net.imglib2.img.Img; +import net.imglib2.img.array.ArrayImgs; +import net.imglib2.type.BooleanType; +import net.imglib2.type.numeric.RealType; +import net.imglib2.type.numeric.integer.UnsignedByteType; +import net.imglib2.view.Views; + +import org.junit.jupiter.api.Test; +import org.scijava.ops.image.AbstractOpTest; + +/** + * Compares the ops of this package with MorphoLibJ on small 8-bit images. + *

+ * The expected arrays were produced by running MorphoLibJ 1.6.4 + * ({@code inra.ijpb.morphology.Reconstruction}, {@code Reconstruction3D}, + * {@code MinimaAndMaxima} and {@code MinimaAndMaxima3D}) as a black box on the + * same pseudo-random images ({@code F2}: 9x7 and {@code F3}: 5x4x3, values 0 + * to 9, so with many plateaus and ties). Binary results are 0 or 255. + * Extended extrema use {@code h = 2}. MorphoLibJ {@code killBorders} and + * {@code fillHoles} have no connectivity parameter and use face connectivity. + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +public class ReconstructionOracleTest extends AbstractOpTest { + + private static final long[] DIMS2 = { 9, 7 }; + private static final long[] DIMS3 = { 5, 4, 3 }; + + @Test + public void testReconstruction2D() { + checkReconstruction(DIMS2, F2, SEEDS2, SEEDS_HIGH2, face(), REC_DIL2_C4, + REC_ERO2_C4); + checkReconstruction(DIMS2, F2, SEEDS2, SEEDS_HIGH2, full(), REC_DIL2_C8, + REC_ERO2_C8); + } + + @Test + public void testReconstruction3D() { + checkReconstruction(DIMS3, F3, SEEDS3, SEEDS_HIGH3, face(), REC_DIL3_C6, + REC_ERO3_C6); + checkReconstruction(DIMS3, F3, SEEDS3, SEEDS_HIGH3, full(), REC_DIL3_C26, + REC_ERO3_C26); + } + + @Test + public void testRegionalExtrema() { + checkBinary("morphology.regionalMaxima", DIMS2, F2, null, face(), + RMAX2_C4); + checkBinary("morphology.regionalMaxima", DIMS2, F2, null, full(), + RMAX2_C8); + checkBinary("morphology.regionalMinima", DIMS2, F2, null, face(), + RMIN2_C4); + checkBinary("morphology.regionalMinima", DIMS2, F2, null, full(), + RMIN2_C8); + checkBinary("morphology.regionalMaxima", DIMS3, F3, null, face(), + RMAX3_C6); + checkBinary("morphology.regionalMaxima", DIMS3, F3, null, full(), + RMAX3_C26); + checkBinary("morphology.regionalMinima", DIMS3, F3, null, face(), + RMIN3_C6); + checkBinary("morphology.regionalMinima", DIMS3, F3, null, full(), + RMIN3_C26); + } + + @Test + public void testExtendedExtrema() { + checkBinary("morphology.extendedMaxima", DIMS2, F2, 2.0, face(), EMAX2_C4); + checkBinary("morphology.extendedMaxima", DIMS2, F2, 2.0, full(), EMAX2_C8); + checkBinary("morphology.extendedMinima", DIMS2, F2, 2.0, face(), EMIN2_C4); + checkBinary("morphology.extendedMinima", DIMS2, F2, 2.0, full(), EMIN2_C8); + checkBinary("morphology.extendedMaxima", DIMS3, F3, 2.0, face(), EMAX3_C6); + checkBinary("morphology.extendedMaxima", DIMS3, F3, 2.0, full(), + EMAX3_C26); + checkBinary("morphology.extendedMinima", DIMS3, F3, 2.0, face(), EMIN3_C6); + checkBinary("morphology.extendedMinima", DIMS3, F3, 2.0, full(), + EMIN3_C26); + } + + @Test + public void testImposeExtrema() { + checkImpose(DIMS2, F2, MARKERS2, face(), IMIN2_C4); + checkImpose(DIMS2, F2, MARKERS2, full(), IMIN2_C8); + checkImpose(DIMS3, F3, MARKERS3, face(), IMIN3_C6); + checkImpose(DIMS3, F3, MARKERS3, full(), IMIN3_C26); + } + + @Test + public void testKillBordersAndFillHoles() { + checkGray("morphology.killBorders", DIMS2, F2, face(), KILL2); + checkGray("morphology.fillHoles", DIMS2, F2, face(), FILL2); + checkGray("morphology.killBorders", DIMS3, F3, face(), KILL3); + checkGray("morphology.fillHoles", DIMS3, F3, face(), FILL3); + } + + private static Shape face() { + return new DiamondShape(1); + } + + private static Shape full() { + return new RectangleShape(1, false); + } + + private void checkReconstruction(final long[] dims, final int[] mask, + final int[] seeds, final int[] seedsHigh, final Shape connectivity, + final int[] expectedDilation, final int[] expectedErosion) + { + final var out = image(dims, new int[mask.length]); + ops.op("morphology.reconstructByDilation").input(image(dims, seeds), image( + dims, mask), connectivity).output(out).compute(); + assertArrayEquals(expectedDilation, values(out)); + ops.op("morphology.reconstructByErosion").input(image(dims, seedsHigh), + image(dims, mask), connectivity).output(out).compute(); + assertArrayEquals(expectedErosion, values(out)); + } + + private void checkBinary(final String name, final long[] dims, + final int[] input, final Double h, final Shape connectivity, + final int[] expected) + { + final var out = ArrayImgs.bits(dims); + if (h == null) { + ops.op(name).input(image(dims, input), connectivity).output(out) + .compute(); + } + else { + ops.op(name).input(image(dims, input), h, connectivity).output(out) + .compute(); + } + assertArrayEquals(expected, values(out), name); + } + + private void checkImpose(final long[] dims, final int[] input, + final int[] markers, final Shape connectivity, final int[] expectedMinima) + { + final var marks = ArrayImgs.bits(dims); + var i = 0; + for (final var b : Views.flatIterable(marks)) + b.set(markers[i++] != 0); + final var out = image(dims, new int[input.length]); + ops.op("morphology.imposeMinima").input(image(dims, input), marks, + connectivity).output(out).compute(); + assertArrayEquals(expectedMinima, values(out)); + + // Note: the imposeMaxima result of MorphoLibJ 1.6.4 on these images has + // regional maxima outside of the markers, so imposeMaxima is checked + // against its duality with imposeMinima instead. + final var inverted = new int[input.length]; + for (var j = 0; j < input.length; j++) + inverted[j] = 255 - input[j]; + ops.op("morphology.imposeMinima").input(image(dims, inverted), marks, + connectivity).output(out).compute(); + final var expectedMaxima = values(out); + for (var j = 0; j < input.length; j++) + expectedMaxima[j] = 255 - expectedMaxima[j]; + ops.op("morphology.imposeMaxima").input(image(dims, input), marks, + connectivity).output(out).compute(); + assertArrayEquals(expectedMaxima, values(out)); + } + + private void checkGray(final String name, final long[] dims, + final int[] input, final Shape connectivity, final int[] expected) + { + final var out = image(dims, new int[input.length]); + ops.op(name).input(image(dims, input), connectivity).output(out).compute(); + assertArrayEquals(expected, values(out), name); + } + + private static Img image(final long[] dims, + final int[] values) + { + final var bytes = new byte[values.length]; + for (var i = 0; i < values.length; i++) + bytes[i] = (byte) values[i]; + return ArrayImgs.unsignedBytes(bytes, dims); + } + + private static int[] values(final RandomAccessibleInterval image) { + final var result = new int[(int) Views.iterable(image).size()]; + var i = 0; + for (final var t : Views.flatIterable(image)) { + if (t instanceof BooleanType) result[i++] = ((BooleanType) t).get() + ? 255 : 0; + else result[i++] = (int) ((RealType) t).getRealDouble(); + } + return result; + } + + // -- Expected values (generated by MorphoLibJ 1.6.4) -- + + private static final int[] F2 = { + 4, 3, 2, 1, 0, 0, 3, 4, 8, 3, 9, 7, 9, 0, 5, 8, 3, 7, 6, 5, + 0, 6, 1, 7, 6, 1, 7, 6, 7, 3, 4, 1, 7, 2, 4, 3, 2, 7, 4, 8, + 7, 5, 6, 0, 3, 4, 6, 9, 5, 0, 2, 2, 4, 8, 9, 5, 4, 8, 6, 4, + 3, 9, 1, + }; + private static final int[] SEEDS2 = { + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 8, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, + }; + private static final int[] SEEDS_HIGH2 = { + 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 8, 9, 9, 9, 9, + 9, 9, 9, 9, 9, 9, 9, 9, 9, 3, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, + 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, + 9, 9, 9, + }; + private static final int[] MARKERS2 = { + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 255, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, + }; + private static final int[] REC_DIL2_C4 = { + 3, 3, 2, 1, 0, 0, 3, 3, 3, 3, 5, 5, 5, 0, 5, 8, 3, 3, 5, 5, + 0, 5, 1, 6, 6, 1, 3, 5, 5, 3, 4, 1, 6, 2, 3, 3, 2, 5, 4, 5, + 5, 5, 5, 0, 3, 4, 5, 5, 5, 0, 2, 2, 3, 3, 5, 5, 4, 5, 5, 4, + 3, 3, 1, + }; + private static final int[] REC_ERO2_C4 = { + 4, 4, 4, 4, 4, 4, 4, 4, 8, 4, 9, 7, 9, 4, 5, 8, 4, 7, 6, 5, + 3, 6, 4, 7, 6, 4, 7, 6, 7, 3, 4, 4, 7, 4, 4, 4, 6, 7, 4, 8, + 7, 5, 6, 4, 4, 6, 6, 9, 5, 4, 4, 4, 4, 8, 9, 6, 6, 8, 6, 4, + 4, 9, 8, + }; + private static final int[] RMAX2_C4 = { + 255, 0, 0, 0, 0, 0, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 0, 0, 0, 0, + 0, 0, 0, 255, 0, 0, 0, 0, 255, 0, 0, 0, 255, 0, 255, 0, 0, 255, 0, 255, + 0, 0, 255, 0, 0, 0, 0, 255, 0, 0, 0, 0, 0, 255, 255, 0, 0, 255, 0, 0, + 0, 255, 0, + }; + private static final int[] RMIN2_C4 = { + 0, 0, 0, 0, 255, 255, 0, 0, 0, 255, 0, 0, 0, 255, 0, 0, 0, 0, 0, 0, + 255, 0, 0, 0, 0, 255, 0, 0, 0, 0, 0, 0, 0, 255, 0, 0, 255, 0, 0, 0, + 0, 0, 0, 255, 0, 0, 0, 0, 0, 255, 0, 0, 0, 0, 0, 0, 255, 0, 0, 0, + 0, 0, 255, + }; + private static final int[] EMAX2_C4 = { + 0, 0, 0, 0, 0, 0, 0, 0, 255, 0, 255, 255, 255, 0, 0, 255, 0, 255, 0, 0, + 0, 0, 0, 255, 255, 0, 255, 0, 0, 0, 0, 0, 255, 0, 0, 0, 0, 0, 0, 255, + 255, 0, 0, 0, 0, 0, 0, 255, 0, 0, 0, 0, 0, 255, 255, 0, 0, 255, 255, 0, + 0, 255, 0, + }; + private static final int[] EMIN2_C4 = { + 0, 0, 255, 255, 255, 255, 0, 0, 0, 0, 0, 0, 0, 255, 0, 0, 255, 0, 0, 0, + 255, 0, 255, 0, 0, 255, 0, 0, 0, 0, 0, 255, 0, 0, 0, 0, 255, 0, 0, 0, + 0, 0, 0, 255, 0, 255, 0, 0, 0, 255, 255, 255, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 255, + }; + private static final int[] IMIN2_C4 = { + 5, 4, 4, 4, 4, 4, 4, 5, 9, 5, 10, 8, 10, 4, 6, 0, 4, 8, 7, 6, + 1, 7, 4, 8, 7, 4, 8, 7, 8, 0, 5, 4, 8, 5, 5, 5, 7, 8, 5, 9, + 8, 6, 7, 5, 5, 7, 7, 10, 6, 5, 5, 5, 5, 9, 10, 7, 7, 9, 7, 5, + 5, 10, 9, + }; + private static final int[] REC_DIL2_C8 = { + 4, 3, 2, 1, 0, 0, 3, 4, 4, 3, 6, 6, 6, 0, 5, 8, 3, 4, 6, 5, + 0, 6, 1, 7, 6, 1, 4, 6, 7, 3, 4, 1, 7, 2, 4, 3, 2, 7, 4, 7, + 7, 5, 6, 0, 3, 4, 6, 7, 5, 0, 2, 2, 4, 4, 6, 5, 4, 7, 6, 4, + 3, 4, 1, + }; + private static final int[] REC_ERO2_C8 = { + 4, 4, 4, 4, 4, 4, 4, 4, 8, 4, 9, 7, 9, 4, 5, 8, 4, 7, 6, 5, + 3, 6, 4, 7, 6, 4, 7, 6, 7, 3, 4, 4, 7, 4, 4, 4, 5, 7, 4, 8, + 7, 5, 6, 4, 4, 5, 6, 9, 5, 4, 4, 4, 4, 8, 9, 5, 5, 8, 6, 4, + 4, 9, 4, + }; + private static final int[] RMAX2_C8 = { + 0, 0, 0, 0, 0, 0, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 255, 0, 0, 0, 0, 0, 0, 255, 0, 0, 0, 0, 0, + 0, 255, 0, + }; + private static final int[] RMIN2_C8 = { + 0, 0, 0, 0, 255, 255, 0, 0, 0, 0, 0, 0, 0, 255, 0, 0, 0, 0, 0, 0, + 255, 0, 0, 0, 0, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 255, 0, 0, 0, + 0, 0, 0, 255, 0, 0, 0, 0, 0, 255, 0, 0, 0, 0, 0, 0, 255, 0, 0, 0, + 0, 0, 255, + }; + private static final int[] EMAX2_C8 = { + 0, 0, 0, 0, 0, 0, 0, 0, 255, 0, 255, 255, 255, 0, 0, 255, 0, 255, 0, 0, + 0, 0, 0, 255, 0, 0, 255, 0, 255, 0, 0, 0, 255, 0, 0, 0, 0, 255, 0, 255, + 255, 0, 0, 0, 0, 0, 0, 255, 0, 0, 0, 0, 0, 255, 255, 0, 0, 255, 0, 0, + 0, 255, 0, + }; + private static final int[] EMIN2_C8 = { + 0, 0, 255, 255, 255, 255, 0, 0, 0, 0, 0, 0, 0, 255, 0, 0, 0, 0, 0, 0, + 255, 0, 255, 0, 0, 255, 0, 0, 0, 0, 0, 255, 0, 255, 0, 0, 255, 0, 0, 0, + 0, 0, 0, 255, 0, 255, 0, 0, 0, 255, 255, 255, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 255, + }; + private static final int[] IMIN2_C8 = { + 5, 4, 3, 2, 1, 1, 4, 5, 9, 4, 10, 8, 10, 1, 6, 0, 4, 8, 7, 6, + 1, 7, 2, 8, 7, 2, 8, 7, 8, 0, 5, 2, 8, 3, 5, 4, 6, 8, 5, 9, + 8, 6, 7, 3, 4, 6, 7, 10, 6, 3, 3, 3, 5, 9, 10, 6, 6, 9, 7, 5, + 4, 10, 5, + }; + private static final int[] KILL2 = { + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4, 2, 4, 0, 0, 3, 0, 0, 0, 0, + 0, 1, 0, 2, 1, 0, 0, 0, 1, 0, 0, 0, 2, 0, 1, 0, 0, 1, 0, 3, + 2, 0, 1, 0, 0, 0, 0, 3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, + }; + private static final int[] FILL2 = { + 4, 3, 2, 1, 0, 0, 3, 4, 8, 3, 9, 7, 9, 0, 5, 8, 4, 7, 6, 5, + 4, 6, 1, 7, 6, 4, 7, 6, 7, 4, 4, 1, 7, 4, 4, 3, 2, 7, 4, 8, + 7, 5, 6, 3, 3, 4, 6, 9, 5, 3, 3, 3, 4, 8, 9, 5, 4, 8, 6, 4, + 3, 9, 1, + }; + private static final int[] F3 = { + 9, 0, 4, 2, 7, 8, 2, 3, 4, 4, 2, 9, 7, 1, 0, 0, 0, 6, 0, 5, + 9, 8, 2, 1, 0, 2, 0, 6, 4, 7, 4, 5, 6, 2, 0, 3, 3, 4, 9, 2, + 0, 8, 1, 4, 8, 8, 8, 6, 4, 7, 8, 5, 8, 8, 0, 9, 9, 4, 9, 2, + }; + private static final int[] SEEDS3 = { + 9, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 6, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + }; + private static final int[] SEEDS_HIGH3 = { + 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, + 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 6, 9, 9, 9, 9, 9, 9, 9, + 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, + }; + private static final int[] MARKERS3 = { + 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 255, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + }; + private static final int[] REC_DIL3_C6 = { + 9, 0, 3, 2, 4, 8, 2, 3, 4, 4, 2, 6, 6, 1, 0, 0, 0, 6, 0, 2, + 9, 8, 2, 1, 0, 2, 0, 6, 4, 4, 4, 5, 6, 2, 0, 3, 3, 4, 6, 2, + 0, 8, 1, 4, 4, 8, 8, 6, 4, 4, 8, 5, 6, 6, 0, 8, 8, 4, 6, 2, + }; + private static final int[] REC_ERO3_C6 = { + 9, 6, 6, 6, 7, 8, 6, 6, 6, 6, 6, 9, 7, 6, 6, 6, 6, 6, 6, 6, + 9, 8, 6, 6, 6, 6, 6, 6, 6, 7, 6, 6, 6, 6, 6, 6, 6, 6, 9, 6, + 8, 8, 6, 6, 8, 8, 8, 6, 6, 7, 8, 6, 8, 8, 6, 9, 9, 6, 9, 6, + }; + private static final int[] RMAX3_C6 = { + 255, 0, 255, 0, 255, 0, 0, 0, 0, 0, 0, 255, 0, 0, 0, 0, 0, 0, 0, 255, + 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 255, 0, + 0, 0, 0, 0, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 255, 255, 0, 255, 0, + }; + private static final int[] RMIN3_C6 = { + 0, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 255, 255, 255, 0, 255, 0, + 0, 0, 0, 0, 255, 0, 255, 0, 0, 0, 0, 0, 0, 0, 255, 0, 0, 0, 0, 0, + 255, 0, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 255, 0, 0, 0, 0, 0, + }; + private static final int[] EMAX3_C6 = { + 255, 0, 0, 0, 255, 255, 0, 0, 0, 0, 0, 255, 255, 0, 0, 0, 0, 0, 0, 255, + 255, 255, 0, 0, 0, 0, 0, 0, 0, 255, 0, 0, 0, 0, 0, 0, 0, 0, 255, 0, + 0, 255, 0, 0, 255, 255, 255, 0, 0, 255, 255, 0, 255, 255, 0, 255, 255, 0, 255, 0, + }; + private static final int[] EMIN3_C6 = { + 0, 255, 0, 255, 0, 0, 255, 0, 0, 0, 255, 0, 0, 255, 255, 255, 255, 0, 255, 0, + 0, 0, 255, 255, 255, 255, 255, 0, 0, 0, 0, 0, 0, 255, 255, 0, 0, 0, 0, 255, + 255, 0, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 255, 0, 0, 0, 0, 255, + }; + private static final int[] IMIN3_C6 = { + 0, 1, 5, 5, 8, 9, 3, 4, 5, 5, 5, 10, 8, 3, 3, 5, 5, 7, 3, 6, + 10, 9, 5, 5, 5, 3, 3, 7, 5, 8, 5, 6, 0, 3, 3, 5, 5, 5, 10, 3, + 9, 9, 5, 5, 9, 9, 9, 7, 5, 8, 9, 6, 9, 9, 3, 10, 10, 5, 10, 3, + }; + private static final int[] REC_DIL3_C26 = { + 9, 0, 4, 2, 7, 8, 2, 3, 4, 4, 2, 8, 7, 1, 0, 0, 0, 6, 0, 5, + 9, 8, 2, 1, 0, 2, 0, 6, 4, 7, 4, 5, 6, 2, 0, 3, 3, 4, 8, 2, + 0, 8, 1, 4, 7, 8, 8, 6, 4, 7, 8, 5, 8, 8, 0, 8, 8, 4, 8, 2, + }; + private static final int[] REC_ERO3_C26 = { + 9, 6, 6, 6, 7, 8, 6, 6, 6, 6, 6, 9, 7, 6, 6, 6, 6, 6, 6, 6, + 9, 8, 6, 6, 6, 6, 6, 6, 6, 7, 6, 6, 6, 6, 6, 6, 6, 6, 9, 6, + 6, 8, 6, 6, 8, 8, 8, 6, 6, 7, 8, 6, 8, 8, 6, 9, 9, 6, 9, 6, + }; + private static final int[] RMAX3_C26 = { + 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 255, 0, 0, 0, 0, 0, 0, 0, 0, + 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 255, 0, + 0, 0, 0, 0, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 255, 255, 0, 255, 0, + }; + private static final int[] RMIN3_C26 = { + 0, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 255, 255, 255, 0, 255, 0, + 0, 0, 0, 0, 255, 0, 255, 0, 0, 0, 0, 0, 0, 0, 255, 0, 0, 0, 0, 0, + 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 255, 0, 0, 0, 0, 0, + }; + private static final int[] EMAX3_C26 = { + 255, 0, 0, 0, 255, 255, 0, 0, 0, 0, 0, 255, 255, 0, 0, 0, 0, 0, 0, 0, + 255, 255, 0, 0, 0, 0, 0, 0, 0, 255, 0, 0, 0, 0, 0, 0, 0, 0, 255, 0, + 0, 255, 0, 0, 255, 255, 255, 0, 0, 255, 255, 0, 255, 255, 0, 255, 255, 0, 255, 0, + }; + private static final int[] EMIN3_C26 = { + 0, 255, 0, 255, 0, 0, 255, 0, 0, 0, 255, 0, 0, 255, 255, 255, 255, 0, 255, 0, + 0, 0, 255, 255, 255, 255, 255, 0, 0, 0, 0, 0, 0, 255, 255, 0, 0, 0, 0, 255, + 255, 0, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 255, 0, 0, 0, 0, 255, + }; + private static final int[] IMIN3_C26 = { + 0, 1, 5, 3, 8, 9, 3, 4, 5, 5, 3, 10, 8, 2, 1, 1, 1, 7, 1, 6, + 10, 9, 3, 2, 2, 3, 1, 7, 5, 8, 5, 6, 0, 3, 1, 4, 4, 5, 10, 3, + 1, 9, 2, 5, 9, 9, 9, 7, 5, 8, 9, 6, 9, 9, 1, 10, 10, 5, 10, 3, + }; + private static final int[] KILL3 = { + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + }; + private static final int[] FILL3 = { + 9, 0, 4, 2, 7, 8, 2, 3, 4, 4, 2, 9, 7, 1, 0, 0, 0, 6, 0, 5, + 9, 8, 2, 1, 0, 2, 2, 6, 4, 7, 4, 5, 6, 2, 0, 3, 3, 4, 9, 2, + 0, 8, 1, 4, 8, 8, 8, 6, 4, 7, 8, 5, 8, 8, 0, 9, 9, 4, 9, 2, + }; +} diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/morphology/reconstruction/ReconstructionTest.java b/scijava-ops-image/src/test/java/org/scijava/ops/image/morphology/reconstruction/ReconstructionTest.java new file mode 100644 index 000000000..07a2ddb10 --- /dev/null +++ b/scijava-ops-image/src/test/java/org/scijava/ops/image/morphology/reconstruction/ReconstructionTest.java @@ -0,0 +1,610 @@ +/* #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.morphology.reconstruction; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Random; + +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.algorithm.neighborhood.DiamondShape; +import net.imglib2.algorithm.neighborhood.RectangleShape; +import net.imglib2.img.Img; +import net.imglib2.img.array.ArrayImgs; +import net.imglib2.type.BooleanType; +import net.imglib2.type.logic.BitType; +import net.imglib2.type.numeric.RealType; +import net.imglib2.type.numeric.integer.UnsignedByteType; +import net.imglib2.type.numeric.real.DoubleType; +import net.imglib2.view.Views; + +import org.junit.jupiter.api.Test; +import org.scijava.ops.image.AbstractOpTest; +import org.scijava.types.Nil; + +/** + * Tests the ops of this package on synthetic images with known answers, and + * checks the defining properties of the transforms. + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +public class ReconstructionTest extends AbstractOpTest { + + // -- Reconstruction -- + + @Test + public void testReconstructByDilation() { + // Two bumps; only the left one is marked. + final var mask = doubles(new double[] { 0, 3, 5, 3, 1, 4, 7, 4, 0 }, 9); + final var marker = doubles(new double[] { 0, 0, 9, 0, 0, 0, 0, 0, 0 }, 9); + final var out = ArrayImgs.doubles(9); + ops.op("morphology.reconstructByDilation").input(marker, mask, null) + .output(out).compute(); + // The marker is clipped to the mask; the right bump is flattened to the + // level of the saddle between the bumps. + assertArrayEquals(new double[] { 0, 3, 5, 3, 1, 1, 1, 1, 0 }, values(out)); + } + + @Test + public void testReconstructByErosion() { + final var mask = doubles(new double[] { 9, 6, 4, 6, 8, 5, 2, 5, 9 }, 9); + final var marker = doubles(new double[] { 9, 9, 9, 9, 9, 9, 2, 9, 9 }, 9); + final var out = ArrayImgs.doubles(9); + ops.op("morphology.reconstructByErosion").input(marker, mask, null) + .output(out).compute(); + assertArrayEquals(new double[] { 9, 8, 8, 8, 8, 5, 2, 5, 9 }, values(out)); + } + + @Test + public void testReconstructionProperties() { + final var random = new Random(42); + final var mask = ArrayImgs.doubles(7, 6, 5); + final var marker = ArrayImgs.doubles(7, 6, 5); + for (final var t : mask) + t.set(random.nextInt(20)); + for (final var t : marker) + t.set(random.nextDouble() < 0.05 ? random.nextInt(25) : 0); + for (final var shape : new Object[] { new DiamondShape(1), + new RectangleShape(1, false) }) + { + final var rec = ArrayImgs.doubles(7, 6, 5); + ops.op("morphology.reconstructByDilation").input(marker, mask, shape) + .output(rec).compute(); + final var rec2 = ArrayImgs.doubles(7, 6, 5); + ops.op("morphology.reconstructByDilation").input(rec, mask, shape) + .output(rec2).compute(); + assertArrayEquals(values(rec), values(rec2), "idempotence"); + final var r = values(rec); + final var f = values(mask); + final var m = values(marker); + for (var i = 0; i < r.length; i++) { + assertTrue(r[i] <= f[i]); + assertTrue(r[i] >= Math.min(m[i], f[i])); + } + // Same result as iterating geodesic dilations until stability. + assertArrayEquals(naiveReconstruction(m, f, 7, 6, 5, shape), r); + } + } + + @Test + public void testReconstructionDefaultsToFullConnectivity() { + // A diagonal path: only reachable with full connectivity. + final var mask = doubles(new double[] { 5, 0, 0, 0, 5, 0, 0, 0, 5 }, 3, 3); + final var marker = doubles(new double[] { 5, 0, 0, 0, 0, 0, 0, 0, 0 }, 3, + 3); + final var out = ArrayImgs.doubles(3, 3); + ops.op("morphology.reconstructByDilation").input(marker, mask).output(out) + .compute(); + assertArrayEquals(values(mask), values(out)); + ops.op("morphology.reconstructByDilation").input(marker, mask, + new DiamondShape(1)).output(out).compute(); + assertArrayEquals(values(marker), values(out)); + } + + @Test + public void testReconstructionFunction() { + final var mask = doubles(new double[] { 0, 3, 5, 3, 1, 4, 7, 4, 0 }, 9); + final var marker = doubles(new double[] { 0, 0, 9, 0, 0, 0, 0, 0, 0 }, 9); + final RandomAccessibleInterval out = ops.op( + "morphology.reconstructByDilation").input(marker, mask).outType( + new Nil>() + {}).apply(); + assertArrayEquals(new double[] { 0, 3, 5, 3, 1, 1, 1, 1, 0 }, values(out)); + } + + @Test + public void testTranslatedInterval() { + final var mask = Views.translate(doubles(new double[] { 0, 3, 5, 3, 1, 4, + 7, 4, 0 }, 9), -4); + final var marker = Views.translate(doubles(new double[] { 0, 0, 9, 0, 0, 0, + 0, 0, 0 }, 9), 10); + final var out = Views.translate(ArrayImgs.doubles(9), 3); + ops.op("morphology.reconstructByDilation").input(marker, mask, null) + .output(out).compute(); + assertArrayEquals(new double[] { 0, 3, 5, 3, 1, 1, 1, 1, 0 }, values(out)); + } + + @Test + public void testDimensionMismatch() { + final var a = ArrayImgs.doubles(4, 4); + final var b = ArrayImgs.doubles(4, 5); + assertThrows(IllegalArgumentException.class, () -> ops.op( + "morphology.reconstructByDilation").input(a, b, null).output(a) + .compute()); + } + + // -- Regional extrema -- + + @Test + public void testRegionalMaximaPlateaus() { + // A plateau on the border, a plateau next to a higher pixel, a single + // pixel maximum and a plateau maximum. + final var f = doubles(new double[] { 7, 7, 3, 4, 4, 5, 2, 6, 6, 1 }, 10); + final var out = ArrayImgs.bits(10); + ops.op("morphology.regionalMaxima").input(f, null).output(out).compute(); + assertArrayEquals(new boolean[] { true, true, false, false, false, true, + false, true, true, false }, flags(out)); + ops.op("morphology.regionalMinima").input(f, null).output(out).compute(); + assertArrayEquals(new boolean[] { false, false, true, false, false, false, + true, false, false, true }, flags(out)); + } + + @Test + public void testRegionalMaximaConnectivity() { + final var f = doubles(new double[] { // + 0, 0, 0, 0, 0, // + 0, 5, 0, 0, 0, // + 0, 0, 6, 0, 0, // + 0, 0, 0, 0, 0 }, 5, 4); + final var out = ArrayImgs.bits(5, 4); + ops.op("morphology.regionalMaxima").input(f, new DiamondShape(1)).output( + out).compute(); + assertEquals(2, count(out)); + ops.op("morphology.regionalMaxima").input(f, new RectangleShape(1, false)) + .output(out).compute(); + assertEquals(1, count(out)); + ops.op("morphology.regionalMaxima").input(f).output(out).compute(); + assertEquals(1, count(out)); + // The background plateau is the only minimum. + ops.op("morphology.regionalMinima").input(f).output(out).compute(); + assertEquals(18, count(out)); + } + + @Test + public void testRegionalMaximaFunction() { + final var f = doubles(new double[] { 7, 7, 3, 4, 4, 5, 2, 6, 6, 1 }, 10); + final RandomAccessibleInterval out = ops.op( + "morphology.regionalMaxima").input(f).outType( + new Nil>() + {}).apply(); + assertArrayEquals(new boolean[] { true, true, false, false, false, true, + false, true, true, false }, flags(out)); + } + + @Test + public void testRegionalMaximaConstantImage() { + final var f = ArrayImgs.unsignedBytes(4, 3, 2); + for (final var t : f) + t.set(17); + final var out = ArrayImgs.bits(4, 3, 2); + ops.op("morphology.regionalMaxima").input(f).output(out).compute(); + assertEquals(24, count(out)); + ops.op("morphology.regionalMinima").input(f).output(out).compute(); + assertEquals(24, count(out)); + } + + @Test + public void testRegionalMaximaOfTwoGaussians() { + final var f = ArrayImgs.doubles(40, 30); + final var c = f.localizingCursor(); + while (c.hasNext()) { + c.fwd(); + final double x = c.getIntPosition(0), y = c.getIntPosition(1); + c.get().set(100 * Math.exp(-((x - 10) * (x - 10) + (y - 12) * (y - + 12)) / 30) + 60 * Math.exp(-((x - 28) * (x - 28) + (y - 18) * (y - + 18)) / 20)); + } + final var out = ArrayImgs.bits(40, 30); + ops.op("morphology.regionalMaxima").input(f).output(out).compute(); + assertEquals(2, count(out)); + assertTrue(out.getAt(10, 12).get()); + assertTrue(out.getAt(28, 18).get()); + } + + // -- h-transforms and extended extrema -- + + @Test + public void testHMaxima() { + final var f = doubles(new double[] { 0, 5, 2, 8, 0 }, 5); + final var out = ArrayImgs.doubles(5); + ops.op("morphology.hMaxima").input(f, 3.0, null).output(out).compute(); + assertArrayEquals(new double[] { 0, 2, 2, 5, 0 }, values(out)); + + // Saturation at zero for unsigned types gives the same result here. + final var u = ArrayImgs.unsignedBytes(new byte[] { 0, 5, 2, 8, 0 }, 5); + final var uOut = ArrayImgs.unsignedBytes(5); + ops.op("morphology.hMaxima").input(u, 3.0, null).output(uOut).compute(); + assertArrayEquals(new double[] { 0, 2, 2, 5, 0 }, values(uOut)); + + final var bits = ArrayImgs.bits(5); + ops.op("morphology.extendedMaxima").input(f, 3.0, null).output(bits) + .compute(); + assertArrayEquals(new boolean[] { false, false, false, true, false }, + flags(bits)); + // The left peak has dynamic 3, so it survives any smaller h. + ops.op("morphology.extendedMaxima").input(f, 2.5, null).output(bits) + .compute(); + assertArrayEquals(new boolean[] { false, true, false, true, false }, flags( + bits)); + ops.op("morphology.extendedMaxima").input(f, 0.0, null).output(bits) + .compute(); + final var rmax = ArrayImgs.bits(5); + ops.op("morphology.regionalMaxima").input(f, null).output(rmax).compute(); + assertArrayEquals(flags(rmax), flags(bits)); + } + + @Test + public void testHMinima() { + final var f = doubles(new double[] { 9, 4, 7, 1, 9 }, 5); + final var out = ArrayImgs.doubles(5); + ops.op("morphology.hMinima").input(f, 3.0, null).output(out).compute(); + assertArrayEquals(new double[] { 9, 7, 7, 4, 9 }, values(out)); + final var bits = ArrayImgs.bits(5); + ops.op("morphology.extendedMinima").input(f, 2.5, null).output(bits) + .compute(); + assertArrayEquals(new boolean[] { false, true, false, true, false }, flags( + bits)); + } + + @Test + public void testHTransformProperties() { + final var random = new Random(7); + final var f = ArrayImgs.unsignedBytes(9, 8, 3); + final var inv = ArrayImgs.unsignedBytes(9, 8, 3); + final var c = f.cursor(); + final var ci = inv.cursor(); + while (c.hasNext()) { + final var v = random.nextInt(256); + c.next().set(v); + ci.next().set(255 - v); + } + final var h = 40.0; + final var hmax = ArrayImgs.unsignedBytes(9, 8, 3); + ops.op("morphology.hMaxima").input(f, h, null).output(hmax).compute(); + final var hmin = ArrayImgs.unsignedBytes(9, 8, 3); + ops.op("morphology.hMinima").input(inv, h, null).output(hmin).compute(); + final var fv = values(f); + final var hv = values(hmax); + final var hm = values(hmin); + for (var i = 0; i < fv.length; i++) { + assertTrue(hv[i] <= fv[i]); + assertTrue(hv[i] >= Math.max(fv[i] - h, 0)); + assertEquals(255 - hv[i], hm[i], "duality"); + } + // Extended maxima are the regional maxima of the h-maxima. + final var emax = ArrayImgs.bits(9, 8, 3); + ops.op("morphology.extendedMaxima").input(f, h, null).output(emax) + .compute(); + final var rmax = ArrayImgs.bits(9, 8, 3); + ops.op("morphology.regionalMaxima").input(hmax, null).output(rmax) + .compute(); + assertArrayEquals(flags(rmax), flags(emax)); + } + + @Test + public void testNegativeHeight() { + final var f = ArrayImgs.doubles(3, 3); + assertThrows(IllegalArgumentException.class, () -> ops.op( + "morphology.hMaxima").input(f, -1.0, null).output(f).compute()); + } + + // -- Minima imposition -- + + @Test + public void testImposeMinimaFloat() { + final var f = doubles(new double[] { 3, 1, 4, 1, 5, 9, 2, 6 }, 8); + final var markers = ArrayImgs.bits(8); + markers.getAt(4).set(true); + final var out = ArrayImgs.doubles(8); + ops.op("morphology.imposeMinima").input(f, markers, null).output(out) + .compute(); + final var inf = Double.NEGATIVE_INFINITY; + assertArrayEquals(new double[] { 4, 4, 4, 1, inf, 9, 9, 9 }, values(out)); + ops.op("morphology.imposeMaxima").input(f, markers, null).output(out) + .compute(); + assertArrayEquals(new double[] { 1, 1, 1, 1, Double.POSITIVE_INFINITY, 9, + 2, 2 }, values(out)); + } + + @Test + public void testImposedMinimaAreMarkers() { + final var random = new Random(3); + final var f = ArrayImgs.unsignedBytes(10, 9); + for (final var t : f) + t.set(random.nextInt(200)); + final var markers = ArrayImgs.bits(10, 9); + markers.getAt(2, 2).set(true); + markers.getAt(3, 2).set(true); + markers.getAt(7, 6).set(true); + markers.getAt(0, 8).set(true); + for (final var shape : new Object[] { new DiamondShape(1), + new RectangleShape(1, false) }) + { + final var out = ArrayImgs.unsignedBytes(10, 9); + final var rmin = ArrayImgs.bits(10, 9); + ops.op("morphology.imposeMinima").input(f, markers, shape).output(out) + .compute(); + ops.op("morphology.regionalMinima").input(out, shape).output(rmin) + .compute(); + assertArrayEquals(flags(markers), flags(rmin)); + ops.op("morphology.imposeMaxima").input(f, markers, shape).output(out) + .compute(); + ops.op("morphology.regionalMaxima").input(out, shape).output(rmin) + .compute(); + assertArrayEquals(flags(markers), flags(rmin)); + } + } + + // -- Kill borders and fill holes -- + + @Test + public void testKillBordersBinary() { + final var img = bits(new int[] { // + 1, 1, 0, 0, 0, 0, // + 0, 0, 1, 1, 1, 0, // + 0, 0, 1, 0, 0, 0, // + 0, 0, 0, 0, 1, 1, // + 0, 1, 0, 0, 0, 0 }, 6, 5); + final var out = ArrayImgs.bits(6, 5); + ops.op("morphology.killBorders").input(img, new DiamondShape(1)).output( + out).compute(); + assertArrayEquals(bitFlags(new int[] { // + 0, 0, 0, 0, 0, 0, // + 0, 0, 1, 1, 1, 0, // + 0, 0, 1, 0, 0, 0, // + 0, 0, 0, 0, 0, 0, // + 0, 0, 0, 0, 0, 0 }), flags(out)); + // With full connectivity, the central component touches the top left one + // by a corner, so it is connected to the border too. + ops.op("morphology.killBorders").input(img).output(out).compute(); + assertEquals(0, count(out)); + } + + @Test + public void testKillBordersGray() { + final var f = doubles(new double[] { // + 2, 2, 2, 2, 2, // + 2, 5, 1, 9, 2, // + 2, 2, 2, 2, 2 }, 5, 3); + final var out = ArrayImgs.doubles(5, 3); + ops.op("morphology.killBorders").input(f).output(out).compute(); + assertArrayEquals(new double[] { // + 0, 0, 0, 0, 0, // + 0, 3, 0, 7, 0, // + 0, 0, 0, 0, 0 }, values(out)); + } + + @Test + public void testFillHolesGray() { + final var f = doubles(new double[] { // + 1, 1, 1, 1, 1, 1, // + 1, 6, 6, 6, 6, 1, // + 1, 6, 2, 3, 6, 1, // + 1, 6, 6, 4, 6, 1, // + 1, 1, 1, 1, 1, 1 }, 6, 5); + final var out = ArrayImgs.doubles(6, 5); + ops.op("morphology.fillHoles").input(f).output(out).compute(); + assertArrayEquals(new double[] { // + 1, 1, 1, 1, 1, 1, // + 1, 6, 6, 6, 6, 1, // + 1, 6, 4, 4, 6, 1, // + 1, 6, 6, 4, 6, 1, // + 1, 1, 1, 1, 1, 1 }, values(out)); + } + + @Test + public void testFillHolesGrayMatchesBinary() { + final var random = new Random(11); + final var bin = ArrayImgs.bits(12, 10); + final var gray = ArrayImgs.unsignedBytes(12, 10); + final var cb = bin.cursor(); + final var cg = gray.cursor(); + while (cb.hasNext()) { + final var b = random.nextDouble() < 0.5; + cb.next().set(b); + cg.next().set(b ? 1 : 0); + } + for (final var shape : new Object[] { new DiamondShape(1), + new RectangleShape(1, false) }) + { + final var binOut = ArrayImgs.bits(12, 10); + ops.op("morphology.fillHoles").input(bin, shape).output(binOut) + .compute(); + final var grayOut = ArrayImgs.unsignedBytes(12, 10); + ops.op("morphology.fillHoles").input(gray, shape).output(grayOut) + .compute(); + final var bv = flags(binOut); + final var gv = values(grayOut); + for (var i = 0; i < bv.length; i++) + assertEquals(bv[i] ? 1.0 : 0.0, gv[i]); + } + } + + // -- Helper methods -- + + /** Reconstruction by iterated elementary geodesic dilations. */ + private static double[] naiveReconstruction(final double[] marker, + final double[] mask, final int w, final int h, final int d, + final Object shape) + { + final var full = shape instanceof RectangleShape; + var cur = new double[marker.length]; + for (var i = 0; i < cur.length; i++) + cur[i] = Math.min(marker[i], mask[i]); + var changed = true; + while (changed) { + changed = false; + final var next = cur.clone(); + for (var z = 0; z < d; z++) + for (var y = 0; y < h; y++) + for (var x = 0; x < w; x++) { + final var i = x + w * (y + h * z); + var v = cur[i]; + for (var dz = -1; dz <= 1; dz++) + for (var dy = -1; dy <= 1; dy++) + for (var dx = -1; dx <= 1; dx++) { + final var dist = Math.abs(dx) + Math.abs(dy) + Math.abs(dz); + if (dist == 0 || !full && dist > 1) continue; + final int xx = x + dx, yy = y + dy, zz = z + dz; + if (xx < 0 || yy < 0 || zz < 0 || xx >= w || yy >= h || + zz >= d) continue; + v = Math.max(v, cur[xx + w * (yy + h * zz)]); + } + next[i] = Math.min(v, mask[i]); + if (next[i] != cur[i]) changed = true; + } + cur = next; + } + return cur; + } + + private static Img doubles(final double[] values, + final long... dims) + { + return ArrayImgs.doubles(values.clone(), dims); + } + + private static Img bits(final int[] values, final long... dims) { + final var img = ArrayImgs.bits(dims); + var i = 0; + for (final var b : img) + b.set(values[i++] != 0); + return img; + } + + private static boolean[] bitFlags(final int[] values) { + final var result = new boolean[values.length]; + for (var i = 0; i < values.length; i++) + result[i] = values[i] != 0; + return result; + } + + private static double[] values( + final RandomAccessibleInterval> image) + { + final var result = new double[(int) Views.iterable(image).size()]; + var i = 0; + for (final var t : Views.flatIterable(image)) + result[i++] = t.getRealDouble(); + return result; + } + + private static boolean[] flags( + final RandomAccessibleInterval> image) + { + final var result = new boolean[(int) Views.iterable(image).size()]; + var i = 0; + for (final var t : Views.flatIterable(image)) + result[i++] = t.get(); + return result; + } + + private static int count( + final RandomAccessibleInterval> image) + { + var n = 0; + for (final var b : flags(image)) + if (b) n++; + return n; + } + + // -- NaN handling -- + + /** + * A single NaN would otherwise spread through the raster scans and turn the + * whole output into NaN, or flag every NaN pixel as both a regional maximum + * and minimum; like the attribute filters, these ops reject NaN. + */ + @Test + public void testNaNRejected() { + final var f = ArrayImgs.floats(new float[] { Float.NaN, 1, 2, 1, 0, 1, 3, 1, + 0 }, 3, 3); + final var g = ArrayImgs.floats(new float[] { 5, 5, 5, 5, 5, 5, 5, 5, 5 }, 3, + 3); + final var out = ArrayImgs.floats(3, 3); + final var bits = ArrayImgs.bits(3, 3); + final var marks = ArrayImgs.bits(3, 3); + marks.firstElement().set(true); + final var shape = new RectangleShape(1, false); + // NaN in the marker or in the mask. + assertThrows(IllegalArgumentException.class, () -> ops.op( + "morphology.reconstructByDilation").input(f, g, shape).output(out) + .compute()); + assertThrows(IllegalArgumentException.class, () -> ops.op( + "morphology.reconstructByDilation").input(ArrayImgs.floats(3, 3), f, + shape).output(out).compute()); + assertThrows(IllegalArgumentException.class, () -> ops.op( + "morphology.reconstructByErosion").input(g, f, shape).output(out) + .compute()); + for (final var name : new String[] { "morphology.hMaxima", + "morphology.hMinima" }) + { + assertThrows(IllegalArgumentException.class, () -> ops.op(name).input(f, + 1.0, shape).output(out).compute(), name); + } + for (final var name : new String[] { "morphology.extendedMaxima", + "morphology.extendedMinima" }) + { + assertThrows(IllegalArgumentException.class, () -> ops.op(name).input(f, + 1.0, shape).output(bits).compute(), name); + } + for (final var name : new String[] { "morphology.imposeMaxima", + "morphology.imposeMinima" }) + { + assertThrows(IllegalArgumentException.class, () -> ops.op(name).input(f, + marks, shape).output(out).compute(), name); + } + for (final var name : new String[] { "morphology.killBorders", + "morphology.fillHoles" }) + { + assertThrows(IllegalArgumentException.class, () -> ops.op(name).input(f, + null).output(out).compute(), name); + } + for (final var name : new String[] { "morphology.regionalMaxima", + "morphology.regionalMinima" }) + { + assertThrows(IllegalArgumentException.class, () -> ops.op(name).input(f, + shape).output(bits).compute(), name); + } + } +} diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/morphology/thickness/LocalThicknessTest.java b/scijava-ops-image/src/test/java/org/scijava/ops/image/morphology/thickness/LocalThicknessTest.java new file mode 100644 index 000000000..ffd378f65 --- /dev/null +++ b/scijava-ops-image/src/test/java/org/scijava/ops/image/morphology/thickness/LocalThicknessTest.java @@ -0,0 +1,281 @@ +/*- + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.morphology.thickness; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.function.Predicate; + +import org.junit.jupiter.api.Test; +import org.scijava.ops.image.AbstractOpTest; +import org.scijava.types.Nil; + +import net.imglib2.Cursor; +import net.imglib2.RandomAccess; +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.img.Img; +import net.imglib2.img.array.ArrayImgs; +import net.imglib2.type.logic.BitType; +import net.imglib2.type.numeric.real.FloatType; +import net.imglib2.view.Views; + +/** + * Tests {@link DefaultLocalThickness}. + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +public class LocalThicknessTest extends AbstractOpTest { + + private static final Nil> OUT = + new Nil<>() + {}; + + /** A slab of even width W has local thickness exactly W. */ + @Test + public void testSlab2D() { + final int width = 10; + final Img in = image(new long[] { 30, 12 }, p -> p[0] >= 8 && + p[0] < 8 + width); + final RandomAccessibleInterval lt = ops.op( + "morphology.localThickness").input(in).outType(OUT).apply(); + assertForeground(in, lt, (v, p) -> assertEquals(width, v, 1e-6)); + } + + /** Calibration scales thickness along each axis independently. */ + @Test + public void testSlabCalibrated() { + final double[] cal = { 0.5, 2.0 }; + final Img slabX = image(new long[] { 30, 12 }, p -> p[0] >= 8 && + p[0] < 18); + final RandomAccessibleInterval ltX = ops.op( + "morphology.localThickness").input(slabX, cal).outType(OUT).apply(); + assertForeground(slabX, ltX, (v, p) -> assertEquals(5.0, v, 1e-6)); + + final Img slabY = image(new long[] { 12, 30 }, p -> p[1] >= 8 && + p[1] < 18); + final RandomAccessibleInterval ltY = ops.op( + "morphology.localThickness").input(slabY, cal).outType(OUT).apply(); + assertForeground(slabY, ltY, (v, p) -> assertEquals(20.0, v, 1e-6)); + } + + /** A 3D slab of even width W has local thickness exactly W. */ + @Test + public void testSlab3D() { + final int width = 6; + final Img in = image(new long[] { 10, 10, 16 }, p -> p[2] >= 5 && + p[2] < 5 + width); + final RandomAccessibleInterval lt = ops.op( + "morphology.localThickness").input(in).outType(OUT).apply(); + assertForeground(in, lt, (v, p) -> assertEquals(width, v, 1e-6)); + } + + /** + * A disk of radius R has a thickness close to its diameter. The distance + * transform measures distances to background pixel centres, so the + * thickness is between 2R and 2R + 2. + */ + @Test + public void testDisk() { + final int r = 10; + final Img in = image(new long[] { 31, 31 }, p -> sq(p[0] - 15) + + sq(p[1] - 15) <= r * r); + final RandomAccessibleInterval lt = ops.op( + "morphology.localThickness").input(in).outType(OUT).apply(); + final double[] sum = { 0, 0 }; + assertForeground(in, lt, (v, p) -> { + assertTrue(v >= 2 * r && v <= 2 * r + 2, "Thickness " + v + " at " + + p[0] + "," + p[1]); + sum[0] += v; + sum[1]++; + }); + assertEquals(2 * r + 1, sum[0] / sum[1], 1.0); + } + + /** A ball of radius R has a thickness close to its diameter. */ + @Test + public void testBall() { + final int r = 6; + final Img in = image(new long[] { 17, 17, 17 }, p -> sq(p[0] - + 8) + sq(p[1] - 8) + sq(p[2] - 8) <= r * r); + final RandomAccessibleInterval lt = ops.op( + "morphology.localThickness").input(in).outType(OUT).apply(); + assertForeground(in, lt, (v, p) -> assertTrue(v >= 2 * r && + v <= 2 * r + 2, "Thickness " + v)); + } + + /** Background is 0 by default, and the result keeps the input interval. */ + @Test + public void testMaskAndInterval() { + final Img img = image(new long[] { 20, 20 }, p -> p[0] >= 5 && + p[0] < 9); + final RandomAccessibleInterval in = Views.translate(img, 3, -7); + final RandomAccessibleInterval lt = ops.op( + "morphology.localThickness").input(in).outType(OUT).apply(); + assertEquals(3, lt.min(0)); + assertEquals(-7, lt.min(1)); + final RandomAccess ra = lt.randomAccess(); + ra.setPosition(new long[] { 3 + 4, -7 + 10 }); + assertEquals(0, ra.get().get()); + ra.setPosition(new long[] { 3 + 6, -7 + 10 }); + assertEquals(4, ra.get().get(), 1e-6); + // Unmasked, the thickness map spills into the adjacent background. + final RandomAccessibleInterval unmasked = ops.op( + "morphology.localThickness").input(in, null, false).outType(OUT) + .apply(); + final RandomAccess ua = unmasked.randomAccess(); + ua.setPosition(new long[] { 3 + 4, -7 + 10 }); + assertEquals(4, ua.get().get(), 1e-6); + } + + @Test + public void testBadCalibration() { + final Img in = image(new long[] { 5, 5 }, p -> p[0] == 2); + assertThrows(RuntimeException.class, () -> ops.op( + "morphology.localThickness").input(in, new double[] { 1 }).outType(OUT) + .apply()); + } + + /** + * Without any background element, every element would be a ridge point + * painting a ball over the whole image, which takes quadratic time and + * yields a meaningless map; the op rejects such input instead. + */ + @Test + public void testAllForeground() { + final Img in = image(new long[] { 6, 5, 4 }, p -> true); + final var e = assertThrows(IllegalArgumentException.class, () -> ops.op( + "morphology.localThickness").input(in).outType(OUT).apply()); + assertTrue(e.getMessage().contains("background"), e.getMessage()); + } + + /** Without any foreground, the thickness is 0 everywhere. */ + @Test + public void testAllBackground() { + final Img in = image(new long[] { 6, 5 }, p -> false); + final RandomAccessibleInterval out = ops.op( + "morphology.localThickness").input(in, null, false).outType(OUT).apply(); + for (final FloatType t : Views.iterable(out)) + assertEquals(0, t.get(), 0); + } + + /** + * Compares against the original Fiji LocalThickness plugins (EDT_S1D, + * Distance_Ridge, Local_Thickness_Parallel, Clean_Up_Local_Thickness, from + * sc.fiji:LocalThickness_:5.0.0) run on the same synthetic image without + * masking. The expected sums were produced by a throwaway harness calling + * those plugins headlessly on an 8-bit stack with foreground = 255. + */ + @Test + public void testRegression3D() { + final Img in = image(new long[] { 24, 20, 16 }, p -> shape(p[0], + p[1], p[2])); + assertSums(in, 27308.722930908203, 18969.93926382065); + } + + /** As {@link #testRegression3D()}, for a single 2D plane (z = 8). */ + @Test + public void testRegression2D() { + final Img in = image(new long[] { 24, 20 }, p -> shape(p[0], p[1], + 8)); + assertSums(in, 2698.6377420425415, 2157.8645343780518); + } + + private void assertSums(final Img in, final double expectedSum, + final double expectedSumFg) + { + final RandomAccessibleInterval unmasked = ops.op( + "morphology.localThickness").input(in, null, false).outType(OUT) + .apply(); + final RandomAccessibleInterval masked = ops.op( + "morphology.localThickness").input(in).outType(OUT).apply(); + double sum = 0, sumFg = 0, sumMasked = 0; + final Cursor c = in.localizingCursor(); + final RandomAccess ua = unmasked.randomAccess(); + final RandomAccess ma = masked.randomAccess(); + while (c.hasNext()) { + c.fwd(); + ua.setPosition(c); + ma.setPosition(c); + sum += ua.get().get(); + if (c.get().get()) sumFg += ua.get().get(); + sumMasked += ma.get().get(); + } + assertEquals(expectedSum, sum, 1e-2); + assertEquals(expectedSumFg, sumFg, 1e-2); + assertEquals(expectedSumFg, sumMasked, 1e-2); + } + + private static boolean shape(final long x, final long y, final long z) { + if (sq(x - 10) + sq(y - 9) + sq(z - 8) <= 36) return true; + if (x >= 15 && x < 21 && y >= 2 && y < 18 && z >= 3 && z < 13) return true; + return y == 4 && z == 4 && x >= 2 && x <= 20; + } + + private static long sq(final long v) { + return v * v; + } + + private static Img image(final long[] dims, + final Predicate fg) + { + final Img img = ArrayImgs.bits(dims); + final Cursor c = img.localizingCursor(); + final long[] pos = new long[dims.length]; + while (c.hasNext()) { + c.fwd(); + c.localize(pos); + c.get().set(fg.test(pos)); + } + return img; + } + + private interface Check { + + void check(float value, long[] pos); + } + + private static void assertForeground(final RandomAccessibleInterval in, + final RandomAccessibleInterval lt, final Check check) + { + final Cursor c = Views.iterable(in).localizingCursor(); + final RandomAccess ra = lt.randomAccess(); + final long[] pos = new long[in.numDimensions()]; + while (c.hasNext()) { + c.fwd(); + c.localize(pos); + ra.setPosition(pos); + if (c.get().get()) check.check(ra.get().get(), pos); + else assertEquals(0, ra.get().get()); + } + } +} diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/segment/dog/DetectSpotsDoGTest.java b/scijava-ops-image/src/test/java/org/scijava/ops/image/segment/dog/DetectSpotsDoGTest.java new file mode 100644 index 000000000..387accdf1 --- /dev/null +++ b/scijava-ops-image/src/test/java/org/scijava/ops/image/segment/dog/DetectSpotsDoGTest.java @@ -0,0 +1,179 @@ +/*- + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.segment.dog; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.Random; + +import net.imglib2.RealPoint; +import net.imglib2.img.Img; +import net.imglib2.img.array.ArrayImgs; +import net.imglib2.type.numeric.real.FloatType; + +import org.junit.jupiter.api.Test; +import org.scijava.ops.image.AbstractOpTest; +import org.scijava.types.Nil; + +/** + * Tests {@link DefaultDetectSpotsDoG}. + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +public class DetectSpotsDoGTest extends AbstractOpTest { + + private static final double SPOT_SIGMA = 2; + + // These are the true spot centers, in (x, y) pairs, at subpixel positions. + private static final double[][] SPOTS = { // + { 10.3, 12.7 }, { 30.8, 9.2 }, { 50.5, 14.1 }, // + { 14.6, 40.4 }, { 33.1, 35.9 }, { 49.9, 44.6 } // + }; + + /** Renders Gaussian spots on a constant background, with optional noise. */ + private static Img render(final double amplitude, + final double background, final boolean noise) + { + final var img = ArrayImgs.floats(64, 56); + final var rng = new Random(42); + final var c = img.localizingCursor(); + while (c.hasNext()) { + c.fwd(); + final double x = c.getDoublePosition(0), y = c.getDoublePosition(1); + var v = background; + for (final var s : SPOTS) { + final double dx = x - s[0], dy = y - s[1]; + v += amplitude * Math.exp(-(dx * dx + dy * dy) / (2 * SPOT_SIGMA * + SPOT_SIGMA)); + } + // Note: Gaussian noise with variance v approximates Poisson noise. + if (noise) v += Math.sqrt(Math.abs(v)) * rng.nextGaussian(); + c.get().setReal(v); + } + return img; + } + + private List detect(final Img img, + final Double threshold, final Boolean bright, final Boolean subpixel) + { + return ops.op("segment.detectSpotsDoG").input(img, SPOT_SIGMA, null, + threshold, bright, subpixel).outType(new Nil>() + {}).apply(); + } + + /** Returns the RMS distance from each true spot to its nearest detection. */ + private static double rmsError(final List detections) { + double sum = 0; + for (final var s : SPOTS) { + var best = Double.POSITIVE_INFINITY; + for (final var p : detections) { + final var dx = p.getDoublePosition(0) - s[0]; + final var dy = p.getDoublePosition(1) - s[1]; + best = Math.min(best, dx * dx + dy * dy); + } + sum += best; + } + return Math.sqrt(sum / SPOTS.length); + } + + @Test + public void testSubpixelNoiseless() { + final var spots = ops.op("segment.detectSpotsDoG").input(render(1000, 100, + false), SPOT_SIGMA, null, 10.0).outType(new Nil>() + {}).apply(); + assertEquals(SPOTS.length, spots.size()); + assertTrue(rmsError(spots) < 0.05, "RMS error: " + rmsError(spots)); + } + + @Test + public void testSubpixelNoisy() { + final var spots = detect(render(1000, 100, true), 50.0, null, null); + assertEquals(SPOTS.length, spots.size()); + assertTrue(rmsError(spots) < 0.1, "RMS error: " + rmsError(spots)); + } + + @Test + public void testPixelPositions() { + final var spots = detect(render(1000, 100, false), 10.0, true, false); + assertEquals(SPOTS.length, spots.size()); + for (final var p : spots) { + for (var d = 0; d < 2; d++) { + final var v = p.getDoublePosition(d); + assertEquals(Math.rint(v), v, 0); + } + } + // Each pixel detection is within a pixel of the true center. + assertTrue(rmsError(spots) < 1, "RMS error: " + rmsError(spots)); + } + + @Test + public void testDarkSpots() { + final var img = render(-500, 1000, false); + // Bright-spot detection may respond to the rings around the dark spots, + // but never at their centers. + assertTrue(rmsError(detect(img, 10.0, true, true)) > 1); + final var spots = detect(img, 10.0, false, true); + assertEquals(SPOTS.length, spots.size()); + assertTrue(rmsError(spots) < 0.05, "RMS error: " + rmsError(spots)); + } + + @Test + public void testThreshold() { + final var img = render(1000, 100, false); + // Without a threshold, weak background extrema between spots appear. + assertTrue(detect(img, null, null, null).size() > SPOTS.length); + assertEquals(SPOTS.length, detect(img, 10.0, null, null).size()); + assertEquals(0, detect(img, 1e6, null, null).size()); + } + + @Test + public void testFlatImage() { + final var img = ArrayImgs.floats(20, 20); + for (final var t : img) + t.set(7); + final List spots = ops.op("segment.detectSpotsDoG").input(img, + SPOT_SIGMA).outType(new Nil>() + {}).apply(); + assertEquals(0, spots.size()); + } + + /** NaN would spread through the Gaussian filters, so it is rejected. */ + @Test + public void testNaN() { + final var img = ArrayImgs.floats(20, 20); + img.firstElement().set(Float.NaN); + assertThrows(IllegalArgumentException.class, () -> detect(img, null, null, + null)); + } +} diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/segment/fastmarching/FastMarchingTest.java b/scijava-ops-image/src/test/java/org/scijava/ops/image/segment/fastmarching/FastMarchingTest.java new file mode 100644 index 000000000..f4facf5e2 --- /dev/null +++ b/scijava-ops-image/src/test/java/org/scijava/ops/image/segment/fastmarching/FastMarchingTest.java @@ -0,0 +1,269 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.segment.fastmarching; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; + +import net.imglib2.Point; +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.img.array.ArrayImgs; +import net.imglib2.type.logic.BitType; +import net.imglib2.type.numeric.real.DoubleType; +import net.imglib2.view.Views; + +import org.junit.jupiter.api.Test; +import org.scijava.ops.image.AbstractOpTest; +import org.scijava.types.Nil; + +/** + * Tests {@code image.fastMarchingTimes} and {@code segment.fastMarching}. + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +public class FastMarchingTest extends AbstractOpTest { + + private static final Nil> TIMES = + new Nil<>() + {}; + + private static final Nil> MASK = + new Nil<>() + {}; + + private static double[] values(final RandomAccessibleInterval img) { + final var out = new double[(int) Views.iterable(img).size()]; + var i = 0; + for (final var t : Views.flatIterable(img)) + out[i++] = t.get(); + return out; + } + + /** + * Maximal absolute and relative error with respect to the Euclidean + * distance from a point seed at the center of a constant-speed image. + */ + private double[] euclideanErrors(final long[] dims, final double[] cal, + final boolean secondOrder, final double minRadius) + { + final var speed = ArrayImgs.doubles(dims); + speed.forEach(t -> t.set(1)); + final var center = new long[dims.length]; + for (var d = 0; d < dims.length; d++) + center[d] = dims[d] / 2; + final RandomAccessibleInterval times = ops.op( + "image.fastMarchingTimes").input(List.of(new Point(center)), speed, + cal, null, secondOrder).outType(TIMES).apply(); + var maxAbs = 0.0; + var maxRel = 0.0; + final var c = Views.iterable(times).localizingCursor(); + while (c.hasNext()) { + final var t = c.next().get(); + var r2 = 0.0; + for (var d = 0; d < dims.length; d++) + r2 += Math.pow((c.getLongPosition(d) - center[d]) * cal[d], 2); + final var r = Math.sqrt(r2); + if (r < minRadius) continue; + final var err = Math.abs(t - r); + maxAbs = Math.max(maxAbs, err); + if (r > 0) maxRel = Math.max(maxRel, err / r); + else assertEquals(0, t); + } + return new double[] { maxAbs, maxRel }; + } + + @Test + public void testConstantSpeed2D() { + // Note: For a point source, the first-order scheme overestimates the + // distance off the axes, with an error growing like log(r) (already 0.29 + // at the diagonal neighbor of the seed); the second-order scheme is + // markedly more accurate. The bounds below are about 10 % above the + // observed errors (1.15 and 0.33 at radii up to 42). + final var dims = new long[] { 61, 61 }; + final var cal = new double[] { 1, 1 }; + final var first = euclideanErrors(dims, cal, false, 0); + final var second = euclideanErrors(dims, cal, true, 0); + assertTrue(first[0] < 1.25, "first-order error " + first[0]); + assertTrue(second[0] < 0.36, "second-order error " + second[0]); + // The relative error decreases with the distance: beyond radius 10, it + // is at most 7 % (first order) and 2.6 % (second order). + assertTrue(first[1] > 0.2 && euclideanErrors(dims, cal, false, 10)[1] < + 0.075); + assertTrue(euclideanErrors(dims, cal, true, 10)[1] < 0.03); + } + + @Test + public void testConstantSpeed3DAnisotropic() { + final var e = euclideanErrors(new long[] { 21, 25, 11 }, new double[] { + 1, 0.8, 2 }, true, 0); + assertTrue(e[0] < 0.9, "error " + e[0]); + } + + /** + * Fast medium (speed 2) for {@code x < 20}, slow medium (speed 1) beyond, + * with a point seed in the fast medium: the arrival time in the slow medium + * is that of the refracted path obeying the law of Snell, found here by + * minimizing the travel time over the crossing point on the interface. + */ + @Test + public void testTwoSpeedMedium() { + final var speed = ArrayImgs.doubles(40, 30); + final var c = speed.localizingCursor(); + while (c.hasNext()) { + c.fwd(); + c.get().set(c.getLongPosition(0) < 20 ? 2 : 1); + } + final double sx = 8, sy = 5, x0 = 19.5; + final RandomAccessibleInterval times = ops.op( + "image.fastMarchingTimes").input(List.of(new Point(8, 5)), speed) + .outType(TIMES).apply(); + final var tc = Views.iterable(times).localizingCursor(); + var maxErr = 0.0; + while (tc.hasNext()) { + final var t = tc.next().get(); + final double px = tc.getLongPosition(0), py = tc.getLongPosition(1); + final double expected; + if (px < x0) expected = Math.hypot(px - sx, py - sy) / 2; + else { + double lo = Math.min(sy, py), hi = Math.max(sy, py); + for (var k = 0; k < 200; k++) { + final var m1 = lo + (hi - lo) / 3; + final var m2 = hi - (hi - lo) / 3; + if (snell(sx, sy, x0, m1, px, py) < snell(sx, sy, x0, m2, px, py)) + hi = m2; + else lo = m1; + } + expected = snell(sx, sy, x0, lo, px, py); + } + maxErr = Math.max(maxErr, Math.abs(t - expected)); + } + // Note: The interface is only resolved to within a pixel, which is + // where most of the error comes from. + assertTrue(maxErr < 0.6, "error " + maxErr); + + // The refracted path is faster than the straight one. + final var ra = times.randomAccess(); + final var straight = Math.hypot(x0 - sx, (39 - sy) * (x0 - sx) / (39 - + sx)) / 2 + Math.hypot(39 - x0, (39 - sy) * (39 - x0) / (39 - sx)); + assertTrue(ra.setPositionAndGet(39, 29).get() < straight - 1); + } + + private static double snell(final double sx, final double sy, + final double x0, final double y, final double px, final double py) + { + return Math.hypot(x0 - sx, y - sy) / 2 + Math.hypot(px - x0, py - y); + } + + @Test + public void testSeedMaskBarrierAndCost() { + // A wall at x = 10 with a gap at y in [0, 2]. + final var speed = ArrayImgs.doubles(20, 20); + final var c = speed.localizingCursor(); + while (c.hasNext()) { + c.fwd(); + c.get().set(c.getLongPosition(0) == 10 && c.getLongPosition(1) > 2 ? 0 + : 1); + } + final var seeds = ArrayImgs.bits(20, 20); + final var sa = seeds.randomAccess(); + sa.setPositionAndGet(2, 15).set(true); + sa.setPositionAndGet(3, 15).set(true); + final RandomAccessibleInterval times = ops.op( + "image.fastMarchingTimes").input(seeds, speed).outType(TIMES).apply(); + final var ra = times.randomAccess(); + assertEquals(0, ra.setPositionAndGet(2, 15).get()); + assertEquals(Double.POSITIVE_INFINITY, ra.setPositionAndGet(10, 15) + .get()); + // Going around the wall is longer than the straight distance. + assertTrue(ra.setPositionAndGet(17, 15).get() > 14 + 8); + + // A cost image (with infinite cost on the wall) gives the same result. + final var cost = ArrayImgs.doubles(20, 20); + final var cc = cost.cursor(); + for (final var s : speed) + cc.next().set(1 / s.get()); + final RandomAccessibleInterval fromCost = ops.op( + "image.fastMarchingTimes").input(seeds, cost, null, null, null, true) + .outType(TIMES).apply(); + assertArrayEquals(values(times), values(fromCost)); + + // Points and the equivalent mask give the same result. + final RandomAccessibleInterval fromPoints = ops.op( + "image.fastMarchingTimes").input(List.of(new Point(2, 15), new Point(3, + 15)), speed).outType(TIMES).apply(); + assertArrayEquals(values(times), values(fromPoints)); + + // Determinism. + final RandomAccessibleInterval again = ops.op( + "image.fastMarchingTimes").input(seeds, speed).outType(TIMES).apply(); + assertArrayEquals(values(times), values(again)); + } + + @Test + public void testStopTimeAndSegmentation() { + final var speed = ArrayImgs.doubles(31, 31); + speed.forEach(t -> t.set(1)); + final var seed = List.of(new Point(15, 15)); + final RandomAccessibleInterval full = ops.op( + "image.fastMarchingTimes").input(seed, speed).outType(TIMES).apply(); + final RandomAccessibleInterval stopped = ops.op( + "image.fastMarchingTimes").input(seed, speed, null, 6.0).outType(TIMES) + .apply(); + final RandomAccessibleInterval mask = ops.op( + "segment.fastMarching").input(seed, speed, 6.0).outType(MASK).apply(); + final var fc = Views.flatIterable(full).cursor(); + final var mc = Views.flatIterable(mask).cursor(); + var count = 0; + for (final var s : Views.flatIterable(stopped)) { + final var f = fc.next().get(); + final var inside = mc.next().get(); + assertEquals(f <= 6, inside); + assertEquals(inside ? f : Double.POSITIVE_INFINITY, s.get()); + if (inside) count++; + } + // Roughly a disk of radius 6. + assertEquals(Math.PI * 36, count, 12); + + // An offset interval is respected. + final var shifted = Views.translate(speed, 100, -50); + final RandomAccessibleInterval st = ops.op( + "image.fastMarchingTimes").input(List.of(new Point(115, -35)), shifted) + .outType(TIMES).apply(); + assertEquals(100, st.min(0)); + assertArrayEquals(values(full), values(st)); + assertThrows(RuntimeException.class, () -> ops.op( + "image.fastMarchingTimes").input(List.of(new Point(0, 0)), shifted).outType(TIMES).apply()); + } +} diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/segment/graphCut/BKMaxFlowTest.java b/scijava-ops-image/src/test/java/org/scijava/ops/image/segment/graphCut/BKMaxFlowTest.java new file mode 100644 index 000000000..b6f0d7485 --- /dev/null +++ b/scijava-ops-image/src/test/java/org/scijava/ops/image/segment/graphCut/BKMaxFlowTest.java @@ -0,0 +1,234 @@ +/*- + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.segment.graphCut; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Random; + +import org.junit.jupiter.api.Test; + +/** + * Tests {@link BKMaxFlow} against an Edmonds-Karp reference implementation and + * a brute-force minimum cut. + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +public class BKMaxFlowTest { + + /** + * On small random graphs, the maximum flow equals the brute-force minimum + * cut and the Edmonds-Karp flow, and the cut given by + * {@link BKMaxFlow#isSource} is a minimum cut. + */ + @Test + public void testSmallRandomGraphs() { + final var rnd = new Random(42); + for (var trial = 0; trial < 500; trial++) { + final var nodes = 1 + rnd.nextInt(10); + final var density = 0.2 + 0.6 * rnd.nextDouble(); + final var integer = trial % 2 == 0; + final var graph = randomGraph(rnd, nodes, density, integer); + final var bk = graph.build(); + final var flow = bk.computeMaximumFlow(); + final var cut = graph.minCutBruteForce(); + final var reference = graph.edmondsKarp(); + assertEquals(reference, flow, 1e-9, "trial " + trial); + assertEquals(cut, flow, 1e-9, "trial " + trial); + final var source = new boolean[nodes]; + for (var p = 0; p < nodes; p++) + source[p] = bk.isSource(p); + assertEquals(flow, graph.cutCapacity(source), 1e-9, "trial " + trial); + assertEquals(flow, bk.getFlow(), 0); + } + } + + /** Larger sparse random graphs, against Edmonds-Karp. */ + @Test + public void testLargerRandomGraphs() { + final var rnd = new Random(7); + for (var trial = 0; trial < 20; trial++) { + final var nodes = 100 + rnd.nextInt(200); + final var graph = randomGraph(rnd, nodes, 4.0 / nodes, trial % 2 == 0); + final var bk = graph.build(); + final var flow = bk.computeMaximumFlow(); + assertEquals(graph.edmondsKarp(), flow, 1e-6, "trial " + trial); + final var source = new boolean[nodes]; + for (var p = 0; p < nodes; p++) + source[p] = bk.isSource(p); + assertEquals(flow, graph.cutCapacity(source), 1e-6, "trial " + trial); + } + } + + /** A path s - 0 - 1 - 2 - t whose middle edge is the bottleneck. */ + @Test + public void testChain() { + final var bk = new BKMaxFlow(3, 2); + bk.addTerminalWeights(0, 5, 0); + bk.addTerminalWeights(2, 0, 4); + bk.addEdge(0, 1, 3, 0); + bk.addEdge(1, 2, 2, 0); + assertEquals(2, bk.computeMaximumFlow(), 0); + assertTrue(bk.isSource(0)); + assertTrue(bk.isSource(1)); + assertFalse(bk.isSource(2)); + assertThrows(IllegalStateException.class, () -> bk.addEdge(0, 2, 1, 1)); + } + + @Test + public void testInvalidCapacities() { + final var bk = new BKMaxFlow(2, 1); + assertThrows(IllegalArgumentException.class, () -> bk.addEdge(0, 1, -1, + 0)); + assertThrows(IllegalArgumentException.class, () -> bk.addTerminalWeights( + 0, Double.NaN, 0)); + } + + private static RandomGraph randomGraph(final Random rnd, final int nodes, + final double density, final boolean integer) + { + final var graph = new RandomGraph(nodes); + for (var p = 0; p < nodes; p++) { + if (rnd.nextDouble() < 0.4) graph.capacity[graph.s][p] += value(rnd, + integer); + if (rnd.nextDouble() < 0.4) graph.capacity[p][graph.t] += value(rnd, + integer); + for (var q = p + 1; q < nodes; q++) { + if (rnd.nextDouble() >= density) continue; + // Note: one-way edges are common, since a capacity may be zero. + final var pq = rnd.nextDouble() < 0.7 ? value(rnd, integer) : 0; + final var qp = rnd.nextDouble() < 0.7 ? value(rnd, integer) : 0; + graph.edges.add(new int[] { p, q }); + graph.capacity[p][q] += pq; + graph.capacity[q][p] += qp; + graph.edgeCapacities.add(new double[] { pq, qp }); + } + } + return graph; + } + + private static double value(final Random rnd, final boolean integer) { + return integer ? rnd.nextInt(10) : 10 * rnd.nextDouble(); + } + + /** A graph as a capacity matrix, with the terminals as extra nodes. */ + private static class RandomGraph { + + private final int nodes; + private final int s; + private final int t; + private final double[][] capacity; + private final List edges = new ArrayList<>(); + private final List edgeCapacities = + new ArrayList<>(); + + private RandomGraph(final int nodes) { + this.nodes = nodes; + s = nodes; + t = nodes + 1; + capacity = new double[nodes + 2][nodes + 2]; + } + + private BKMaxFlow build() { + final var bk = new BKMaxFlow(nodes, edges.size()); + for (var p = 0; p < nodes; p++) + bk.addTerminalWeights(p, capacity[s][p], capacity[p][t]); + for (var i = 0; i < edges.size(); i++) { + final var e = edges.get(i); + final var c = edgeCapacities.get(i); + bk.addEdge(e[0], e[1], c[0], c[1]); + } + return bk; + } + + private double cutCapacity(final boolean[] source) { + final var side = Arrays.copyOf(source, nodes + 2); + side[s] = true; + side[t] = false; + var sum = 0.0; + for (var p = 0; p < nodes + 2; p++) + for (var q = 0; q < nodes + 2; q++) + if (side[p] && !side[q]) sum += capacity[p][q]; + return sum; + } + + private double minCutBruteForce() { + var best = Double.POSITIVE_INFINITY; + final var source = new boolean[nodes]; + for (var mask = 0; mask < 1 << nodes; mask++) { + for (var p = 0; p < nodes; p++) + source[p] = (mask >> p & 1) != 0; + best = Math.min(best, cutCapacity(source)); + } + return best; + } + + private double edmondsKarp() { + final var size = nodes + 2; + final var residual = new double[size][]; + for (var p = 0; p < size; p++) + residual[p] = capacity[p].clone(); + var flow = 0.0; + while (true) { + final var previous = new int[size]; + Arrays.fill(previous, -1); + previous[s] = s; + final var queue = new ArrayDeque(); + queue.add(s); + while (!queue.isEmpty() && previous[t] < 0) { + final int p = queue.poll(); + for (var q = 0; q < size; q++) { + if (previous[q] < 0 && residual[p][q] > 1e-12) { + previous[q] = p; + queue.add(q); + } + } + } + if (previous[t] < 0) return flow; + var bottleneck = Double.POSITIVE_INFINITY; + for (var q = t; q != s; q = previous[q]) + bottleneck = Math.min(bottleneck, residual[previous[q]][q]); + for (var q = t; q != s; q = previous[q]) { + residual[previous[q]][q] -= bottleneck; + residual[q][previous[q]] += bottleneck; + } + flow += bottleneck; + } + } + } +} diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/segment/graphCut/GraphCutTest.java b/scijava-ops-image/src/test/java/org/scijava/ops/image/segment/graphCut/GraphCutTest.java new file mode 100644 index 000000000..a942242af --- /dev/null +++ b/scijava-ops-image/src/test/java/org/scijava/ops/image/segment/graphCut/GraphCutTest.java @@ -0,0 +1,312 @@ +/*- + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.segment.graphCut; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.ArrayList; +import java.util.Random; +import java.util.function.Predicate; + +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.algorithm.neighborhood.DiamondShape; +import net.imglib2.algorithm.neighborhood.RectangleShape; +import net.imglib2.algorithm.neighborhood.Shape; +import net.imglib2.img.array.ArrayImgs; +import net.imglib2.type.logic.BitType; +import net.imglib2.type.numeric.real.DoubleType; +import net.imglib2.view.Views; + +import org.junit.jupiter.api.Test; +import org.scijava.ops.image.AbstractOpTest; +import org.scijava.types.Nil; + +/** + * Tests {@link DefaultGraphCut} and {@link GraphCutCosts}. + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +public class GraphCutTest extends AbstractOpTest { + + private static final Nil> MASK = + new Nil<>() + {}; + + /** A noisy bright square on a dark background is recovered. */ + @Test + public void testNoisyTwoRegions() { + final var truth = new boolean[64 * 64]; + final var image = noisy(new long[] { 64, 64 }, truth, 0.5, 1, x -> x[0] >= + 16 && x[0] < 48 && x[1] >= 20 && x[1] < 52); + + final var pixelwise = errorRate(segment(image, 0.0, null, null, null, + null), truth); + assertTrue(pixelwise > 0.1, "pixelwise error " + pixelwise); + final var smooth = errorRate(segment(image, null, null, null, null, false), + truth); + assertTrue(smooth < 0.01, "graph cut error " + smooth); + // The defaults are lambda = 1 and full connectivity. + assertArrayEquals(bits(segment(image, 1.0, null, null, false, true)), bits( + segment(image, null, null, null, null, null))); + final var full = errorRate(segment(image, 1.0, null, null, false, true), + truth); + assertTrue(full < 0.01, "full connectivity error " + full); + // Note: the contrast term weakens the smoothness term across noise too, + // which a larger lambda compensates for. + final var contrast = errorRate(segment(image, 3.0, null, null, true, + false), truth); + assertTrue(contrast < 0.03, "contrast error " + contrast); + } + + /** A noisy ball in 3D is recovered, with full connectivity. */ + @Test + public void testNoisyBall3D() { + final var truth = new boolean[24 * 24 * 24]; + final var image = noisy(new long[] { 24, 24, 24 }, truth, 0.6, 2, x -> { + var r2 = 0.0; + for (final var c : x) + r2 += (c - 11.5) * (c - 11.5); + return r2 < 64; + }); + final var pixelwise = errorRate(segment(image, 0.0, 1.0, 0.0, null, + true), truth); + // Note: with full connectivity in 3D, a boundary cuts about six times as + // much weight per unit area as with face connectivity. + final var smooth = errorRate(segment(image, 0.25, 1.0, 0.0, null, true), + truth); + assertTrue(pixelwise > 0.1, "pixelwise error " + pixelwise); + assertTrue(smooth < 0.02, "graph cut error " + smooth); + } + + /** With lambda = 0, the segmentation thresholds the data term. */ + @Test + public void testZeroLambdaThresholds() { + final var rnd = new Random(3); + final var image = ArrayImgs.doubles(17, 13); + for (final var v : image) + v.set(100 * rnd.nextDouble()); + final double fg = 70, bg = 20; + final var mask = segment(image, 0.0, fg, bg, null, null); + final var m = Views.flatIterable(mask).cursor(); + for (final var v : Views.flatIterable(image)) { + final var expected = Math.abs(v.get() - fg) < Math.abs(v.get() - bg); + assertEquals(expected, m.next().get(), "value " + v.get()); + } + + // The same holds with cost images, also with negative costs. + final var fgCost = ArrayImgs.doubles(17, 13); + final var bgCost = ArrayImgs.doubles(17, 13); + for (final var v : fgCost) + v.set(rnd.nextGaussian()); + for (final var v : bgCost) + v.set(rnd.nextGaussian()); + final var costMask = segmentCosts(fgCost, bgCost, 0.0, null, null); + final var f = fgCost.cursor(); + final var b = bgCost.cursor(); + final var c = Views.flatIterable(costMask).cursor(); + while (f.hasNext()) + assertEquals(f.next().get() < b.next().get(), c.next().get()); + } + + /** + * On a small grid, the segmentation of the cost op has the minimal energy + * among all labelings, found by brute force. + */ + @Test + public void testMinimalEnergy() { + final var rnd = new Random(11); + final long[] dims = { 4, 3 }; + for (var trial = 0; trial < 20; trial++) { + final var fgCost = ArrayImgs.doubles(dims); + final var bgCost = ArrayImgs.doubles(dims); + final var contrast = ArrayImgs.doubles(dims); + for (final var v : fgCost) + v.set(rnd.nextDouble()); + for (final var v : bgCost) + v.set(rnd.nextDouble()); + for (final var v : contrast) + v.set(rnd.nextDouble()); + final var lambda = 0.5 * rnd.nextDouble(); + final var full = trial % 2 == 1; + final var useContrast = trial % 4 >= 2; + final var mask = segmentCosts(fgCost, bgCost, lambda, useContrast + ? contrast : null, full); + final var labels = new boolean[12]; + var i = 0; + for (final var v : Views.flatIterable(mask)) + labels[i++] = v.get(); + + final var f = fgCost.update(null).getCurrentStorageArray(); + final var b = bgCost.update(null).getCurrentStorageArray(); + final var beta = useContrast ? beta(contrast.update(null) + .getCurrentStorageArray(), full) : 0; + final var energy = energy(labels, f, b, contrast.update(null) + .getCurrentStorageArray(), lambda, beta, full); + var best = Double.POSITIVE_INFINITY; + for (var mask2 = 0; mask2 < 1 << 12; mask2++) { + final var l = new boolean[12]; + for (var p = 0; p < 12; p++) + l[p] = (mask2 >> p & 1) != 0; + best = Math.min(best, energy(l, f, b, contrast.update(null) + .getCurrentStorageArray(), lambda, beta, full)); + } + assertEquals(best, energy, 1e-9, "trial " + trial); + } + } + + /** + * The estimated data term scales with the intensities, so an affine + * intensity transform gives the same segmentation. + */ + @Test + public void testAffineInvariance() { + final var truth = new boolean[40 * 30]; + final var image = noisy(new long[] { 40, 30 }, truth, 0.7, 5, x -> x[0] + + x[1] < 35); + final var transformed = ArrayImgs.doubles(40, 30); + final var t = transformed.cursor(); + for (final var v : image) + t.next().set(3 * v.get() + 10); + final var expected = bits(segment(image, 1.5, null, null, true, true)); + assertArrayEquals(expected, bits(segment(transformed, 1.5, null, null, + true, true))); + var foreground = 0; + for (final var b : expected) + if (b) foreground++; + assertTrue(foreground > 0 && foreground < expected.length); + } + + private RandomAccessibleInterval segment( + final RandomAccessibleInterval image, final Double lambda, + final Double fgMean, final Double bgMean, final Boolean contrast, + final Boolean full) + { + return ops.op("segment.graphCut").input(image, lambda, fgMean, bgMean, + contrast, connectivity(full)).outType(MASK).apply(); + } + + private RandomAccessibleInterval segmentCosts( + final RandomAccessibleInterval fgCost, + final RandomAccessibleInterval bgCost, final Double lambda, + final RandomAccessibleInterval contrast, final Boolean full) + { + return ops.op("segment.graphCut").input(fgCost, bgCost, lambda, contrast, + connectivity(full)).outType(MASK).apply(); + } + + /** Gets full or face connectivity, or null for the default. */ + private static Shape connectivity(final Boolean full) { + if (full == null) return null; + return full ? new RectangleShape(1, false) : new DiamondShape(1); + } + + private static boolean[] bits(final RandomAccessibleInterval mask) { + final var bits = new boolean[(int) mask.size()]; + var i = 0; + for (final var v : Views.flatIterable(mask)) + bits[i++] = v.get(); + return bits; + } + + private static double errorRate(final RandomAccessibleInterval mask, + final boolean[] truth) + { + var errors = 0; + var i = 0; + for (final var v : Views.flatIterable(mask)) + if (v.get() != truth[i++]) errors++; + return (double) errors / truth.length; + } + + /** + * Creates an image of 1 inside the region and 0 outside, plus Gaussian + * noise. + */ + private static RandomAccessibleInterval noisy(final long[] dims, + final boolean[] truth, final double noise, final long seed, + final Predicate region) + { + final var rnd = new Random(seed); + final var image = ArrayImgs.doubles(dims); + final var cursor = Views.flatIterable(image).localizingCursor(); + var i = 0; + while (cursor.hasNext()) { + cursor.fwd(); + truth[i] = region.test(cursor.positionAsLongArray()); + cursor.get().set((truth[i] ? 1 : 0) + noise * rnd.nextGaussian()); + i++; + } + return image; + } + + /** The 4 x 3 grid neighbor pairs, with their distances. */ + private static double[][] pairs(final boolean full) { + final var pairs = new ArrayList(); + for (var y = 0; y < 3; y++) + for (var x = 0; x < 4; x++) + for (var dy = -1; dy <= 1; dy++) + for (var dx = -1; dx <= 1; dx++) { + if (dy < 0 || dy == 0 && dx <= 0) continue; + if (!full && dx != 0 && dy != 0) continue; + final var x2 = x + dx; + final var y2 = y + dy; + if (x2 < 0 || x2 >= 4 || y2 >= 3) continue; + pairs.add(new double[] { y * 4 + x, y2 * 4 + x2, Math.sqrt(dx * dx + + dy * dy) }); + } + return pairs.toArray(new double[0][]); + } + + private static double beta(final double[] contrast, final boolean full) { + var sum = 0.0; + final var pairs = pairs(full); + for (final var pair : pairs) + sum += Math.pow(contrast[(int) pair[0]] - contrast[(int) pair[1]], 2); + return pairs.length / (2 * sum); + } + + private static double energy(final boolean[] labels, final double[] fg, + final double[] bg, final double[] contrast, final double lambda, + final double beta, final boolean full) + { + var e = 0.0; + for (var p = 0; p < labels.length; p++) + e += labels[p] ? fg[p] : bg[p]; + for (final var pair : pairs(full)) { + final int p = (int) pair[0], q = (int) pair[1]; + if (labels[p] != labels[q]) e += lambda * Math.exp(-beta * Math.pow( + contrast[p] - contrast[q], 2)) / pair[2]; + } + return e; + } +} diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/segment/hough/HoughCircleDetectorTest.java b/scijava-ops-image/src/test/java/org/scijava/ops/image/segment/hough/HoughCircleDetectorTest.java new file mode 100644 index 000000000..244f8e943 --- /dev/null +++ b/scijava-ops-image/src/test/java/org/scijava/ops/image/segment/hough/HoughCircleDetectorTest.java @@ -0,0 +1,193 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.segment.hough; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; + +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.img.array.ArrayImgs; +import net.imglib2.type.logic.BitType; +import net.imglib2.type.numeric.real.DoubleType; +import net.imglib2.view.Views; + +import org.junit.jupiter.api.Test; +import org.scijava.ops.image.AbstractOpTest; +import org.scijava.types.Nil; + +/** + * Tests {@link HoughCircleDetector}. + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +public class HoughCircleDetectorTest extends AbstractOpTest { + + /** + * Draws the discrete sphere (offsets whose norm rounds to the radius) into + * the image, restricted to points whose first coordinate is at least + * {@code xMin}. + */ + private static void drawSphere(final RandomAccessibleInterval img, + final long[] center, final long radius, final long xMin) + { + final var c = Views.iterable(img).localizingCursor(); + while (c.hasNext()) { + c.fwd(); + if (c.getLongPosition(0) < xMin) continue; + var r2 = 0.0; + for (var d = 0; d < center.length; d++) { + final var dx = c.getDoublePosition(d) - center[d]; + r2 += dx * dx; + } + if (Math.round(Math.sqrt(r2)) == radius) c.get().set(true); + } + } + + private List detect(final RandomAccessibleInterval img, + final int minRadius, final int maxRadius, final Integer stepRadius, + final Double sensitivity) + { + final RandomAccessibleInterval votes = ops.op( + "transform.houghCircle").input(img, minRadius, maxRadius, stepRadius) + .outType(new Nil>() + {}).apply(); + return ops.op("segment.detectHoughCircles") // + .input(votes, minRadius, stepRadius, sensitivity) // + .outType(new Nil>() + {}).apply(); + } + + private static void assertCircle(final HoughCircle circle, + final double radius, final double... center) + { + assertEquals(center.length, circle.numDimensions()); + for (var d = 0; d < center.length; d++) + assertEquals(center[d], circle.getDoublePosition(d), 0.5); + assertEquals(radius, circle.getRadius(), 0.5); + } + + /** Two complete rings are found, with sensitivity close to 1. */ + @Test + public void testTwoRings() { + final var img = ArrayImgs.bits(64, 50); + drawSphere(img, new long[] { 16, 15 }, 8, Long.MIN_VALUE); + drawSphere(img, new long[] { 44, 30 }, 12, Long.MIN_VALUE); + final var circles = detect(img, 5, 15, null, 3.0); + assertEquals(2, circles.size(), circles.toString()); + final var small = circles.get(0).getRadius() < circles.get(1).getRadius() + ? circles.get(0) : circles.get(1); + final var large = small == circles.get(0) ? circles.get(1) : circles.get( + 0); + assertCircle(small, 8, 16, 15); + assertCircle(large, 12, 44, 30); + for (final var c : circles) + assertEquals(1, c.getSensitivity(), 0.25); + assertTrue(circles.get(0).getSensitivity() <= circles.get(1) + .getSensitivity()); + } + + /** + * The permissive default sensitivity also reports weak spurious circles, but + * the true circles come first. + */ + @Test + public void testDefaultSensitivity() { + final var img = ArrayImgs.bits(64, 50); + drawSphere(img, new long[] { 16, 15 }, 8, Long.MIN_VALUE); + drawSphere(img, new long[] { 44, 30 }, 12, Long.MIN_VALUE); + final var circles = detect(img, 5, 15, null, null); + assertTrue(circles.size() > 2); + assertCircle(circles.get(0), 8, 16, 15); + assertCircle(circles.get(1), 12, 44, 30); + for (final var c : circles.subList(2, circles.size())) + assertTrue(c.getSensitivity() > 3 && c.getSensitivity() <= 20); + } + + /** A radius step greater than 1 still yields the right radius. */ + @Test + public void testRadiusStep() { + final var img = ArrayImgs.bits(40, 40); + drawSphere(img, new long[] { 20, 19 }, 11, Long.MIN_VALUE); + final var circles = detect(img, 5, 17, 2, 3.0); + assertEquals(1, circles.size(), circles.toString()); + assertCircle(circles.get(0), 11, 20, 19); + } + + /** + * A half ring has sensitivity close to 2, so it is only found with a + * permissive enough sensitivity. + */ + @Test + public void testSensitivity() { + final var img = ArrayImgs.bits(40, 40); + drawSphere(img, new long[] { 20, 20 }, 10, 20); + final var circles = detect(img, 8, 12, null, 3.0); + assertEquals(1, circles.size(), circles.toString()); + assertCircle(circles.get(0), 10, 20, 20); + assertEquals(2, circles.get(0).getSensitivity(), 0.5); + assertEquals(0, detect(img, 8, 12, null, 1.5).size()); + } + + /** A 3D spherical shell is found as a sphere. */ + @Test + public void testSphere3D() { + final var img = ArrayImgs.bits(24, 22, 20); + drawSphere(img, new long[] { 12, 10, 9 }, 6, Long.MIN_VALUE); + final var circles = detect(img, 4, 8, null, 3.0); + assertEquals(1, circles.size(), circles.toString()); + assertCircle(circles.get(0), 6, 12, 10, 9); + assertEquals(1, circles.get(0).getSensitivity(), 0.25); + } + + @Test + public void testEmpty() { + assertTrue(detect(ArrayImgs.bits(20, 20), 3, 6, null, null).isEmpty()); + } + + @Test + public void testInvalidSensitivity() { + assertThrows(IllegalArgumentException.class, () -> detect(ArrayImgs.bits( + 10, 10), 1, 3, null, 0.0)); + } + + /** NaN votes are ignored instead of being reported as circles. */ + @Test + public void testNaNVotes() { + final var votes = ArrayImgs.doubles(20, 20, 3); + votes.randomAccess().setPositionAndGet(10, 10, 1).set(Double.NaN); + final List circles = ops.op("segment.detectHoughCircles") + .input(votes, 3, 1, 20.0).outType(new Nil>() + {}).apply(); + assertTrue(circles.isEmpty()); + } +} diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/segment/hwatershed/HWatershedTest.java b/scijava-ops-image/src/test/java/org/scijava/ops/image/segment/hwatershed/HWatershedTest.java new file mode 100644 index 000000000..276674b43 --- /dev/null +++ b/scijava-ops-image/src/test/java/org/scijava/ops/image/segment/hwatershed/HWatershedTest.java @@ -0,0 +1,560 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.segment.hwatershed; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.HashSet; +import java.util.Set; + +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.algorithm.neighborhood.DiamondShape; +import net.imglib2.algorithm.neighborhood.RectangleShape; +import net.imglib2.algorithm.neighborhood.Shape; +import net.imglib2.img.Img; +import net.imglib2.img.array.ArrayImgs; +import net.imglib2.roi.labeling.ImgLabeling; +import net.imglib2.type.numeric.integer.IntType; +import net.imglib2.type.numeric.real.FloatType; +import net.imglib2.view.Views; + +import org.junit.jupiter.api.Test; +import org.scijava.ops.image.AbstractOpTest; +import org.scijava.types.Nil; + +/** + * Tests {@code segment.hWatershed}. + *

+ * The {@code EXPECTED_*} label images were produced by running the original + * Interactive H-Watershed classes ({@code HWatershedLabeling} with threshold + * {@code -Infinity}, then {@code SegmentHierarchyToLabelMap.getLabelMap}, as + * done by its headless {@code HWatershed_Plugin}) on the same integer-valued + * synthetic images built by {@link #bumps}. Each string lists the labels of + * the image in flat iteration order, one row of the first dimension per line. + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +public class HWatershedTest extends AbstractOpTest { + + private static final long[] DIMS_2D = { 20, 20 }; + private static final double[][] PEAKS_2D = { // + { 5, 5, 100, 3 }, { 12, 6, 80, 3 }, { 8, 13, 60, 2.5 }, // + { 16, 15, 30, 2 }, { 3, 15, 12, 1.5 } }; + + private static final long[] DIMS_3D = { 10, 9, 6 }; + private static final double[][] PEAKS_3D = { // + { 2, 2, 2, 50, 1.5 }, { 7, 3, 3, 40, 1.5 }, { 4, 7, 3, 30, 1.2 } }; + + /** Original plugin, 2D FACE h=5.0 T=10.0 pf=100.0 split=true. */ + private static final String EXPECTED_2D_FACE_H5_T10 = // + "33333333333333333333" + // + "33333333333333333333" + // + "33333333333333333333" + // + "33333333333333333333" + // + "33333333333333333333" + // + "33333333333333333333" + // + "33333333333333333333" + // + "33333333333333333333" + // + "33333333333333333333" + // + "33333333333333333333" + // + "33333333333333333333" + // + "22222222222233333333" + // + "22222222222222311111" + // + "22222222222222111111" + // + "22222222222222111111" + // + "22222222222221111111" + // + "22222222222221111111" + // + "22222222222221111111" + // + "22222222222221111111" + // + "22222222222221111111"; + + /** Original plugin, 2D FACE h=0.0 T=10.0 pf=100.0 split=true. */ + private static final String EXPECTED_2D_FACE_H0_T10 = // + "44444444443333333333" + // + "44444444443333333333" + // + "44444444443333333333" + // + "44444444443333333333" + // + "44444444443333333333" + // + "44444444443333333333" + // + "44444444443333333333" + // + "44444444443333333333" + // + "44444444443333333333" + // + "44444444443333333333" + // + "44444444443333333333" + // + "22222222222233333333" + // + "22222222222222311111" + // + "22222222222222111111" + // + "22222222222222111111" + // + "22222222222221111111" + // + "22222222222221111111" + // + "22222222222221111111" + // + "22222222222221111111" + // + "22222222222221111111"; + + /** Original plugin, 2D FACE h=5.0 T=20.0 pf=100.0 split=true. */ + private static final String EXPECTED_2D_FACE_H5_T20 = // + "03333333333333000000" + // + "33333333333333330000" + // + "33333333333333333000" + // + "33333333333333333300" + // + "33333333333333333300" + // + "33333333333333333330" + // + "33333333333333333330" + // + "33333333333333333330" + // + "33333333333333333300" + // + "33333333333333333300" + // + "03333333333333333000" + // + "00222222222233333000" + // + "00022222222222311100" + // + "00022222222222111110" + // + "00222222222222111110" + // + "00222222222221111110" + // + "00022222222221111110" + // + "00000222222000111110" + // + "00000000000000000000" + // + "00000000000000000000"; + + /** Original plugin, 2D FACE h=5.0 T=30.0 pf=50.0 split=false. */ + private static final String EXPECTED_2D_FACE_H5_T30_PF50_NOSPLIT = // + "00000000000000000000" + // + "00000000000000000000" + // + "00000000000000000000" + // + "00033333300000000000" + // + "00033333333333000000" + // + "00033333333333000000" + // + "00033333333333300000" + // + "00033333333333000000" + // + "00000333333333000000" + // + "00000000000000000000" + // + "00000000000000000000" + // + "00000022222200000000" + // + "00000022222000000000" + // + "00000022222000000000" + // + "00000022222000011000" + // + "00000000200000011100" + // + "00000000000000001000" + // + "00000000000000000000" + // + "00000000000000000000" + // + "00000000000000000000"; + + /** Original plugin, 2D FACE h=5.0 T=30.0 pf=70.0 split=true. */ + private static final String EXPECTED_2D_FACE_H5_T30_PF70 = // + "00000000000000000000" + // + "00000000000000000000" + // + "00033333300000000000" + // + "00333333333333000000" + // + "00333333333333300000" + // + "00333333333333330000" + // + "00333333333333330000" + // + "00333333333333330000" + // + "00033333333333300000" + // + "00000333333333000000" + // + "00000033333300000000" + // + "00000222222200000000" + // + "00000222222200000000" + // + "00000222222200000000" + // + "00000022222000011100" + // + "00000022220000011100" + // + "00000000000000011100" + // + "00000000000000000000" + // + "00000000000000000000" + // + "00000000000000000000"; + + /** Original plugin, 2D FULL h=0.0 T=25.0 pf=80.0 split=true. */ + private static final String EXPECTED_2D_FULL_H0_T25_PF80 = // + "00000000000000000000" + // + "00044444400000000000" + // + "00444444444443000000" + // + "00444444444433300000" + // + "04444444444333330000" + // + "04444444443333333000" + // + "04444444443333333000" + // + "04444444443333333000" + // + "00444444443333330000" + // + "00044444433333330000" + // + "00000444443333000000" + // + "00000422222330000000" + // + "00000222222200000000" + // + "00000222222200111000" + // + "00000222222200111100" + // + "00000222222000111110" + // + "00000002220000011100" + // + "00000000000000001000" + // + "00000000000000000000" + // + "00000000000000000000"; + + /** Original plugin, 3D FACE h=3.0 T=0.0 pf=100.0 split=true. */ + private static final String EXPECTED_3D_FACE_H3 = // + "3333332222" + // + "3333332222" + // + "3333332222" + // + "3333322222" + // + "3333322222" + // + "3333122222" + // + "3331112222" + // + "3311111122" + // + "1111111122" + // + "3333332222" + // + "3333332222" + // + "3333332222" + // + "3333322222" + // + "3333322222" + // + "3333122222" + // + "3331112222" + // + "3111111122" + // + "1111111122" + // + "3333332222" + // + "3333332222" + // + "3333332222" + // + "3333322222" + // + "3333322222" + // + "3333122222" + // + "3311112222" + // + "1111111122" + // + "1111111122" + // + "3333322222" + // + "3333322222" + // + "3333322222" + // + "3333322222" + // + "3333322222" + // + "3331112222" + // + "3111111222" + // + "1111111122" + // + "1111111112" + // + "3333322222" + // + "3333322222" + // + "3333322222" + // + "3333322222" + // + "3333222222" + // + "3331122222" + // + "1111111222" + // + "1111111122" + // + "1111111122" + // + "3333322222" + // + "3333322222" + // + "3333322222" + // + "3333322222" + // + "3333222222" + // + "3331122222" + // + "1111111222" + // + "1111111122" + // + "1111111122"; + + /** Original plugin, 3D FULL h=0.0 T=5.0 pf=60.0 split=true. */ + private static final String EXPECTED_3D_FULL_H0_T5_PF60 = // + "0000000000" + // + "0000000000" + // + "0000000000" + // + "0000000000" + // + "0000000000" + // + "0000000000" + // + "0000000000" + // + "0000000000" + // + "0000000000" + // + "0000000000" + // + "0333000000" + // + "0333000000" + // + "0333000000" + // + "0000000000" + // + "0000000000" + // + "0000000000" + // + "0000000000" + // + "0000000000" + // + "0000000000" + // + "0333000000" + // + "0333302220" + // + "0333002220" + // + "0000002220" + // + "0000000000" + // + "0000100000" + // + "0001110000" + // + "0000100000" + // + "0000000000" + // + "0333000000" + // + "0333002220" + // + "0333022220" + // + "0000002220" + // + "0000000000" + // + "0001110000" + // + "0001110000" + // + "0000110000" + // + "0000000000" + // + "0000000000" + // + "0000002220" + // + "0000002220" + // + "0000002220" + // + "0000000000" + // + "0000100000" + // + "0001110000" + // + "0000100000" + // + "0000000000" + // + "0000000000" + // + "0000000000" + // + "0000000000" + // + "0000000000" + // + "0000000000" + // + "0000000000" + // + "0000000000" + // + "0000000000"; + + // -- Regression tests against the original plugin -- + + @Test + public void testRegression2D() { + final var img = bumps(DIMS_2D, 10, PEAKS_2D); + assertEquals(EXPECTED_2D_FACE_H5_T10, digits(hWatershed(img, 5, 10, 100, + true, false))); + assertEquals(EXPECTED_2D_FACE_H0_T10, digits(hWatershed(img, 0, 10, 100, + true, false))); + assertEquals(EXPECTED_2D_FACE_H5_T20, digits(hWatershed(img, 5, 20, 100, + true, false))); + assertEquals(EXPECTED_2D_FACE_H5_T30_PF50_NOSPLIT, digits(hWatershed(img, 5, + 30, 50, false, false))); + assertEquals(EXPECTED_2D_FACE_H5_T30_PF70, digits(hWatershed(img, 5, 30, + 70, true, false))); + assertEquals(EXPECTED_2D_FULL_H0_T25_PF80, digits(hWatershed(img, 0, 25, + 80, true, true))); + } + + @Test + public void testRegression3D() { + final var img = bumps(DIMS_3D, 0, PEAKS_3D); + assertEquals(EXPECTED_3D_FACE_H3, digits(hWatershed(img, 3, 0, 100, true, + false))); + assertEquals(EXPECTED_3D_FULL_H0_T5_PF60, digits(hWatershed(img, 0, 5, 60, + true, true))); + } + + // -- Analytic tests -- + + /** + * Two peaks of heights 100 and 80.25, separated by a saddle at 60: the lower + * peak has dynamics 20.25, so it seeds its own segment iff hMin < 20.25. + */ + @Test + public void testDynamicsSplit() { + final var img = twoPeaks(); + assertEquals(2, labelCount(hWatershed(img, 20.2, 0, 100, true, false))); + assertEquals(1, labelCount(hWatershed(img, 20.3, 0, 100, true, false))); + assertEquals(2, labelCount(hWatershed(img, 0, 0, 100, true, true))); + + // Every pixel is labeled when there is no threshold. + final var split = hWatershed(img, 10, 0, 100, true, false); + assertEquals(0, countBackground(split)); + // The basins meet at the saddle (x = 10). + final var ra = split.randomAccess(); + ra.setPosition(new long[] { 9, 0 }); + final var left = ra.get().iterator().next(); + ra.setPosition(new long[] { 11, 0 }); + final var right = ra.get().iterator().next(); + assertTrue(left != right); + } + + /** Threshold and peak flooding remove the low pixels of each segment. */ + @Test + public void testThresholdAndPeakFlooding() { + final var img = twoPeaks(); + // Profile values >= 70 are at x in [2, 8] (peak 100) and x in [12, 18] + // (peak 80.25), on all 3 rows. + final var thresholded = hWatershed(img, 10, 70, 100, true, false); + assertEquals(2, labelCount(thresholded)); + assertEquals(21 * 3 - (7 + 7) * 3, countBackground(thresholded)); + + // With 50% flooding, the thresholds are 70 + 30 / 2 = 85 and + // 70 + 10.25 / 2 = 75.125, which keeps x in [4, 6] and x in [13, 17]. + final var flooded = hWatershed(img, 10, 70, 50, true, false); + assertEquals(2, labelCount(flooded)); + assertEquals(21 * 3 - (3 + 5) * 3, countBackground(flooded)); + } + + /** + * A threshold above the saddle separates a low peak from the segment it was + * merged into: it is kept iff splitting is allowed. + */ + @Test + public void testAllowSplitting() { + final var img = twoPeaks(); + assertEquals(2, labelCount(hWatershed(img, 30, 65, 100, true, false))); + assertEquals(1, labelCount(hWatershed(img, 30, 65, 100, false, false))); + assertEquals(1, labelCount(hWatershed(img, 30, 55, 100, true, false))); + } + + @Test + public void testDefaults() { + final var img = bumps(DIMS_2D, 10, PEAKS_2D); + final ImgLabeling defaults = ops.op("segment.hWatershed") + .input(img).outType(new Nil>() + {}).apply(); + // By default, hMin is 5% of the range, threshold is the image minimum, + // and the connectivity is full. + final var range = 115 - 10; + assertEquals(digits(hWatershed(img, 0.05 * range, 10, 100, true, true)), + digits(defaults)); + } + + @Test + public void testFlatImage() { + final var img = ArrayImgs.floats(5, 4); + for (final var t : img) + t.set(3); + final var labeling = hWatershed(img, 0, 0, 100, true, false); + assertEquals(0, labelCount(labeling)); + assertEquals(20, countBackground(labeling)); + } + + /** The op works in any dimension; here two 4D bumps. */ + @Test + public void test4D() { + final var img = bumps(new long[] { 7, 6, 5, 4 }, 0, new double[][] { // + { 1, 1, 1, 1, 50, 1.2 }, { 5, 4, 3, 2, 50, 1.2 } }); + assertEquals(2, labelCount(hWatershed(img, 5, 0, 100, true, false))); + assertEquals(1, labelCount(hWatershed(img, 60, 0, 100, true, false))); + } + + @Test + public void testNonZeroMinInterval() { + final var img = bumps(DIMS_2D, 10, PEAKS_2D); + final var translated = Views.translate(img, 5, -3); + assertEquals(EXPECTED_2D_FACE_H5_T10, digits(hWatershed(translated, 5, 10, + 100, true, false))); + } + + @Test + public void testNegativeHMin() { + final var img = twoPeaks(); + assertThrows(IllegalArgumentException.class, () -> hWatershed(img, -1, 0, + 100, true, false)); + } + + /** NaN has no order, so images containing NaN are rejected. */ + @Test + public void testNaN() { + final var img = twoPeaks(); + img.firstElement().set(Float.NaN); + assertThrows(IllegalArgumentException.class, () -> hWatershed(img, 1, 0, + 100, true, false)); + } + + // -- Helper methods -- + + private ImgLabeling hWatershed( + final RandomAccessibleInterval img, final double hMin, + final double threshold, final double peakFlooding, + final boolean allowSplitting, final boolean useEightConnectivity) + { + final Shape connectivity = useEightConnectivity ? new RectangleShape(1, + false) : new DiamondShape(1); + return ops.op("segment.hWatershed").input(img, hMin, threshold, + peakFlooding, allowSplitting, connectivity).outType( + new Nil>() + {}).apply(); + } + + /** + * A 21x3 image whose rows all follow the same piecewise linear profile: a + * peak of 100 at x = 5, a saddle of 60 at x = 10 and a peak of 80.25 at x = + * 15, descending by 10 per pixel elsewhere. + */ + private static Img twoPeaks() { + final float[] profile = { 50, 60, 70, 80, 90, 100, 90, 80, 70, 65, 60, + 65.25f, 70.25f, 75.25f, 77.75f, 80.25f, 77.75f, 75.25f, 70.25f, 60.25f, + 50.25f }; + final var w = profile.length; + final var data = new float[w * 3]; + for (var y = 0; y < 3; y++) + System.arraycopy(profile, 0, data, y * w, w); + return ArrayImgs.floats(data, w, 3); + } + + /** + * Integer-valued sum of isotropic Gaussian bumps, each given as + * {@code {position..., amplitude, sigma}}; must match the harness used to + * produce the expected values. + */ + private static Img bumps(final long[] dims, final double base, + final double[][] peaks) + { + var n = 1; + for (final var d : dims) + n *= (int) d; + final var data = new float[n]; + final var pos = new long[dims.length]; + for (var i = 0; i < n; i++) { + var idx = i; + for (var d = 0; d < dims.length; d++) { + pos[d] = idx % dims[d]; + idx /= (int) dims[d]; + } + var v = base; + for (final var p : peaks) { + double r2 = 0; + for (var d = 0; d < dims.length; d++) { + final var dd = pos[d] - p[d]; + r2 += dd * dd; + } + final var s = p[dims.length + 1]; + v += p[dims.length] * Math.exp(-r2 / (2 * s * s)); + } + data[i] = (float) Math.floor(v); + } + return ArrayImgs.floats(data, dims); + } + + private static String digits(final ImgLabeling labeling) { + final var sb = new StringBuilder(); + for (final var t : Views.flatIterable(labeling)) { + assertTrue(t.size() <= 1); + final var label = t.isEmpty() ? 0 : t.iterator().next(); + assertTrue(label < 10); + sb.append(label); + } + return sb.toString(); + } + + private static int labelCount(final ImgLabeling labeling) { + final Set labels = new HashSet<>(); + for (final var t : labeling) + labels.addAll(t); + return labels.size(); + } + + private static int countBackground( + final ImgLabeling labeling) + { + var count = 0; + for (final var t : labeling) + if (t.isEmpty()) count++; + return count; + } +} diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/segment/kmeans/KMeansTest.java b/scijava-ops-image/src/test/java/org/scijava/ops/image/segment/kmeans/KMeansTest.java new file mode 100644 index 000000000..aea82a355 --- /dev/null +++ b/scijava-ops-image/src/test/java/org/scijava/ops/image/segment/kmeans/KMeansTest.java @@ -0,0 +1,237 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.segment.kmeans; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Arrays; +import java.util.Random; + +import net.imglib2.img.array.ArrayImgs; +import net.imglib2.roi.labeling.ImgLabeling; +import net.imglib2.type.numeric.integer.IntType; +import net.imglib2.view.Views; + +import org.junit.jupiter.api.Test; +import org.scijava.ops.image.AbstractOpTest; +import org.scijava.types.Nil; + +/** + * Tests {@link KMeans}. + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +public class KMeansTest extends AbstractOpTest { + + private static final Nil> LABELS = + new Nil<>() + {}; + + /** Three well-separated intensity populations are recovered exactly. */ + @Test + public void testIntensityMixture() { + final var rnd = new Random(1); + final var means = new double[] { 200, 20, 100 }; + final var in = ArrayImgs.doubles(30, 20); + final var truth = new int[30 * 20]; + var i = 0; + for (final var t : in) { + final var c = rnd.nextInt(3); + truth[i++] = c; + t.set(means[c] + 5 * rnd.nextGaussian()); + } + final ImgLabeling labels = ops.op("segment.kMeans") + .input(in, 3) + .outType(LABELS).apply(); + // Labels are ordered by center: 20 -> 1, 100 -> 2, 200 -> 3. + final var expected = new int[] { 3, 1, 2 }; + i = 0; + for (final var set : Views.flatIterable(labels)) { + assertEquals(1, set.size()); + assertEquals(expected[truth[i++]], set.iterator().next()); + } + } + + /** Clusters separable only in feature space, along a channel axis. */ + @Test + public void testFeatureVectors() { + final var rnd = new Random(2); + final double[][] centers = { { 60, 0 }, { 0, 50 }, { 70, 50 }, { 10, 0 } }; + final var in = ArrayImgs.floats(16, 12, 2); + final var truth = new int[16 * 12]; + final var ra = in.randomAccess(); + for (var y = 0; y < 12; y++) { + for (var x = 0; x < 16; x++) { + final var c = rnd.nextInt(4); + truth[y * 16 + x] = c; + for (var ch = 0; ch < 2; ch++) { + ra.setPositionAndGet(x, y, ch).set((float) (centers[c][ch] + 3 * rnd + .nextGaussian())); + } + } + } + final ImgLabeling labels = ops.op("segment.kMeans") + .input(in, 4, 2, 7L) + .outType(LABELS).apply(); + assertArrayEquals(new long[] { 16, 12 }, labels.dimensionsAsLongArray()); + // Lexicographic order: (0,50), (10,0), (60,0), (70,50). + final var expected = new int[] { 3, 1, 4, 2 }; + var i = 0; + for (final var set : Views.flatIterable(labels)) + assertEquals(expected[truth[i++]], set.iterator().next()); + } + + @Test + public void testDeterminism() { + final var rnd = new Random(3); + final var in = ArrayImgs.doubles(25, 25); + in.forEach(t -> t.set(rnd.nextDouble())); + final ImgLabeling a = ops.op("segment.kMeans").input(in, + 5, null, 42L).outType(LABELS).apply(); + final ImgLabeling b = ops.op("segment.kMeans").input(in, + 5, null, 42L).outType(LABELS).apply(); + final var ac = Views.flatIterable(a.getIndexImg()).cursor(); + final var bc = Views.flatIterable(b.getIndexImg()).cursor(); + final var used = new boolean[5]; + final var labels = Views.flatIterable(a).cursor(); + while (ac.hasNext()) { + assertEquals(ac.next().get(), bc.next().get()); + used[labels.next().iterator().next() - 1] = true; + } + for (final var u : used) + assertEquals(true, u); + + final ImgLabeling one = ops.op("segment.kMeans").input( + in, 1).outType(LABELS).apply(); + for (final var set : Views.flatIterable(one)) + assertEquals(1, set.iterator().next()); + } + + @Test + public void testInvalidArguments() { + final var in = ArrayImgs.doubles(3, 3); + assertThrows(IllegalArgumentException.class, () -> ops.op("segment.kMeans") + .input(in, 10).outType(LABELS).apply()); + assertThrows(IllegalArgumentException.class, () -> ops.op("segment.kMeans") + .input(in, 2, 2).outType(LABELS).apply()); + } + + /** + * With few iterations, the labels still correspond to the final centers: + * for intensity clustering, each label is an intensity interval, and the + * intervals are ordered by label. Before, a limit of 0 iterations threw + * ArrayIndexOutOfBoundsException, and when the limit was hit, the centers + * were moved after the last assignment and the labels were ordered by the + * moved centers. + */ + @Test + public void testIterationLimit() { + for (var seed = 0L; seed < 20; seed++) { + final var rnd = new Random(seed); + final var in = ArrayImgs.doubles(20, 20); + in.forEach(t -> t.set(rnd.nextDouble() * rnd.nextDouble())); + for (var maxIter = 0; maxIter <= 3; maxIter++) { + final ImgLabeling labels = ops.op("segment.kMeans") + .input(in, 4, null, seed, maxIter).outType(LABELS).apply(); + final var min = new double[4]; + final var max = new double[4]; + Arrays.fill(min, Double.POSITIVE_INFINITY); + Arrays.fill(max, Double.NEGATIVE_INFINITY); + final var ic = Views.flatIterable(in).cursor(); + for (final var set : Views.flatIterable(labels)) { + final var v = ic.next().get(); + assertEquals(1, set.size()); + final int l = set.iterator().next() - 1; + min[l] = Math.min(min[l], v); + max[l] = Math.max(max[l], v); + } + for (var l = 0; l + 1 < 4; l++) { + if (max[l] == Double.NEGATIVE_INFINITY) continue; + for (var m = l + 1; m < 4; m++) { + assertTrue(max[l] <= min[m], "seed " + seed + ", maxIterations " + + maxIter + ": label " + (l + 1) + " overlaps label " + (m + 1)); + } + } + } + } + final var in = ArrayImgs.doubles(3, 3); + assertThrows(IllegalArgumentException.class, () -> ops.op("segment.kMeans") + .input(in, 2, null, 0L, -1).outType(LABELS).apply()); + } + + /** The labeling keeps the min of a translated input. */ + @Test + public void testTranslatedInput() { + final var in = ArrayImgs.doubles(10, 8, 2); + final var c = in.localizingCursor(); + while (c.hasNext()) { + c.fwd(); + c.get().set(c.getIntPosition(0) < 5 ? c.getIntPosition(2) : 50); + } + final var translated = Views.translate(in, 4, -2, 3); + final ImgLabeling labels = ops.op("segment.kMeans") + .input(translated, 2, 2).outType(LABELS).apply(); + assertArrayEquals(new long[] { 4, -2 }, labels.minAsLongArray()); + assertArrayEquals(new long[] { 13, 5 }, labels.maxAsLongArray()); + final var lc = labels.localizingCursor(); + while (lc.hasNext()) { + final var set = lc.next(); + assertEquals(lc.getIntPosition(0) < 9 ? 1 : 2, set.iterator().next()); + } + } + + /** NaN pixels are left unlabeled and do not affect the clusters. */ + @Test + public void testNaN() { + final var in = ArrayImgs.doubles(10, 10); + final var c = in.localizingCursor(); + while (c.hasNext()) { + c.fwd(); + final var x = c.getIntPosition(0); + c.get().set(x == 3 ? Double.NaN : x < 5 ? 10 : 90); + } + final ImgLabeling labels = ops.op("segment.kMeans") + .input(in, 2).outType(LABELS).apply(); + final var lc = labels.localizingCursor(); + while (lc.hasNext()) { + final var set = lc.next(); + final var x = lc.getIntPosition(0); + if (x == 3) assertEquals(0, set.size()); + else assertEquals(x < 5 ? 1 : 2, set.iterator().next()); + } + in.forEach(t -> t.set(Double.NaN)); + assertThrows(IllegalArgumentException.class, () -> ops.op("segment.kMeans") + .input(in, 1).outType(LABELS).apply()); + } +} diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/segment/levelset/LevelSetTest.java b/scijava-ops-image/src/test/java/org/scijava/ops/image/segment/levelset/LevelSetTest.java new file mode 100644 index 000000000..2ae329a50 --- /dev/null +++ b/scijava-ops-image/src/test/java/org/scijava/ops/image/segment/levelset/LevelSetTest.java @@ -0,0 +1,243 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.segment.levelset; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Random; +import java.util.function.Predicate; + +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.img.Img; +import net.imglib2.img.array.ArrayImgs; +import net.imglib2.type.logic.BitType; +import net.imglib2.type.numeric.real.DoubleType; +import net.imglib2.type.numeric.real.FloatType; +import net.imglib2.view.Views; + +import org.junit.jupiter.api.Test; +import org.scijava.ops.image.AbstractOpTest; +import org.scijava.types.Nil; + +/** + * Tests {@code segment.levelSet} and {@code image.levelSetFunction}. + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +public class LevelSetTest extends AbstractOpTest { + + private static final Nil> MASK = + new Nil<>() + {}; + + private static final Nil> PHI = + new Nil<>() + {}; + + /** Binary mask of the pixels whose position satisfies the predicate. */ + private static Img mask(final Predicate inside, + final long... dims) + { + final var img = ArrayImgs.bits(dims); + final var c = img.localizingCursor(); + final var p = new long[dims.length]; + while (c.hasNext()) { + c.fwd(); + c.localize(p); + c.get().set(inside.test(p)); + } + return img; + } + + /** Image with value 1 on the mask, 0 elsewhere, plus Gaussian noise. */ + private static Img noisy(final Img truth, + final double noise, final long seed) + { + final var img = ArrayImgs.doubles(truth.dimensionsAsLongArray()); + final var random = new Random(seed); + final var tc = truth.cursor(); + for (final var t : img) + t.set((tc.next().get() ? 1 : 0) + noise * random.nextGaussian()); + return img; + } + + private static double dice(final RandomAccessibleInterval a, + final RandomAccessibleInterval b) + { + long both = 0, sum = 0; + final var bc = Views.flatIterable(b).cursor(); + for (final var x : Views.flatIterable(a)) { + final var y = bc.next().get(); + if (x.get() && y) both++; + if (x.get()) sum++; + if (y) sum++; + } + return 2.0 * both / sum; + } + + static String ascii(final RandomAccessibleInterval m) { + final var sb = new StringBuilder("\n"); + for (long y = 0; y < m.dimension(1); y++) { + for (long x = 0; x < m.dimension(0); x++) + sb.append(m.getAt(x, y).get() ? '#' : '.'); + sb.append('\n'); + } + return sb.toString(); + } + + private static double r2(final long[] p, final double... center) { + var s = 0.0; + for (var d = 0; d < p.length; d++) + s += (p[d] - center[d]) * (p[d] - center[d]); + return s; + } + + @Test + public void testChanVeseNoisyDisk() { + final var truth = mask(p -> r2(p, 32, 32) <= 15 * 15, 64, 64); + // Signal-to-noise ratio of 2: thresholding at 0.5 misclassifies 31 % of + // the pixels. + final var image = noisy(truth, 0.5, 1); + // A poor initialization: a small square straddling the disk boundary. + final var init = mask(p -> p[0] >= 40 && p[0] < 52 && p[1] >= 20 && + p[1] < 32, 64, 64); + final RandomAccessibleInterval result = ops.op( + "segment.levelSet").input(image, init).outType(MASK).apply(); + final var dice = dice(result, truth); + assertTrue(dice > 0.95, "Dice " + dice + ascii(result)); + } + + @Test + public void testChanVese3DBlob() { + final long[] dims = { 32, 30, 24 }; + final var truth = mask(p -> Math.pow((p[0] - 15) / 10.0, 2) + Math.pow( + (p[1] - 14) / 7.0, 2) + Math.pow((p[2] - 12) / 5.0, 2) <= 1, dims); + final var image = noisy(truth, 0.4, 2); + final var init = mask(p -> Math.abs(p[0] - 22) <= 2 && Math.abs(p[1] - + 14) <= 2 && Math.abs(p[2] - 12) <= 2, dims); + final RandomAccessibleInterval result = ops.op( + "segment.levelSet").input(image, init).outType(MASK).apply(); + final var dice = dice(result, truth); + assertTrue(dice > 0.9, "Dice " + dice); + } + + @Test + public void testGeodesicStepEdge() { + // A bright square [15, 44]^2 with mild noise. + final var truth = mask(p -> p[0] >= 15 && p[0] <= 44 && p[1] >= 15 && + p[1] <= 44, 60, 60); + final var image = noisy(truth, 0.05, 3); + // Note: The edge lies halfway between pixels 14 and 15 (and 44 and 45), + // and the contour settles within one pixel of it: the balloon force can + // push it slightly past the minimum of the edge stopping function. + + // Expansion from a small disk inside the square. + final var small = mask(p -> r2(p, 28, 31) <= 16, 60, 60); + final RandomAccessibleInterval grown = ops.op( + "segment.levelSet").input(image, small, "geodesic").outType(MASK) + .apply(); + final var dGrown = dice(grown, truth); + assertTrue(dGrown > 0.93, "Dice " + dGrown + ascii(grown)); + assertWithinOnePixel(grown); + + // Shrinking from a large rectangle outside the square. + final var large = mask(p -> p[0] >= 4 && p[0] <= 56 && p[1] >= 6 && + p[1] <= 54, 60, 60); + final RandomAccessibleInterval shrunk = ops.op( + "segment.levelSet").input(image, large, "geodesic", 1.0, -1.0).outType( + MASK).apply(); + final var dShrunk = dice(shrunk, truth); + assertTrue(dShrunk > 0.93, "Dice " + dShrunk + ascii(shrunk)); + assertWithinOnePixel(shrunk); + } + + /** + * Asserts that the boundary of a mask lies within one pixel of the edge of + * the square [15, 44]^2. + */ + private static void assertWithinOnePixel( + final RandomAccessibleInterval result) + { + final var dilated = mask(p -> p[0] >= 14 && p[0] <= 45 && p[1] >= 14 && + p[1] <= 45, 60, 60); + final var eroded = mask(p -> p[0] >= 16 && p[0] <= 43 && p[1] >= 16 && + p[1] <= 43, 60, 60); + final var rc = Views.flatIterable(result).cursor(); + final var ec = eroded.cursor(); + for (final var d : dilated) { + final var r = rc.next().get(); + final var e = ec.next().get(); + assertTrue(!r || d.get(), ascii(result)); + assertTrue(r || !e, ascii(result)); + } + } + + @Test + public void testLevelSetFunctionAndDeterminism() { + final var truth = mask(p -> r2(p, 16, 14) <= 64, 32, 30); + final var image = noisy(truth, 0.3, 4); + final var init = mask(p -> r2(p, 12, 12) <= 9, 32, 30); + final RandomAccessibleInterval phi = ops.op( + "image.levelSetFunction").input(image, init).outType(PHI).apply(); + final RandomAccessibleInterval again = ops.op( + "image.levelSetFunction").input(image, init).outType(PHI).apply(); + final RandomAccessibleInterval result = ops.op( + "segment.levelSet").input(image, init).outType(MASK).apply(); + final var ac = Views.flatIterable(again).cursor(); + final var rc = Views.flatIterable(result).cursor(); + for (final var t : Views.flatIterable(phi)) { + assertEquals(t.get(), ac.next().get()); + assertEquals(t.get() > 0, rc.next().get()); + } + // The level-set function is a signed distance: about 8 (the radius) at + // the center, up to the roughness of the noisy contour. + assertEquals(8, phi.getAt(16, 14).get(), 1.5); + + // An offset interval is respected. + final RandomAccessibleInterval shifted = ops.op( + "segment.levelSet").input(Views.translate(image, 5, -3), Views + .translate(init, 5, -3)).outType(MASK).apply(); + assertArrayEquals(new long[] { 5, -3 }, shifted.minAsLongArray()); + assertEquals(1.0, dice(Views.zeroMin(shifted), result)); + + // Without iterations, the initial mask is returned. + final RandomAccessibleInterval none = ops.op("segment.levelSet") + .input(image, init, null, null, null, null, null, 0).outType(MASK) + .apply(); + assertEquals(1.0, dice(none, init)); + + assertThrows(RuntimeException.class, () -> ops.op("segment.levelSet") + .input(image, init, "snake").outType(MASK).apply()); + } +} diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/segment/phantast/PhantastTest.java b/scijava-ops-image/src/test/java/org/scijava/ops/image/segment/phantast/PhantastTest.java new file mode 100644 index 000000000..ee190318e --- /dev/null +++ b/scijava-ops-image/src/test/java/org/scijava/ops/image/segment/phantast/PhantastTest.java @@ -0,0 +1,207 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.segment.phantast; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; + +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.img.Img; +import net.imglib2.img.array.ArrayImgs; +import net.imglib2.type.logic.BitType; +import net.imglib2.type.numeric.real.FloatType; +import net.imglib2.view.Views; + +import org.junit.jupiter.api.Test; +import org.scijava.ops.image.AbstractOpTest; +import org.scijava.types.Nil; + +/** + * Tests {@link DefaultPhantast}. + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +public class PhantastTest extends AbstractOpTest { + + static final int W = 64, H = 64; + + private static final Nil> MASK = + new Nil<>() + {}; + + /** + * Synthetic phase contrast image: textured cells (one of them tiny and + * without halo) surrounded by bright halos on a flat noisy background, with + * flat holes in the two large cells. + */ + public static Img synth() { + final var p = new float[W * H]; + var seed = 12345; + final int[][] cells = { { 20, 22, 13 }, { 46, 44, 11 }, { 52, 10, 3 } }; + for (var y = 0; y < H; y++) { + for (var x = 0; x < W; x++) { + seed = seed * 1103515245 + 12345; + final var noise = ((seed >>> 16) & 0x7fff) % 5 - 2; + double v = 100 + noise; + for (final var c : cells) { + final double dx = x - c[0], dy = y - c[1]; + final var r = Math.sqrt(dx * dx + dy * dy); + if (r <= c[2]) { + final var hole = c == cells[0] && dx * dx + dy * dy <= 9 || + c == cells[1] && dx * dx + dy * dy <= 25; + if (!hole) v = 100 + 25 * StrictMath.sin(x * 1.3) * StrictMath + .cos(y * 1.7) + 2 * noise; + } + else if (r <= c[2] + 3 && c != cells[2]) { + v += 60 * (c[2] + 3 - r) / 3.0; + } + } + p[x + W * y] = (float) v; + } + } + return ArrayImgs.floats(p, W, H); + } + + /** + * Compares the mask to the one of the original PHANTAST plugin. The + * expected masks were produced by running the steps of + * {@code PHANTAST_.run} of PHANTAST-FIJI (local contrast, threshold, + * {@code removeSmallObjectsAndFillHoles}, {@code haloCorrection}) on + * {@link #synth()} with sigma 1.2 and epsilon 0.03, with ImageJ black + * background off, after fixing the off-by-one errors in + * {@code haloCorrection} (see {@link DefaultPhantast}). In that + * configuration, the plugin fills holes smaller than 100 pixels and removes + * objects smaller than 25 pixels. + */ + @Test + public void testPhantastRegression() throws IOException { + final var noHalo = ops.op("segment.phantast").input(synth(), 1.2, 0.03, + false, 25L, 100L).outType(MASK).apply(); + assertMask("phantast-nohalo.txt", noHalo); + final var withHalo = ops.op("segment.phantast").input(synth(), 1.2, 0.03, + true, 25L, 100L).outType(MASK).apply(); + assertMask("phantast-halo.txt", withHalo); + } + + /** + * With the default sizes, the tiny cell is removed and the large hole is + * kept, and halo correction shrinks the cells. + */ + @Test + public void testPhantastDefaults() { + final var defaults = ops.op("segment.phantast").input(synth(), 1.2, 0.03) + .outType(MASK).apply(); + final var noHalo = ops.op("segment.phantast").input(synth(), 1.2, 0.03, + false).outType(MASK).apply(); + final var ra = noHalo.randomAccess(); + // The tiny cell is removed. + assertTrue(!ra.setPositionAndGet(52, 10).get()); + // The small hole is filled, but the large one is kept. + assertTrue(ra.setPositionAndGet(20, 22).get()); + assertTrue(!ra.setPositionAndGet(46, 44).get()); + // The halo around the first cell is segmented without halo correction. + assertTrue(ra.setPositionAndGet(20, 22 + 15).get()); + + final var rd = defaults.randomAccess(); + assertTrue(!rd.setPositionAndGet(20, 22 + 15).get()); + assertTrue(rd.setPositionAndGet(20, 22 + 8).get()); + assertTrue(count(defaults) < count(noHalo)); + // Halo correction only removes pixels. + final var cd = Views.flatIterable(defaults).cursor(); + final var cn = Views.flatIterable(noHalo).cursor(); + while (cd.hasNext()) { + final var d = cd.next().get(); + final var n = cn.next().get(); + assertTrue(!d || n); + } + } + + /** Without halo correction, n-D images are supported. */ + @Test + public void testPhantast3D() { + final var flat = ArrayImgs.floats(10, 10, 3); + for (final var t : flat) + t.set(5); + final var mask = ops.op("segment.phantast").input(flat, 1.2, 0.03) + .outType(MASK).apply(); + assertEquals(3, mask.numDimensions()); + assertEquals(0, count(mask)); + assertThrows(IllegalArgumentException.class, () -> ops.op( + "segment.phantast").input(flat, 1.2, 0.03, true).outType(MASK).apply()); + } + + private static long count(final RandomAccessibleInterval mask) { + long n = 0; + for (final var b : Views.iterable(mask)) + if (b.get()) n++; + return n; + } + + private static void assertMask(final String resource, + final RandomAccessibleInterval mask) throws IOException + { + final List expected = new ArrayList<>(); + try (var in = new BufferedReader(new InputStreamReader(PhantastTest.class + .getResourceAsStream(resource), StandardCharsets.UTF_8))) + { + String line; + while ((line = in.readLine()) != null) + expected.add(line); + } + final List actual = new ArrayList<>(); + final var ra = mask.randomAccess(); + for (var y = 0; y < H; y++) { + final var sb = new StringBuilder(); + for (var x = 0; x < W; x++) { + sb.append(ra.setPositionAndGet(x, y).get() ? '#' : '.'); + } + actual.add(sb.toString()); + } + assertEquals(String.join("\n", expected), String.join("\n", actual)); + } + + /** NaN would spread through the local contrast filter, so it is rejected. */ + @Test + public void testNaN() { + final var img = synth(); + img.firstElement().set(Float.NaN); + assertThrows(IllegalArgumentException.class, () -> ops.op( + "segment.phantast").input(img, 1.2, 0.03).outType(MASK).apply()); + } +} diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/segment/regionGrowing/SeededRegionGrowingTest.java b/scijava-ops-image/src/test/java/org/scijava/ops/image/segment/regionGrowing/SeededRegionGrowingTest.java new file mode 100644 index 000000000..7312ff349 --- /dev/null +++ b/scijava-ops-image/src/test/java/org/scijava/ops/image/segment/regionGrowing/SeededRegionGrowingTest.java @@ -0,0 +1,184 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.segment.regionGrowing; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Random; + +import net.imglib2.algorithm.neighborhood.DiamondShape; +import net.imglib2.algorithm.neighborhood.RectangleShape; +import net.imglib2.algorithm.neighborhood.Shape; +import net.imglib2.img.array.ArrayImgs; +import net.imglib2.roi.labeling.ImgLabeling; +import net.imglib2.type.numeric.integer.IntType; +import net.imglib2.view.Views; + +import org.junit.jupiter.api.Test; +import org.scijava.ops.image.AbstractOpTest; +import org.scijava.types.Nil; + +/** + * Tests {@link SeededRegionGrowing}. + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +public class SeededRegionGrowingTest extends AbstractOpTest { + + private static final Nil> LABELS = + new Nil<>() + {}; + + private static final Nil> STRING_LABELS = + new Nil<>() + {}; + + /** Two noisy regions separated by a step are recovered from two seeds. */ + @Test + public void testNoisyStep() { + final var rnd = new Random(1); + final long w = 30, h = 20; + final var in = ArrayImgs.doubles(w, h); + final var c = in.localizingCursor(); + while (c.hasNext()) { + c.fwd(); + c.get().set((c.getLongPosition(0) < 12 ? 50 : 150) + 10 * rnd + .nextGaussian()); + } + for (final var se : new Shape[] { new DiamondShape(1), + new RectangleShape(1, false) }) + { + final var seeds = new ImgLabeling(ArrayImgs.ints(w, h)); + final var ra = seeds.randomAccess(); + ra.setPositionAndGet(2, 10).add("dark"); + ra.setPositionAndGet(25, 3).add("bright"); + final ImgLabeling out = ops.op( + "segment.seededRegionGrowing").input(in, seeds, se) + .outType(STRING_LABELS).apply(); + final var oc = Views.flatIterable(out).localizingCursor(); + while (oc.hasNext()) { + final var set = oc.next(); + assertEquals(1, set.size()); + assertEquals(oc.getLongPosition(0) < 12 ? "dark" : "bright", set + .iterator().next(), se.toString()); + } + } + } + + /** + * In 3D, a bright sphere seeded at its center and a background seed give + * the sphere; the seeds keep their labels. + */ + @Test + public void testSphere3D() { + final var in = ArrayImgs.unsignedBytes(15, 15, 15); + final var c = in.localizingCursor(); + var inside = 0; + while (c.hasNext()) { + c.fwd(); + var r2 = 0.0; + for (var d = 0; d < 3; d++) + r2 += Math.pow(c.getDoublePosition(d) - 7, 2); + if (r2 <= 16) { + c.get().set(200); + inside++; + } + else c.get().set(30); + } + final var seeds = new ImgLabeling(ArrayImgs.ints(15, 15, + 15)); + final var ra = seeds.randomAccess(); + ra.setPositionAndGet(7, 7, 7).add(1); + ra.setPositionAndGet(0, 0, 0).add(2); + ra.setPositionAndGet(14, 0, 14).add(2); + final ImgLabeling out = ops.op( + "segment.seededRegionGrowing").input(in, seeds) + .outType(LABELS).apply(); + var count = 0; + final var oc = Views.flatIterable(out).cursor(); + final var ic = Views.flatIterable(in).cursor(); + while (oc.hasNext()) { + final var label = oc.next().iterator().next(); + final var v = ic.next().get(); + assertEquals(v == 200 ? 1 : 2, label); + if (label == 1) count++; + } + assertEquals(inside, count); + } + + @Test + public void testRegionMeansGuideGrowth() { + // A step between two seeds: the boundary falls at the step. + final var in = ArrayImgs.doubles(21, 1); + final var c = in.localizingCursor(); + while (c.hasNext()) { + c.fwd(); + c.get().set(c.getDoublePosition(0) < 7 ? 0 : 100); + } + final var seeds = new ImgLabeling(ArrayImgs.ints(21, 1)); + final var ra = seeds.randomAccess(); + ra.setPositionAndGet(0, 0).add(1); + ra.setPositionAndGet(20, 0).add(2); + final ImgLabeling out = ops.op( + "segment.seededRegionGrowing").input(in, seeds) + .outType(LABELS).apply(); + final var oc = Views.flatIterable(out).localizingCursor(); + while (oc.hasNext()) { + final var set = oc.next(); + assertTrue(!set.isEmpty()); + assertEquals(oc.getLongPosition(0) < 7 ? 1 : 2, set.iterator().next()); + } + } + + /** + * Images containing NaN (which has no order) and seeds with several labels + * at one pixel (whose "first" label is not well-defined) are rejected. + */ + @Test + public void testInvalidInputs() { + final var in = ArrayImgs.doubles(5, 1); + final var seeds = new ImgLabeling(ArrayImgs.ints(5, 1)); + final var ra = seeds.randomAccess(); + ra.setPositionAndGet(0, 0).add(1); + ra.setPositionAndGet(4, 0).add(2); + in.firstElement().set(Double.NaN); + assertThrows(IllegalArgumentException.class, () -> ops.op( + "segment.seededRegionGrowing").input(in, seeds).outType(LABELS) + .apply()); + in.firstElement().set(0); + ra.setPositionAndGet(4, 0).add(3); + assertThrows(IllegalArgumentException.class, () -> ops.op( + "segment.seededRegionGrowing").input(in, seeds).outType(LABELS) + .apply()); + } +} diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/segment/siox/SioxTest.java b/scijava-ops-image/src/test/java/org/scijava/ops/image/segment/siox/SioxTest.java new file mode 100644 index 000000000..73988f916 --- /dev/null +++ b/scijava-ops-image/src/test/java/org/scijava/ops/image/segment/siox/SioxTest.java @@ -0,0 +1,414 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.segment.siox; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.img.Img; +import net.imglib2.img.array.ArrayImgs; +import net.imglib2.type.logic.BitType; +import net.imglib2.type.numeric.ARGBType; +import net.imglib2.type.numeric.integer.UnsignedByteType; +import net.imglib2.type.numeric.real.FloatType; +import net.imglib2.view.Views; + +import org.junit.jupiter.api.Test; +import org.scijava.ops.image.AbstractOpTest; + +/** + * Tests {@code segment.siox}. + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +public class SioxTest extends AbstractOpTest { + + private static final int W = 32, H = 24; + + /** + * Expected masks, produced by {@code org.siox.SioxSegmentator.segmentate} + * of the Fiji Siox_Segmentation project (version 1.0.5) on the image of + * {@link #image()} and the trimap of {@link #trimap(float)}, with + * smoothness 6 and size factor 0. + */ + private static final String[] EXPECTED_6_0 = { // + "................................", // + "................................", // + "................................", // + "................................", // + "................................", // + "................................", // + "................................", // + ".......#######..................", // + "......#########.................", // + ".....###########................", // + ".....###########................", // + ".....###########................", // + ".....###########................", // + ".....############...............", // + ".....############...............", // + ".....############...............", // + "......#########.................", // + ".......#######..................", // + "................................", // + "................................", // + "................................", // + "................................", // + "................................", // + "................................", // + }; + + /** As {@link #EXPECTED_6_0}, but with smoothness 0 and size factor 4. */ + private static final String[] EXPECTED_0_4 = { // + "................................", // + "................................", // + "................................", // + "................................", // + "................................", // + "................................", // + "................................", // + "................................", // + ".......#######..................", // + "......#########.......#####.....", // + ".....###########.....#######....", // + ".....###########.....#######....", // + ".....###########.....#######....", // + ".....###########.....#######....", // + ".....###########.....#######....", // + "......#########.......#####.....", // + ".......#######..................", // + "................................", // + "................................", // + "................................", // + "................................", // + "................................", // + "................................", // + "................................", // + }; + + /** + * As {@link #EXPECTED_6_0}, but with smoothness 2, size factor 4, and a + * known foreground confidence of 0.9 rather than 1. + */ + private static final String[] EXPECTED_2_4_UNCERTAIN = { // + "................................", // + "................................", // + "................................", // + "................................", // + "................................", // + "................................", // + "................................", // + "................................", // + ".......#######..................", // + "......#########.................", // + ".....###########................", // + ".....###########................", // + ".....###########................", // + ".....###########................", // + ".....###########................", // + "......#########.................", // + ".......#######..................", // + "................................", // + "................................", // + "................................", // + "................................", // + "................................", // + "................................", // + "................................", // + }; + + @Test + public void testRegressionDefaults() { + final var mask = siox(image(), trimap(1f)); + assertArrayEquals(EXPECTED_6_0, render(mask)); + } + + @Test + public void testRegressionExplicit() { + assertArrayEquals(EXPECTED_6_0, render(siox(image(), trimap(1f), 6, 0.0))); + // Note: With smoothness 6, the original also drops the smaller object. + assertArrayEquals(EXPECTED_6_0, render(siox(image(), trimap(1f), 6, 4.0))); + assertArrayEquals(EXPECTED_0_4, render(siox(image(), trimap(1f), 0, 4.0))); + assertArrayEquals(EXPECTED_2_4_UNCERTAIN, render(siox(image(), trimap( + 0.9f), 2, 4.0))); + } + + @Test + public void testUncertainForegroundWithoutMultipart() { + // Without samples of confidence 1, size factor 0 keeps no component. + final var mask = siox(image(), trimap(0.9f), 6, 0.0); + for (final BitType b : Views.iterable(mask)) + assertTrue(!b.get()); + } + + @Test + public void testMasks() { + final var tri = trimap(1f); + final Img fg = ArrayImgs.bits(W, H); + final Img bg = ArrayImgs.bits(W, H); + final var t = tri.cursor(); + final var f = fg.cursor(); + final var b = bg.cursor(); + while (t.hasNext()) { + final float v = t.next().get(); + f.next().set(v == 1f); + b.next().set(v == 0f); + } + @SuppressWarnings("unchecked") + final var mask = (RandomAccessibleInterval) ops.op("segment.siox") + .input(image(), fg, bg).apply(); + assertArrayEquals(EXPECTED_6_0, render(mask)); + @SuppressWarnings("unchecked") + final var mask2 = (RandomAccessibleInterval) ops.op( + "segment.siox").input(image(), fg, bg, 0, 4.0).apply(); + assertArrayEquals(EXPECTED_0_4, render(mask2)); + } + + @Test + public void testChannels() { + final var argb = image(); + final Img u8 = ArrayImgs.unsignedBytes(W, H, 3); + final Img f32 = ArrayImgs.floats(W, H, 3); + final var ra = argb.randomAccess(); + final var ru = u8.randomAccess(); + final var rf = f32.randomAccess(); + for (int y = 0; y < H; y++) { + for (int x = 0; x < W; x++) { + final int v = ra.setPositionAndGet(x, y).get(); + final int[] rgb = { ARGBType.red(v), ARGBType.green(v), ARGBType.blue( + v) }; + for (int c = 0; c < 3; c++) { + ru.setPositionAndGet(x, y, c).set(rgb[c]); + rf.setPositionAndGet(x, y, c).set(rgb[c] / 255f); + } + } + } + assertArrayEquals(EXPECTED_0_4, render(siox(u8, trimap(1f), 0, 4.0))); + assertArrayEquals(EXPECTED_0_4, render(siox(f32, trimap(1f), 0, 4.0))); + assertArrayEquals(EXPECTED_6_0, render(siox(u8, trimap(1f)))); + + final Img fg = ArrayImgs.bits(W, H); + final Img bg = ArrayImgs.bits(W, H); + final var t = trimap(1f).cursor(); + final var f = fg.cursor(); + final var b = bg.cursor(); + while (t.hasNext()) { + final float v = t.next().get(); + f.next().set(v == 1f); + b.next().set(v == 0f); + } + @SuppressWarnings("unchecked") + final var mask = (RandomAccessibleInterval) ops.op("segment.siox") + .input(u8, fg, bg).apply(); + assertArrayEquals(EXPECTED_6_0, render(mask)); + } + + /** A noisy two-color sphere in 3D is extracted almost exactly. */ + @Test + public void testSphere3D() { + final int n = 16; + final Img img = ArrayImgs.argbs(n, n, n); + final Img tri = ArrayImgs.floats(n, n, n); + final boolean[] truth = new boolean[n * n * n]; + final var c = img.localizingCursor(); + final var t = tri.cursor(); + int i = 0; + while (c.hasNext()) { + c.fwd(); + t.fwd(); + final long x = c.getLongPosition(0) - 8, y = c.getLongPosition(1) - 8, + z = c.getLongPosition(2) - 8; + final long r2 = x * x + y * y + z * z; + final boolean inside = r2 <= 25; + truth[i++] = inside; + final int noise = (int) (next() % 21) - 10; + c.get().set(inside ? ARGBType.rgba(200 + noise, 60 - noise, 60, 255) + : ARGBType.rgba(40, 120 + noise, 200 - noise, 255)); + t.get().set(r2 <= 4 ? 1f : r2 >= 64 ? 0f : 0.5f); + } + final var mask = siox(img, tri, 0, 0.0); + int missed = 0, extra = 0, inside = 0; + i = 0; + for (final BitType b : Views.flatIterable(mask)) { + if (truth[i]) inside++; + if (truth[i] && !b.get()) missed++; + if (!truth[i] && b.get()) extra++; + i++; + } + // Note: Smoothing and erosion round off the surface, while the final + // dilation grows the object by about one sample. + assertTrue(missed < 0.1 * inside, "missed samples: " + missed); + assertTrue(extra < 0.5 * inside, "extra samples: " + extra); + } + + @Test + public void testInvalidInputs() { + final Img allFg = ArrayImgs.floats(W, H); + for (final FloatType v : allFg) + v.set(1f); + assertThrows(RuntimeException.class, () -> siox(image(), allFg)); + + final Img fg = ArrayImgs.bits(W, H); + final Img bg = ArrayImgs.bits(W, H); + fg.firstElement().set(true); + bg.firstElement().set(true); + assertThrows(RuntimeException.class, () -> ops.op("segment.siox").input( + image(), fg, bg).apply()); + + assertThrows(RuntimeException.class, () -> siox(image(), ArrayImgs.floats( + W, H + 1))); + } + + @Test + public void testRgbToLab() { + // Reference white and black of sRGB. + final float[] white = Siox.rgbToLab(1, 1, 1); + assertEquals(100, white[0], 1e-2); + // Note: The rounded matrix coefficients leave a small chroma offset. + assertEquals(0, white[1], 0.02); + assertEquals(0, white[2], 0.02); + assertArrayEquals(new float[] { 0, 0, 0 }, Siox.rgbToLab(0, 0, 0), 1e-4f); + // Pure sRGB red: L=53.24, a=80.09, b=67.20. + final float[] red = Siox.rgbToLab(1, 0, 0); + assertEquals(53.24, red[0], 0.05); + assertEquals(80.09, red[1], 0.1); + assertEquals(67.20, red[2], 0.1); + } + + // -- Helper methods -- + + private int seed = 12345; + + private int next() { + seed = seed * 1103515245 + 12345; + return (seed >>> 16) & 0x7fff; + } + + private static int clamp(final int v) { + return Math.max(0, Math.min(255, v)); + } + + /** + * Creates a noisy image of an orange ellipse and a smaller orange disk on + * a blue background. Must match the harness that produced the expected + * masks. + */ + private Img image() { + seed = 12345; + final Img img = ArrayImgs.argbs(W, H); + final var ra = img.randomAccess(); + for (int y = 0; y < H; y++) { + for (int x = 0; x < W; x++) { + final int nr = next() % 21 - 10, ng = next() % 21 - 10, nb = next() % + 21 - 10; + final boolean fg = (x - 10) * (x - 10) / 36.0 + (y - 12) * (y - 12) / + 25.0 <= 1 || (x - 24) * (x - 24) + (y - 12) * (y - 12) <= 16; + final int r, g, b; + if (fg) { + r = 220 + nr; + g = 120 + ng; + b = 40 + nb; + } + else { + r = 70 + nr; + g = 90 + ng; + b = 150 + nb; + } + ra.setPositionAndGet(x, y).set(0xff000000 | clamp(r) << 16 | clamp( + g) << 8 | clamp(b)); + } + } + return img; + } + + /** + * Creates a trimap with a two-sample background border and a known + * foreground rectangle inside the ellipse. + */ + private static Img trimap(final float fgValue) { + final Img tri = ArrayImgs.floats(W, H); + final var ra = tri.randomAccess(); + for (int y = 0; y < H; y++) { + for (int x = 0; x < W; x++) { + float c = 0.5f; + if (x < 2 || y < 2 || x >= W - 2 || y >= H - 2) c = 0f; + else if (x >= 8 && x <= 12 && y >= 10 && y <= 14) c = fgValue; + ra.setPositionAndGet(x, y).set(c); + } + } + return tri; + } + + @SuppressWarnings("unchecked") + private static RandomAccessibleInterval siox(final Object image, + final Object trimap) + { + return (RandomAccessibleInterval) ops.op("segment.siox").input( + image, trimap).apply(); + } + + @SuppressWarnings("unchecked") + private static RandomAccessibleInterval siox(final Object image, + final Object trimap, final Integer smoothness, final Double sizeFactor) + { + return (RandomAccessibleInterval) ops.op("segment.siox").input( + image, trimap, smoothness, sizeFactor).apply(); + } + + private static String[] render(final RandomAccessibleInterval m) { + assertEquals(2, m.numDimensions()); + final String[] rows = new String[(int) m.dimension(1)]; + final var ra = m.randomAccess(); + for (int y = 0; y < rows.length; y++) { + final StringBuilder sb = new StringBuilder(); + for (int x = 0; x < m.dimension(0); x++) { + sb.append(ra.setPositionAndGet(x, y).get() ? '#' : '.'); + } + rows[y] = sb.toString(); + } + return rows; + } + + /** Images and trimaps containing NaN are rejected. */ + @Test + public void testNaN() { + final Img f32 = ArrayImgs.floats(W, H, 3); + f32.forEach(t -> t.set(0.5f)); + f32.firstElement().set(Float.NaN); + assertThrows(IllegalArgumentException.class, () -> siox(f32, trimap(1f))); + final var tri = trimap(1f); + tri.firstElement().set(Float.NaN); + assertThrows(IllegalArgumentException.class, () -> siox(image(), tri)); + } +} diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/segment/srm/StatisticalRegionMergingTest.java b/scijava-ops-image/src/test/java/org/scijava/ops/image/segment/srm/StatisticalRegionMergingTest.java new file mode 100644 index 000000000..999b41288 --- /dev/null +++ b/scijava-ops-image/src/test/java/org/scijava/ops/image/segment/srm/StatisticalRegionMergingTest.java @@ -0,0 +1,321 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.segment.srm; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Arrays; + +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.img.Img; +import net.imglib2.img.array.ArrayImgs; +import net.imglib2.roi.labeling.ImgLabeling; +import net.imglib2.type.numeric.RealType; +import net.imglib2.type.numeric.integer.IntType; +import net.imglib2.type.numeric.integer.UnsignedByteType; +import net.imglib2.type.numeric.real.FloatType; +import net.imglib2.view.Views; + +import org.junit.jupiter.api.Test; +import org.scijava.ops.image.AbstractOpTest; +import org.scijava.types.Nil; + +/** + * Tests {@link DefaultStatisticalRegionMerging} and + * {@link StatisticalRegionMergingMeans}. + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +public class StatisticalRegionMergingTest extends AbstractOpTest { + + private static final String OP = "segment.statisticalRegionMerging"; + + /** + * Regression test against the original Fiji {@code SRM_} plugin + * (Statistical_Region_Merging, git HEAD as of 2026-10). The expected values + * were produced by a throwaway harness that compiled {@code SRM_.java} + * against ImageJ 1.54p, built an 8-bit {@code ImagePlus} from + * {@link #synth}, and called {@code srm2D(ip, false)} (for d = 1) or + * {@code srm3D(false)}, then {@code srm2D(ip, true)} / {@code srm3D(true)}. + * {@code labelHash} is {@link Arrays#hashCode(int[])} of the plugin's region + * index image in raster order; {@code meanSum} is the sum of its + * "showAverages" image. The region partition and numbering must match + * exactly. + */ + @Test + public void testRegressionAgainstOriginal() { + for (final var row : REGRESSION) { + assertRegression((int) row[0], (int) row[1], (int) row[2], (int) row[3], + row[4], (int) row[5], (int) row[6], row[7]); + } + } + + /** + * Columns: synth mode, width, height, depth, Q, region count, label hash, + * sum of region means. + */ + private static final double[][] REGRESSION = { // + { 0, 24, 20, 1, 5, 17, 1452090626, 55285.000314712524 }, // + { 0, 24, 20, 1, 25, 17, 1452090626, 55285.000314712524 }, // + { 0, 24, 20, 1, 100, 18, 931531555, 55285.00026893616 }, // + { 0, 12, 10, 6, 25, 9, 544277378, 95061.99896621704 }, // + { 0, 12, 10, 6, 100, 10, 1736166980, 95061.99871826172 }, // + { 1, 24, 20, 1, 5, 6, 101628027, 59074.99913024902 }, // + { 1, 24, 20, 1, 25, 9, 1450233234, 59075.00372314453 }, // + { 1, 24, 20, 1, 100, 53, 1256104942, 59074.999629974365 }, // + { 1, 24, 20, 1, 400, 150, -965902072, 59075.00006484985 }, // + { 1, 12, 10, 6, 25, 11, 1790855819, 86966.9997253418 }, // + { 1, 12, 10, 6, 400, 124, -9543278, 86967.00000572205 }, // + }; + + private void assertRegression(final int mode, final int w, final int h, + final int d, final double q, final int expectedRegions, + final int expectedLabelHash, final double expectedMeanSum) + { + final var img = synth(mode, w, h, d); + final var labeling = labeling(img, q, null); + final var labels = labels(labeling); + assertEquals(expectedRegions, Arrays.stream(labels).max().getAsInt() + 1); + assertEquals(expectedLabelHash, Arrays.hashCode(labels)); + + final var means = ArrayImgs.floats(img.dimensionsAsLongArray()); + ops.op(OP).input(img, q, null).output(means).compute(); + var sum = 0.0; + for (final var v : means) + sum += v.get(); + assertEquals(expectedMeanSum, sum, 1e-2); + assertMeansMatchRegions(img, labels, means); + } + + /** Default parameters are Q = 25 and g = 256. */ + @Test + public void testDefaults() { + final var img = synth(1, 24, 20, 1); + final ImgLabeling labeling = ops.op(OP).input(img) + .outType(new Nil>() + {}).apply(); + assertArrayEquals(labels(labeling(img, 25.0, 256)), labels(labeling)); + assertEquals(1450233234, Arrays.hashCode(labels(labeling))); + + final var means = ArrayImgs.doubles(24, 20); + ops.op(OP).input(img).output(means).compute(); + final var expected = ArrayImgs.doubles(24, 20); + ops.op(OP).input(img, 25.0, 256).output(expected).compute(); + assertArrayEquals(expected.update(null).getCurrentStorageArray(), means + .update(null).getCurrentStorageArray()); + } + + /** A constant image is a single region whose mean is the constant. */ + @Test + public void testConstantImage() { + final var img = ArrayImgs.doubles(7, 5, 3); + for (final var t : img) + t.set(-4.5); + assertArrayEquals(new int[7 * 5 * 3], labels(labeling(img, null, null))); + final var means = ArrayImgs.doubles(7, 5, 3); + ops.op(OP).input(img, null, null).output(means).compute(); + for (final var t : means) + assertEquals(-4.5, t.get(), 0); + } + + /** Two flat halves separated by a strong step remain two regions, in 4D. */ + @Test + public void testStep4D() { + final var img = ArrayImgs.unsignedBytes(4, 3, 5, 6); + final var c = img.localizingCursor(); + while (c.hasNext()) { + c.fwd(); + c.get().set(c.getIntPosition(3) < 2 ? 10 : 200); + } + final var labels = labels(labeling(img, null, null)); + final var c2 = img.localizingCursor(); + for (final var label : labels) { + c2.fwd(); + assertEquals(c2.getIntPosition(3) < 2 ? 0 : 1, label); + } + final var means = ArrayImgs.floats(4, 3, 5, 6); + ops.op(OP).input(img, null, null).output(means).compute(); + final var m = means.cursor(); + for (final var t : img) + assertEquals(t.get(), m.next().get(), 1e-4); + } + + /** + * The segmentation is invariant under positive affine intensity + * transformations, since intensities are rescaled from their value range; + * the region means transform accordingly. + */ + @Test + public void testAffineIntensityInvariance() { + final var img = synth(1, 12, 10, 6); + final var floats = ArrayImgs.floats(12, 10, 6); + final var f = floats.cursor(); + for (final var t : img) + f.next().set(t.get() * 0.25f - 3); + assertArrayEquals(labels(labeling(img, 100.0, null)), labels(labeling( + floats, 100.0, null))); + + final var means = ArrayImgs.doubles(12, 10, 6); + ops.op(OP).input(img, 100.0, null).output(means).compute(); + final var floatMeans = ArrayImgs.doubles(12, 10, 6); + ops.op(OP).input(floats, 100.0, null).output(floatMeans).compute(); + final var fm = floatMeans.cursor(); + for (final var t : means) + assertEquals(t.get() * 0.25 - 3, fm.next().get(), 1e-9); + } + + /** Larger Q never yields fewer regions on these inputs. */ + @Test + public void testQMonotonicity() { + final var img = synth(1, 24, 20, 1); + var previous = 0; + for (final var q : new double[] { 1, 5, 25, 100, 400, 1600 }) { + final var regions = Arrays.stream(labels(labeling(img, q, null))).max() + .getAsInt() + 1; + assertTrue(regions >= previous, "Q = " + q); + previous = regions; + } + } + + /** Inputs with a nonzero origin are processed in flat iteration order. */ + @Test + public void testTranslatedInput() { + final var img = synth(0, 24, 20, 1); + final var translated = Views.translate(img, 5, -7); + assertArrayEquals(labels(labeling(img, null, null)), labels(labeling( + translated, null, null))); + } + + @Test + public void testInvalidParameters() { + final var img = synth(0, 4, 4, 1); + assertThrows(IllegalArgumentException.class, () -> labeling(img, 0.0, + null)); + assertThrows(IllegalArgumentException.class, () -> labeling(img, null, + 1)); + } + + // -- Helper methods -- + + private > ImgLabeling labeling( + final RandomAccessibleInterval img, final Double q, final Integer g) + { + return ops.op(OP).input(img, q, g).outType( + new Nil>() + {}).apply(); + } + + /** + * Extracts the single label of each sample, in flat iteration order, minus + * one: the op labels regions from 1, while the region index image of the + * original plugin (from which the regression values come) counts from 0. + */ + private static int[] labels(final ImgLabeling labeling) { + final var labels = new int[(int) Views.iterable(labeling).size()]; + var i = 0; + for (final var set : Views.flatIterable(labeling)) { + assertEquals(1, set.size()); + final int label = set.iterator().next(); + assertTrue(label >= 1, "label " + label); + labels[i++] = label - 1; + } + return labels; + } + + /** Checks that each output value is the input mean over its region. */ + private static void assertMeansMatchRegions(final Img img, + final int[] labels, final Img means) + { + final var regionCount = Arrays.stream(labels).max().getAsInt() + 1; + final var sums = new double[regionCount]; + final var counts = new int[regionCount]; + var i = 0; + for (final var t : Views.flatIterable(img)) { + sums[labels[i]] += t.get(); + counts[labels[i]]++; + i++; + } + i = 0; + for (final var t : Views.flatIterable(means)) { + final var label = labels[i++]; + assertEquals(sums[label] / counts[label], t.get(), 1e-3); + } + } + + /** + * Generates the synthetic 8-bit test image also used by the regression + * harness: mode 0 is a piecewise constant pattern with mild noise; mode 1 is + * a smooth sinusoidal pattern with stronger noise. The first and last + * samples are set to 0 and 255, so that the value range is the full 8-bit + * range. + */ + private static Img synth(final int mode, final int w, + final int h, final int d) + { + final var p = new byte[w * h * d]; + var seed = 42; + for (var z = 0; z < d; z++) + for (var y = 0; y < h; y++) + for (var x = 0; x < w; x++) { + seed = seed * 1103515245 + 12345; + final int noise; + final int base; + if (mode == 0) { + noise = ((seed >>> 16) % 31) - 15; + base = ((x / 6 + y / 5 + z / 3) % 3) * 90 + 30; + } + else { + noise = ((seed >>> 16) % 51) - 25; + base = (int) (127 + 100 * StrictMath.sin(x / 3.0) * StrictMath + .cos((y + z) / 4.0)); + } + p[x + w * (y + h * z)] = (byte) Math.max(0, Math.min(255, base + + noise)); + } + p[0] = 0; + p[p.length - 1] = (byte) 255; + return d == 1 ? ArrayImgs.unsignedBytes(p, w, h) : ArrayImgs + .unsignedBytes(p, w, h, d); + } + + /** NaN has no order, so images containing NaN are rejected. */ + @Test + public void testNaN() { + final var img = ArrayImgs.floats(8, 8); + img.firstElement().set(Float.NaN); + assertThrows(IllegalArgumentException.class, () -> ops.op(OP).input(img) + .outType(new Nil>() + {}).apply()); + } +} diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/segment/watershed/CompactWatershedTest.java b/scijava-ops-image/src/test/java/org/scijava/ops/image/segment/watershed/CompactWatershedTest.java new file mode 100644 index 000000000..0bd80d656 --- /dev/null +++ b/scijava-ops-image/src/test/java/org/scijava/ops/image/segment/watershed/CompactWatershedTest.java @@ -0,0 +1,250 @@ +/* #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.segment.watershed; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.scijava.ops.image.segment.watershed.ExtendedMinimaWatershedTest.assertBoundaryDifferences; +import static org.scijava.ops.image.segment.watershed.ExtendedMinimaWatershedTest.assertFewDifferences; +import static org.scijava.ops.image.segment.watershed.ExtendedMinimaWatershedTest.labels; +import static org.scijava.ops.image.segment.watershed.ExtendedMinimaWatershedTest.toArray; + +import java.util.Set; + +import net.imglib2.algorithm.neighborhood.RectangleShape; +import net.imglib2.img.Img; +import net.imglib2.img.array.ArrayImgs; +import net.imglib2.roi.labeling.ImgLabeling; +import net.imglib2.type.numeric.integer.IntType; +import net.imglib2.type.numeric.real.DoubleType; + +import org.junit.jupiter.api.Test; +import org.scijava.ops.image.AbstractOpTest; +import org.scijava.types.Nil; +import org.scijava.util.MersenneTwisterFast; + +/** + * Tests {@link CompactWatershed}. + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +public class CompactWatershedTest extends AbstractOpTest { + + private static final Nil> LABELING = + new Nil<>() + {}; + + private static final int W = 30; + private static final int H = 20; + private static final int[][] SEEDS = { { 4, 4 }, { 22, 6 }, { 12, 15 } }; + + private static ImgLabeling seeds(final long w, + final long h, final int[][] points, final int... labels) + { + final var seeds = new ImgLabeling(ArrayImgs.ints(w, h)); + final var ra = seeds.randomAccess(); + for (var k = 0; k < points.length; k++) { + ra.setPositionAndGet(points[k]).add(labels[k]); + } + return seeds; + } + + private static Img random() { + final var img = ArrayImgs.doubles(W, H); + final var random = new MersenneTwisterFast(0x1234); + for (final var t : img) + t.set(random.nextDouble()); + return img; + } + + /** NaN has no order, so reliefs containing NaN are rejected. */ + @Test + public void testNaN() { + final var img = random(); + img.firstElement().set(Double.NaN); + assertThrows(IllegalArgumentException.class, () -> ops.op( + "segment.compactWatershed").input(img, seeds(W, H, SEEDS, 1, 2, 3), 0.1) + .outType(LABELING).apply()); + } + + @Test + public void testLabelsArePreserved() { + final var out = ops.op("segment.compactWatershed").input(random(), seeds(W, + H, SEEDS, 7, 42, 3), 0.1).outType(LABELING).apply(); + assertEquals(Set.of(7, 42, 3), labels(out)); + final var ra = out.randomAccess(); + assertEquals(Set.of(42), ra.setPositionAndGet(SEEDS[1])); + } + + @Test + public void testWatershedLabel() { + final var img = random(); + final var markers = seeds(W, H, SEEDS, 7, 42, 3); + final var all = ArrayImgs.bits(W, H); + for (final var t : all) + t.set(true); + final var lines = ops.op("segment.compactWatershed").input(img, markers, + 0.1).outType(LABELING).apply(); + final var labeled = ops.op("segment.compactWatershed").input(img, markers, + 0.1, null, true, all, 0).outType(LABELING).apply(); + final var ra = lines.randomAccess(); + var lineCount = 0; + for (final var c = labeled.localizingCursor(); c.hasNext();) { + final var l = c.next(); + final var expected = ra.setPositionAndGet(c); + assertEquals(1, l.size()); + if (expected.isEmpty()) { + assertEquals(Set.of(0), l); + lineCount++; + } + else assertEquals(expected, l); + } + assertTrue(lineCount > 0); + + // Without watershed lines, the watershed label is not used. + final var noLines = ops.op("segment.compactWatershed").input(img, markers, + 0.1, null, false, all, 0).outType(LABELING).apply(); + assertEquals(Set.of(7, 42, 3), labels(noLines)); + + // A watershed label which is also a region label is rejected. + assertThrows(IllegalArgumentException.class, () -> ops.op( + "segment.compactWatershed").input(img, markers, 0.1, null, true, all, 42) + .outType(LABELING).apply()); + } + + /** Without compactness, the result is the classical seeded watershed. */ + @Test + public void testZeroCompactnessMatchesSeededWatershed() { + final var img = random(); + final var markers = seeds(W, H, SEEDS, 1, 2, 3); + final var expected = ops.op("image.watershed").input(img, markers, true, + false).outType(LABELING).apply(); + final var actual = ops.op("segment.compactWatershed").input(img, markers, + 0.0, new RectangleShape(1, false), false).outType(LABELING).apply(); + assertBoundaryDifferences(toArray(expected), toArray(actual), W); + } + + /** With a huge compactness, the result is the Voronoi partition. */ + @Test + public void testHighCompactnessGivesVoronoi() { + final var out = ops.op("segment.compactWatershed").input(random(), seeds(W, + H, SEEDS, 1, 2, 3), 1e6, null, false).outType(LABELING).apply(); + final var actual = toArray(out); + var checked = 0; + for (var y = 0; y < H; y++) { + for (var x = 0; x < W; x++) { + final var d = new double[SEEDS.length]; + var nearest = 0; + for (var k = 0; k < SEEDS.length; k++) { + d[k] = Math.hypot(x - SEEDS[k][0], y - SEEDS[k][1]); + if (d[k] < d[nearest]) nearest = k; + } + var margin = Double.POSITIVE_INFINITY; + for (var k = 0; k < SEEDS.length; k++) + if (k != nearest) margin = Math.min(margin, d[k] - d[nearest]); + // Note: Discrete paths make distances near the bisectors ambiguous. + if (margin < 2) continue; + assertEquals(nearest + 1, actual[y * W + x], "(" + x + ", " + y + ")"); + checked++; + } + } + assertTrue(checked > W * H / 2); + } + + /** + * Compactness keeps a region from leaking along a long valley into the + * surroundings of another marker. + */ + @Test + public void testCompactnessStopsLeaks() { + // A flat image with an L-shaped valley from (5, 10) up to row 3 and then + // along it, passing over the second marker at (25, 10). + final var img = ArrayImgs.doubles(31, 21); + final var ra = img.randomAccess(); + for (final var c = img.localizingCursor(); c.hasNext();) { + c.fwd(); + final var x = c.getIntPosition(0); + final var y = c.getIntPosition(1); + final var valley = y == 3 || (x == 5 && y >= 3 && y <= 10); + ra.setPositionAndGet(c).set(valley ? 0 : 1); + } + final var markers = seeds(31, 21, new int[][] { { 5, 10 }, { 25, 10 } }, + 1, 2); + final var classic = ops.op("segment.compactWatershed").input(img, markers, + 0.0, null, false).outType(LABELING).apply(); + assertEquals(Set.of(1), classic.randomAccess().setPositionAndGet(25, 3)); + assertEquals(Set.of(1), classic.randomAccess().setPositionAndGet(25, 4)); + final var compact = ops.op("segment.compactWatershed").input(img, markers, + 1.0, null, false).outType(LABELING).apply(); + assertEquals(Set.of(2), compact.randomAccess().setPositionAndGet(25, 3)); + assertEquals(Set.of(2), compact.randomAccess().setPositionAndGet(25, 4)); + assertEquals(Set.of(1), compact.randomAccess().setPositionAndGet(5, 3)); + } + + /** + * Compares with MorphoLibJ 1.6.4, run as a black box + * ({@code Watershed.computeWatershed} with compactness 0, full connectivity + * and no dams) on the image {@code ExtendedMinimaWatershedTest.F} with + * three single-pixel markers. Pixels of equal priority are flooded in a + * different order (see {@code ExtendedMinimaWatershedTest}). + */ + @Test + public void testMorphoLibJ() { + final var img = ArrayImgs.unsignedBytes(20, 16); + var i = 0; + for (final var t : img) + t.set(ExtendedMinimaWatershedTest.F[i++]); + final var markers = seeds(20, 16, new int[][] { { 4, 4 }, { 14, 5 }, { 9, + 12 } }, 1, 2, 3); + final var out = ops.op("segment.compactWatershed").input(img, markers, 0.0, + null, false).outType(LABELING).apply(); + assertFewDifferences(COMPACT_0, toArray(out)); + } + + private static final int[] COMPACT_0 = { + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2, 2, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2, 2, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 3, 2, 2, 2, 2, 2, 2, 2, 2, + 1, 1, 1, 1, 1, 1, 3, 3, 3, 3, 3, 3, 3, 2, 2, 2, 2, 2, 2, 2, + 1, 1, 1, 1, 1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 2, 2, 2, 2, 2, 2, + 1, 1, 1, 1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 2, 2, 2, 2, 2, + 1, 1, 1, 1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 2, 2, 2, 2, + 1, 1, 1, 1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 2, 2, 2, + 1, 1, 1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 2, 2, + 1, 1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 2, + }; +} diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/segment/watershed/DistanceTransformWatershedTest.java b/scijava-ops-image/src/test/java/org/scijava/ops/image/segment/watershed/DistanceTransformWatershedTest.java new file mode 100644 index 000000000..f63d8373c --- /dev/null +++ b/scijava-ops-image/src/test/java/org/scijava/ops/image/segment/watershed/DistanceTransformWatershedTest.java @@ -0,0 +1,228 @@ +/* #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.segment.watershed; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.scijava.ops.image.segment.watershed.ExtendedMinimaWatershedTest.labels; + +import java.util.Set; + +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.algorithm.neighborhood.DiamondShape; +import net.imglib2.img.Img; +import net.imglib2.img.array.ArrayImgs; +import net.imglib2.roi.labeling.ImgLabeling; +import net.imglib2.type.logic.BitType; +import net.imglib2.type.numeric.integer.IntType; +import net.imglib2.view.Views; + +import org.junit.jupiter.api.Test; +import org.scijava.ops.image.AbstractOpTest; +import org.scijava.types.Nil; + +/** + * Tests {@link DistanceTransformWatershed}. + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +public class DistanceTransformWatershedTest extends AbstractOpTest { + + private static final Nil> LABELING = + new Nil<>() + {}; + + /** + * Two disks of radius 8 whose centers are 12 pixels apart: the distance + * transform is about 8 at their centers and about 5.3 at the neck. + */ + private static Img twoDisks() { + final var img = ArrayImgs.bits(40, 22); + final var ra = img.randomAccess(); + for (var y = 0; y < 22; y++) { + for (var x = 0; x < 40; x++) { + final var in = Math.hypot(x - 14, y - 11) <= 8 || Math.hypot(x - 26, + y - 11) <= 8; + ra.setPositionAndGet(x, y).set(in); + } + } + return img; + } + + @Test + public void testSplitsTouchingDisks() { + final var img = twoDisks(); + final var out = ops.op("segment.distanceTransformWatershed").input(img) + .outType(LABELING).apply(); + assertEquals(2, labels(out).size()); + final var ra = out.randomAccess(); + final var raIn = img.randomAccess(); + var lines = 0; + for (var y = 0; y < 22; y++) { + for (var x = 0; x < 40; x++) { + final var l = ra.setPositionAndGet(x, y); + if (!raIn.setPositionAndGet(x, y).get()) { + assertTrue(l.isEmpty()); + continue; + } + if (l.isEmpty()) { + lines++; + assertEquals(20, x, "line pixel at (" + x + ", " + y + ")"); + } + else assertEquals(x < 20 ? 1 : 2, l.iterator().next()); + } + } + assertTrue(lines > 0); + } + + @Test + public void testWatershedLabel() { + final var img = twoDisks(); + final var lines = ops.op("segment.distanceTransformWatershed").input(img) + .outType(LABELING).apply(); + final var labeled = ops.op("segment.distanceTransformWatershed").input(img, + 1.0, null, true, null, -1).outType(LABELING).apply(); + final var ra = lines.randomAccess(); + final var raIn = img.randomAccess(); + var lineCount = 0; + for (final var c = labeled.localizingCursor(); c.hasNext();) { + final var l = c.next(); + final var expected = ra.setPositionAndGet(c); + if (!raIn.setPositionAndGet(c).get()) { + // Note: The background is not part of the watershed lines. + assertTrue(l.isEmpty()); + } + else if (expected.isEmpty()) { + assertEquals(Set.of(-1), l); + lineCount++; + } + else assertEquals(expected, l); + } + assertTrue(lineCount > 0); + + // Without watershed lines, the watershed label is not used. + final var noLines = ops.op("segment.distanceTransformWatershed").input(img, + 1.0, null, false, null, -1).outType(LABELING).apply(); + assertEquals(Set.of(1, 2), labels(noLines)); + } + + @Test + public void testDynamicControlsSplitting() { + final var img = twoDisks(); + final var split = ops.op("segment.distanceTransformWatershed").input(img, + 2.0, new DiamondShape(1), false).outType(LABELING).apply(); + assertEquals(2, labels(split).size()); + // Without lines, every foreground pixel is labeled. + final var ra = split.randomAccess(); + for (final var c = img.localizingCursor(); c.hasNext();) { + final var fg = c.next().get(); + assertEquals(fg ? 1 : 0, ra.setPositionAndGet(c).size()); + } + final var merged = ops.op("segment.distanceTransformWatershed").input(img, + 4.0).outType(LABELING).apply(); + assertEquals(1, labels(merged).size()); + } + + @Test + public void testCalibration() { + final var img = twoDisks(); + // Unit spacing gives the same result as no calibration. + final var plain = ops.op("segment.distanceTransformWatershed").input(img, + 1.0, null, true).outType(LABELING).apply(); + final var unit = ops.op("segment.distanceTransformWatershed").input(img, + 1.0, null, true, new double[] { 1, 1 }).outType(LABELING).apply(); + final var ra = unit.randomAccess(); + for (final var c = plain.localizingCursor(); c.hasNext();) { + assertEquals(c.next(), ra.setPositionAndGet(c)); + } + // Doubling the spacing doubles the distances, and thus the neck depth. + final var scaled = ops.op("segment.distanceTransformWatershed").input(img, + 4.0, null, true, new double[] { 2, 2 }).outType(LABELING).apply(); + assertEquals(2, labels(scaled).size()); + } + + @Test + public void test3D() { + final var img = ArrayImgs.bits(30, 16, 16); + final var ra = img.randomAccess(); + for (final var c = img.localizingCursor(); c.hasNext();) { + c.fwd(); + final double x = c.getIntPosition(0), y = c.getIntPosition(1), z = c + .getIntPosition(2); + final var r1 = Math.sqrt(sq(x - 10) + sq(y - 8) + sq(z - 8)); + final var r2 = Math.sqrt(sq(x - 19) + sq(y - 8) + sq(z - 8)); + ra.setPositionAndGet(c).set(r1 <= 6 || r2 <= 6); + } + final var out = ops.op("segment.distanceTransformWatershed").input(img) + .outType(LABELING).apply(); + assertEquals(2, labels(out).size()); + } + + private static double sq(final double v) { + return v * v; + } + + @Test + public void testNonZeroMin() { + final var img = twoDisks(); + checkNonZeroMin(img, Views.translate(img, 25, -9), null); + checkNonZeroMin(img, Views.translate(img, 25, -9), new double[] { 1, 1 }); + + // The disks cropped from a larger image with foreground around them. + final var big = ArrayImgs.bits(46, 30); + final var ra = big.randomAccess(); + final var raIn = img.randomAccess(); + for (var y = 0; y < 30; y++) { + for (var x = 0; x < 46; x++) { + final var inCrop = x >= 4 && x < 44 && y >= 5 && y < 27; + ra.setPositionAndGet(x, y).set(inCrop ? raIn.setPositionAndGet(x - 4, + y - 5).get() : true); + } + } + checkNonZeroMin(img, Views.interval(big, new long[] { 4, 5 }, new long[] { + 43, 26 }), null); + } + + private void checkNonZeroMin(final Img img, + final RandomAccessibleInterval view, final double[] calibration) + { + final var expected = ops.op("segment.distanceTransformWatershed").input( + img, 1.0, null, true, calibration).outType(LABELING).apply(); + final var actual = ops.op("segment.distanceTransformWatershed").input(view, + 1.0, null, true, calibration).outType(LABELING).apply(); + assertArrayEquals(view.minAsLongArray(), actual.minAsLongArray()); + assertArrayEquals(view.maxAsLongArray(), actual.maxAsLongArray()); + assertEquals(2, labels(actual).size()); + final var e = Views.flatIterable(expected).cursor(); + for (final var a : Views.flatIterable(actual)) + assertEquals(e.next(), a); + } +} diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/segment/watershed/ExtendedMinimaWatershedTest.java b/scijava-ops-image/src/test/java/org/scijava/ops/image/segment/watershed/ExtendedMinimaWatershedTest.java new file mode 100644 index 000000000..481f415ae --- /dev/null +++ b/scijava-ops-image/src/test/java/org/scijava/ops/image/segment/watershed/ExtendedMinimaWatershedTest.java @@ -0,0 +1,452 @@ +/* #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.segment.watershed; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; +import java.util.stream.Collectors; + +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.algorithm.neighborhood.DiamondShape; +import net.imglib2.algorithm.neighborhood.RectangleShape; +import net.imglib2.algorithm.neighborhood.Shape; +import net.imglib2.img.Img; +import net.imglib2.img.array.ArrayImgs; +import net.imglib2.roi.labeling.ImgLabeling; +import net.imglib2.type.numeric.integer.IntType; +import net.imglib2.type.numeric.integer.UnsignedByteType; +import net.imglib2.type.numeric.real.DoubleType; +import net.imglib2.view.Views; + +import org.junit.jupiter.api.Test; +import org.scijava.ops.image.AbstractOpTest; +import org.scijava.types.Nil; + +/** + * Tests {@link ExtendedMinimaWatershed}. + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +public class ExtendedMinimaWatershedTest extends AbstractOpTest { + + private static final Nil> LABELING = + new Nil<>() + {}; + + /** Two cones whose basins meet at a saddle of depth 5 below the rims. */ + private static Img twoBasins() { + final var img = ArrayImgs.doubles(21, 11); + final var ra = img.randomAccess(); + for (var y = 0; y < 11; y++) { + for (var x = 0; x < 21; x++) { + final var d1 = Math.hypot(x - 5, y - 5); + final var d2 = Math.hypot(x - 15, y - 5); + ra.setPositionAndGet(x, y).set(Math.min(d1, d2)); + } + } + return img; + } + + @Test + public void testDynamicControlsSplitting() { + final var img = twoBasins(); + // The saddle between the cones is at height 5 (x = 10, y = 5). + final var split = ops.op("segment.extendedMinimaWatershed").input(img, 1.0) + .outType(LABELING).apply(); + assertEquals(2, labels(split).size()); + final var merged = ops.op("segment.extendedMinimaWatershed").input(img, + 6.0).outType(LABELING).apply(); + assertEquals(1, labels(merged).size()); + } + + @Test + public void testWatershedLines() { + final var img = twoBasins(); + final var lines = ops.op("segment.extendedMinimaWatershed").input(img, 1.0, + new DiamondShape(1), true).outType(LABELING).apply(); + final var noLines = ops.op("segment.extendedMinimaWatershed").input(img, + 1.0, new DiamondShape(1), false).outType(LABELING).apply(); + final var ra = lines.randomAccess(); + final var raNo = noLines.randomAccess(); + var unlabeled = 0; + for (var y = 0; y < 11; y++) { + for (var x = 0; x < 21; x++) { + assertEquals(1, raNo.setPositionAndGet(x, y).size()); + final var l = ra.setPositionAndGet(x, y); + if (l.isEmpty()) { + unlabeled++; + assertEquals(10, x); + } + else { + assertEquals(x < 10 ? 1 : 2, l.iterator().next()); + assertEquals(l, raNo.get()); + } + } + } + assertEquals(11, unlabeled); + } + + @Test + public void testWatershedLabel() { + final var img = twoBasins(); + final var shape = new DiamondShape(1); + final var all = ArrayImgs.bits(21, 11); + for (final var t : all) + t.set(true); + final var lines = ops.op("segment.extendedMinimaWatershed").input(img, 1.0, + shape, true).outType(LABELING).apply(); + final var labeled = ops.op("segment.extendedMinimaWatershed").input(img, + 1.0, shape, true, all, 0.0, -1).outType(LABELING).apply(); + final var ra = lines.randomAccess(); + var lineCount = 0; + for (final var c = labeled.localizingCursor(); c.hasNext();) { + final var l = c.next(); + final var expected = ra.setPositionAndGet(c); + assertEquals(1, l.size()); + if (expected.isEmpty()) { + assertEquals(Set.of(-1), l); + lineCount++; + } + else assertEquals(expected, l); + } + assertEquals(11, lineCount); + + // Without watershed lines, the watershed label is not used. + final var noLines = ops.op("segment.extendedMinimaWatershed").input(img, + 1.0, shape, false, all, 0.0, -1).outType(LABELING).apply(); + assertEquals(Set.of(1, 2), labels(noLines)); + + // A watershed label which is also a region label is rejected. + assertThrows(IllegalArgumentException.class, () -> ops.op( + "segment.extendedMinimaWatershed").input(img, 1.0, shape, true, all, 0.0, + 2).outType(LABELING).apply()); + } + + /** NaN has no order, so reliefs containing NaN are rejected. */ + @Test + public void testNaN() { + final var img = twoBasins(); + img.firstElement().set(Double.NaN); + assertThrows(IllegalArgumentException.class, () -> ops.op( + "segment.extendedMinimaWatershed").input(img, 1.0).outType(LABELING) + .apply()); + } + + @Test + public void testMask() { + final var img = twoBasins(); + final var mask = ArrayImgs.bits(21, 11); + for (final var c = Views.interval(mask, new long[] { 0, 0 }, new long[] { + 12, 10 }).cursor(); c.hasNext();) + c.next().set(true); + final var out = ops.op("segment.extendedMinimaWatershed").input(img, 3.0, + null, false, mask).outType(LABELING).apply(); + final var ra = out.randomAccess(); + for (var y = 0; y < 11; y++) { + for (var x = 0; x < 21; x++) { + final var l = ra.setPositionAndGet(x, y); + if (x > 12) assertTrue(l.isEmpty()); + else assertEquals(Set.of(1), l); + } + } + } + + /** + * Compares with MorphoLibJ on a noisy 8-bit relief with three basins. + *

+ * The expected arrays were produced by running MorphoLibJ 1.6.4 as a black + * box ({@code MinimaAndMaxima.extendedMinima}, then + * {@code BinaryImages.componentsLabeling}, {@code + * MinimaAndMaxima.imposeMinima} and {@code Watershed.computeWatershed}) on + * the image {@code F}; 0 stands for no label. With watershed lines the + * results are identical; without them, MorphoLibJ breaks ties between pixels + * of equal priority differently (see {@link #assertFewDifferences}). + *

+ */ + @Test + public void testMorphoLibJ() { + final var img = ArrayImgs.unsignedBytes(20, 16); + var i = 0; + for (final var t : img) + t.set(F[i++]); + check(img, 2.0, new DiamondShape(1), true, EMW_H2_C4_LINES); + check(img, 2.0, new DiamondShape(1), false, EMW_H2_C4); + check(img, 2.0, new RectangleShape(1, false), true, EMW_H2_C8_LINES); + check(img, 2.0, new RectangleShape(1, false), false, EMW_H2_C8); + check(img, 4.0, new DiamondShape(1), true, EMW_H4_C4_LINES); + check(img, 4.0, new DiamondShape(1), false, EMW_H4_C4); + check(img, 4.0, new RectangleShape(1, false), true, EMW_H4_C8_LINES); + check(img, 4.0, new RectangleShape(1, false), false, EMW_H4_C8); + } + + private void check(final Img img, final double h, + final Shape shape, final boolean lines, final int[] expected) + { + final var out = ops.op("segment.extendedMinimaWatershed").input(img, h, + shape, lines).outType(LABELING).apply(); + final var actual = toArray(out); + if (lines) assertArrayEquals(expected, actual); + else assertFewDifferences(expected, actual); + } + + /** + * Asserts that two label arrays have the same labels and differ on at most + * 10% of the pixels. + *

+ * This is used for comparisons with MorphoLibJ without watershed lines, + * where pixels of equal priority (notably on the plateaus created by minima + * imposition) are flooded in a different order, so that whole plateaus may + * be assigned to another adjacent region. + *

+ */ + static void assertFewDifferences(final int[] expected, final int[] actual) { + assertEquals(Arrays.stream(expected).boxed().collect(Collectors.toSet()), + Arrays.stream(actual).boxed().collect(Collectors.toSet())); + var diff = 0; + for (var i = 0; i < expected.length; i++) + if (expected[i] != actual[i]) diff++; + assertTrue(diff <= expected.length / 10, diff + " differences"); + } + + /** + * Asserts that two 2D label arrays differ only on few pixels, each lying + * next to a pixel of the expected array having the actual label. + */ + static void assertBoundaryDifferences(final int[] expected, + final int[] actual, final int width) + { + final var height = expected.length / width; + var diff = 0; + for (var i = 0; i < expected.length; i++) { + if (expected[i] == actual[i]) continue; + diff++; + final var x = i % width; + final var y = i / width; + var nearby = false; + for (var yy = Math.max(0, y - 1); yy <= Math.min(height - 1, y + + 1); yy++) + { + for (var xx = Math.max(0, x - 1); xx <= Math.min(width - 1, x + + 1); xx++) + { + if (expected[yy * width + xx] == actual[i]) nearby = true; + } + } + assertTrue(nearby, "Pixel (" + x + ", " + y + ") differs"); + } + assertTrue(diff <= expected.length / 10, diff + " differences"); + } + + static int[] toArray( + final RandomAccessibleInterval> labeling) + { + final var result = new int[(int) Views.iterable(labeling).size()]; + var i = 0; + for (final var l : Views.flatIterable(labeling)) + result[i++] = l.isEmpty() ? 0 : l.iterator().next(); + return result; + } + + static Set labels(final ImgLabeling labeling) { + final var set = new HashSet(); + for (final var l : labeling) + set.addAll(l); + return set; + } + + static final int[] F = { + 12, 11, 10, 9, 9, 8, 9, 11, 13, 14, 15, 14, 13, 12, 11, 11, 11, 12, 13, 15, + 12, 8, 7, 7, 7, 6, 8, 10, 11, 13, 13, 10, 9, 10, 8, 10, 10, 10, 12, 13, + 11, 8, 6, 6, 5, 6, 6, 7, 11, 12, 12, 8, 8, 7, 8, 7, 9, 8, 12, 13, + 10, 6, 6, 5, 3, 3, 6, 6, 9, 10, 9, 8, 7, 4, 6, 5, 8, 7, 10, 12, + 10, 6, 4, 4, 2, 3, 5, 8, 9, 10, 10, 7, 5, 3, 2, 4, 6, 7, 10, 10, + 9, 6, 4, 4, 4, 5, 6, 8, 8, 10, 9, 6, 5, 4, 0, 4, 4, 6, 10, 11, + 9, 8, 8, 6, 5, 4, 7, 7, 9, 11, 10, 8, 6, 3, 2, 3, 4, 7, 10, 12, + 12, 8, 9, 6, 8, 8, 7, 8, 11, 10, 9, 8, 6, 5, 5, 5, 7, 9, 9, 12, + 11, 12, 10, 8, 8, 10, 11, 10, 8, 10, 10, 8, 9, 8, 8, 7, 7, 10, 11, 12, + 14, 12, 12, 11, 12, 10, 8, 7, 8, 7, 7, 9, 8, 9, 9, 8, 9, 11, 12, 15, + 16, 14, 15, 12, 13, 9, 9, 6, 6, 5, 6, 8, 9, 11, 12, 12, 13, 13, 15, 15, + 16, 15, 14, 12, 10, 8, 7, 4, 5, 4, 5, 6, 8, 9, 10, 14, 15, 13, 15, 18, + 20, 17, 15, 13, 11, 9, 7, 4, 4, 1, 2, 4, 7, 10, 11, 13, 16, 17, 17, 17, + 19, 16, 14, 14, 10, 10, 7, 4, 5, 3, 4, 5, 8, 8, 12, 12, 16, 17, 18, 21, + 20, 18, 17, 13, 13, 10, 7, 7, 4, 4, 4, 8, 9, 9, 11, 13, 15, 18, 18, 20, + 21, 18, 15, 13, 14, 12, 9, 8, 6, 8, 6, 9, 9, 11, 14, 13, 16, 17, 20, 23, + }; + private static final int[] EMW_H2_C4_LINES = { + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 2, 2, 2, 2, 2, 2, 2, 2, 2, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, + 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, + 1, 1, 1, 1, 1, 1, 0, 0, 3, 3, 0, 2, 2, 2, 2, 2, 2, 2, 2, 2, + 1, 1, 1, 1, 1, 0, 3, 3, 3, 3, 3, 0, 0, 2, 2, 2, 2, 2, 2, 2, + 1, 1, 0, 0, 0, 3, 3, 3, 3, 3, 3, 3, 3, 0, 0, 2, 2, 2, 2, 2, + 1, 0, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 0, 2, 2, 2, 2, + 0, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 0, 2, 2, 2, + 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 0, 2, 2, + 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 0, 2, + 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 0, + }; + private static final int[] EMW_H2_C4 = { + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2, 2, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, + 1, 1, 1, 1, 1, 1, 1, 1, 3, 3, 3, 2, 2, 2, 2, 2, 2, 2, 2, 2, + 1, 1, 1, 1, 1, 1, 3, 3, 3, 3, 3, 3, 3, 2, 2, 2, 2, 2, 2, 2, + 1, 1, 1, 1, 1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 2, 2, 2, 2, 2, + 1, 1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 2, 2, 2, + 1, 1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, + 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, + 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, + 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, + }; + private static final int[] EMW_H2_C8_LINES = { + 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, + 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 2, 2, 2, 2, 2, 2, 2, 2, + 1, 1, 1, 1, 0, 0, 3, 3, 3, 3, 3, 0, 0, 0, 2, 2, 2, 2, 2, 2, + 1, 1, 0, 0, 0, 3, 3, 3, 3, 3, 3, 3, 3, 0, 0, 0, 2, 2, 2, 2, + 1, 0, 0, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 0, 0, 2, 2, 2, + 0, 0, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 0, 0, 2, 2, + 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 0, 0, 0, + 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, + 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, + }; + private static final int[] EMW_H2_C8 = { + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2, 2, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2, 2, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 3, 2, 2, 2, 2, 2, 2, 2, 2, + 1, 1, 1, 1, 1, 1, 3, 3, 3, 3, 3, 3, 3, 2, 2, 2, 2, 2, 2, 2, + 1, 1, 1, 1, 1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 2, 2, 2, 2, 2, 2, + 1, 1, 1, 1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 2, 2, 2, 2, 2, + 1, 1, 1, 1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 2, 2, 2, 2, + 1, 1, 1, 1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 2, 2, 2, + 1, 1, 1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 2, 2, + 1, 1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 2, + }; + private static final int[] EMW_H4_C4_LINES = { + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 2, 2, 2, 2, 2, 2, 2, 2, 2, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, + 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, + 1, 1, 1, 1, 1, 1, 0, 0, 3, 3, 0, 2, 2, 2, 2, 2, 2, 2, 2, 2, + 1, 1, 1, 1, 1, 0, 3, 3, 3, 3, 3, 0, 0, 2, 2, 2, 2, 2, 2, 2, + 1, 1, 0, 0, 0, 3, 3, 3, 3, 3, 3, 3, 3, 0, 0, 2, 2, 2, 2, 2, + 1, 0, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 0, 2, 2, 2, 2, + 0, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 0, 2, 2, 2, + 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 0, 2, 2, + 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 0, 2, + 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 0, + }; + private static final int[] EMW_H4_C4 = { + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2, 2, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, + 1, 1, 1, 1, 1, 1, 1, 1, 3, 3, 3, 2, 2, 2, 2, 2, 2, 2, 2, 2, + 1, 1, 1, 1, 1, 1, 3, 3, 3, 3, 3, 3, 3, 2, 2, 2, 2, 2, 2, 2, + 1, 1, 1, 1, 1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 2, 2, 2, 2, 2, + 1, 1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 2, 2, 2, + 1, 1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, + 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, + 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, + 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, + }; + private static final int[] EMW_H4_C8_LINES = { + 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, + 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 2, 2, 2, 2, 2, 2, 2, 2, + 1, 1, 1, 1, 0, 0, 3, 3, 3, 3, 3, 0, 0, 0, 2, 2, 2, 2, 2, 2, + 1, 1, 0, 0, 0, 3, 3, 3, 3, 3, 3, 3, 3, 0, 0, 0, 2, 2, 2, 2, + 1, 0, 0, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 0, 0, 2, 2, 2, + 0, 0, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 0, 0, 2, 2, + 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 0, 0, 0, + 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, + 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, + }; + private static final int[] EMW_H4_C8 = { + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2, 2, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2, 2, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 3, 2, 2, 2, 2, 2, 2, 2, 2, + 1, 1, 1, 1, 1, 1, 3, 3, 3, 3, 3, 3, 3, 2, 2, 2, 2, 2, 2, 2, + 1, 1, 1, 1, 1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 2, 2, 2, 2, 2, 2, + 1, 1, 1, 1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 2, 2, 2, 2, 2, + 1, 1, 1, 1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 2, 2, 2, 2, + 1, 1, 1, 1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 2, 2, 2, + 1, 1, 1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 2, 2, + 1, 1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 2, + }; +} diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/stats/entropy/DefaultEntropyTest.java b/scijava-ops-image/src/test/java/org/scijava/ops/image/stats/entropy/DefaultEntropyTest.java new file mode 100644 index 000000000..9a0eb4e2c --- /dev/null +++ b/scijava-ops-image/src/test/java/org/scijava/ops/image/stats/entropy/DefaultEntropyTest.java @@ -0,0 +1,74 @@ +/*- + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.stats.entropy; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import net.imglib2.img.array.ArrayImgs; + +import org.junit.jupiter.api.Test; +import org.scijava.ops.image.AbstractOpTest; + +/** + * Tests {@link DefaultEntropy}. + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +public class DefaultEntropyTest extends AbstractOpTest { + + @Test + public void testUniform() { + final var img = ArrayImgs.unsignedBytes(new byte[] { 0, 1, 2, 3, 3, 2, 1, + 0 }, 8); + final Double h = ops.op("stats.entropy").input(img, 4).outType(Double.class) + .apply(); + assertEquals(2, h, 1e-12); + } + + @Test + public void testTwoLevels() { + // One quarter of the pixels at 1, the rest at 0. + final var img = ArrayImgs.doubles(new double[] { 0, 0, 0, 1 }, 2, 2); + final Double h = ops.op("stats.entropy").input(img).outType(Double.class) + .apply(); + final double expected = -(0.25 * Math.log(0.25) + 0.75 * Math.log(0.75)) / + Math.log(2); + assertEquals(expected, h, 1e-12); + } + + @Test + public void testConstant() { + final var img = ArrayImgs.floats(new float[] { 7, 7, 7, 7, 7, 7 }, 3, 2); + final Double h = ops.op("stats.entropy").input(img, 16).outType( + Double.class).apply(); + assertEquals(0, h, 0); + } +} diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/threshold/AutoLocalThresholdParityTest.java b/scijava-ops-image/src/test/java/org/scijava/ops/image/threshold/AutoLocalThresholdParityTest.java new file mode 100644 index 000000000..07df73dcf --- /dev/null +++ b/scijava-ops-image/src/test/java/org/scijava/ops/image/threshold/AutoLocalThresholdParityTest.java @@ -0,0 +1,276 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.threshold; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; + +import net.imglib2.algorithm.neighborhood.HyperSphereShape; +import net.imglib2.img.Img; +import net.imglib2.img.array.ArrayImgs; +import net.imglib2.type.logic.BitType; +import net.imglib2.type.numeric.integer.UnsignedByteType; + +import org.junit.jupiter.api.Test; +import org.scijava.ops.image.AbstractOpTest; + +/** + * Regression tests pinning the local threshold ops to the masks of Fiji's + * Auto_Local_Threshold plugin. + *

+ * The expected masks in {@code fiji-local-masks.txt} were obtained by running + * {@code Auto_Local_Threshold.exec} (fiji/Auto_Local_Threshold at + * 1.11.1-SNAPSHOT, with ImageJ 1.54p) with default parameters and white + * objects on the 8-bit images of {@link AutoThresholdParityTest}. Fiji was run + * with the radius {@code sqrt(r * r - 0.5)}, for which its circular kernel + * contains exactly the pixels of {@code HyperSphereShape(r)}; with an integer + * radius, ImageJ kernels also include the pixels at squared distance + * {@code r * r + 1}. + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +public class AutoLocalThresholdParityTest extends AbstractOpTest { + + @Test + public void testContrast() throws IOException { + assertFiji("Contrast"); + } + + @Test + public void testMean() throws IOException { + assertFiji("Mean"); + } + + @Test + public void testMedian() throws IOException { + assertFiji("Median"); + } + + @Test + public void testMidGrey() throws IOException { + assertFiji("MidGrey"); + } + + /** + * Sauvola with Fiji parameters k = 0.5 and r = 128. Before the op used the + * population standard deviation, as ImageJ RankFilters do, rather than the + * sample standard deviation, a few pixels differed. + */ + @Test + public void testSauvola() throws IOException { + assertFiji("Sauvola"); + } + + /** + * Sauvola with the default parameters of the op, which are those of Fiji on + * 8-bit images. Before the default r was half the type range, it was 0.5, + * which made nearly every pixel of an 8-bit image background. + */ + @Test + public void testSauvolaDefaults() throws IOException { + assertFiji("Sauvola", (img, shape, out) -> ops.op("threshold.localSauvola") + .input(img, shape).output(out).compute()); + } + + /** + * Sauvola with the default parameters on a floating-point image with the + * values of the 8-bit image divided by 256, for which the default r is 0.5 + * (since 256 is a power of 2, all the statistics scale exactly). + */ + @Test + public void testSauvolaDefaultsFloat() throws IOException { + assertFiji("Sauvola", (img, shape, out) -> { + final var scaled = ArrayImgs.doubles(img.dimensionsAsLongArray()); + final var c = img.cursor(); + for (final var t : scaled) + t.set(c.next().get() / 256.0); + ops.op("threshold.localSauvola").input(scaled, shape).output(out) + .compute(); + }); + } + + /** + * Phansalkar with Fiji parameters k = 0.25 and r = 0.5, on the raw 8-bit + * image, which the op stretches to [0, 1] as Fiji does. Before the op + * stretched its input, all pixels were background (1490 and 1324 of 4096 + * pixels differed on blobs8, 2445 and 2205 on narrow8). Then 1, 0, 26 and 36 + * pixels differed, because ImageJ stretches 8-bit images to 8-bit values, + * rounding down, and computes the population rather than the sample + * standard deviation; the op now does both. + */ + @Test + public void testPhansalkar() throws IOException { + assertFiji("Phansalkar"); + } + + /** Phansalkar with the default parameters, which are those of Fiji. */ + @Test + public void testPhansalkarDefaults() throws IOException { + assertFiji("Phansalkar", (img, shape, out) -> ops.op( + "threshold.localPhansalkar").input(img, shape).output(out).compute()); + } + + /** + * Bernsen with Fiji parameters (contrast threshold 15, and 128 as the mid + * grey of low-contrast regions). Fiji rounds the local mid grey + * {@code (min + max) / 2} down to an integer before comparing pixels to it + * with {@code >=}. Before the op did so too, it missed the pixels half a + * grey level below the mid grey (47 and 15 pixels on blobs8, 86 and 46 on + * narrow8). + */ + @Test + public void testBernsen() throws IOException { + assertFiji("Bernsen"); + } + + /** + * Niblack with Fiji parameters k = 0.2 and c = 0. Before the op used the + * population standard deviation, as ImageJ RankFilters do, rather than the + * sample standard deviation, a few pixels differed (7 and 0 on blobs8, 6 and + * 1 on narrow8). That Fiji truncates the threshold to an integer makes no + * difference, since pixels must be strictly above it. + */ + @Test + public void testNiblack() throws IOException { + assertFiji("Niblack"); + } + + private interface Method { + + void compute(Img img, HyperSphereShape shape, + Img out); + } + + private void assertFiji(final String method) throws IOException { + assertFiji(method, 0); + } + + private void assertFiji(final String method, final int maxDiff) + throws IOException + { + assertFiji(method, maxDiff, (img, shape, out) -> compute(method, img, + shape, out)); + } + + private void assertFiji(final String method, final Method op) + throws IOException + { + assertFiji(method, 0, op); + } + + private void assertFiji(final String method, final int maxDiff, + final Method op) throws IOException + { + var checked = 0; + try (var in = new BufferedReader(new InputStreamReader(getClass() + .getResourceAsStream("fiji-local-masks.txt"), StandardCharsets.UTF_8))) + { + String line; + while ((line = in.readLine()) != null) { + if (line.startsWith("#")) continue; + final var f = line.split(" "); + if (!f[1].equals(method)) continue; + final var img = image(f[0]); + final var r = Integer.parseInt(f[2]); + final Img actual = ArrayImgs.bits(img.dimensionsAsLongArray()); + op.compute(img, new HyperSphereShape(r), actual); + var diff = 0; + var i = 0; + for (final var b : actual) { + final var nibble = Character.digit(f[3].charAt(i / 4), 16); + final var expected = (nibble & (8 >> (i % 4))) != 0; + if (expected != b.get()) diff++; + i++; + } + final var msg = method + " on " + f[0] + " with radius " + r + ": " + + diff + " differing pixels"; + assertTrue(diff <= maxDiff, msg); + checked++; + } + } + assertEquals(4, checked, "number of " + method + " masks"); + } + + private static Img image(final String name) { + if (name.equals("blobs8")) return AutoThresholdParityTest.blobs8(); + if (name.equals("narrow8")) return AutoThresholdParityTest.bytes( + AutoThresholdParityTest.blobs(2, 90, 6, 130, 6, 70, 150, false)); + throw new IllegalArgumentException(name); + } + + private static void compute(final String method, + final Img img, final HyperSphereShape shape, + final Img out) + { + switch (method) { + case "Contrast": + ops.op("threshold.localContrast").input(img, shape).output(out) + .compute(); + break; + case "Mean": + ops.op("threshold.localMean").input(img, shape, 0.0).output(out) + .compute(); + break; + case "Median": + ops.op("threshold.localMedian").input(img, shape, 0.0).output(out) + .compute(); + break; + case "MidGrey": + ops.op("threshold.localMidGrey").input(img, shape, 0.0).output(out) + .compute(); + break; + case "Bernsen": + ops.op("threshold.localBernsen").input(img, shape, 15.0, 128.0) + .output(out).compute(); + break; + case "Niblack": + ops.op("threshold.localNiblack").input(img, shape, 0.0, 0.2) + .output(out).compute(); + break; + case "Phansalkar": + ops.op("threshold.localPhansalkar").input(img, shape, 0.25, 0.5) + .output(out).compute(); + break; + case "Sauvola": + ops.op("threshold.localSauvola").input(img, shape, 0.5, 128.0).output( + out).compute(); + break; + default: + throw new IllegalArgumentException(method); + } + } +} diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/threshold/AutoThresholdParityTest.java b/scijava-ops-image/src/test/java/org/scijava/ops/image/threshold/AutoThresholdParityTest.java new file mode 100644 index 000000000..6e82acc75 --- /dev/null +++ b/scijava-ops-image/src/test/java/org/scijava/ops/image/threshold/AutoThresholdParityTest.java @@ -0,0 +1,282 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.threshold; + +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Random; + +import net.imglib2.img.Img; +import net.imglib2.img.array.ArrayImgs; +import net.imglib2.loops.LoopBuilder; +import net.imglib2.type.logic.BitType; +import net.imglib2.type.numeric.RealType; +import net.imglib2.type.numeric.integer.UnsignedByteType; +import net.imglib2.type.numeric.integer.UnsignedShortType; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.function.Executable; +import org.scijava.ops.image.AbstractOpTest; + +/** + * Regression tests pinning the global threshold ops to the thresholds of + * Fiji's Auto_Threshold plugin. + *

+ * The expected thresholds were obtained by running {@code Auto_Threshold.exec} + * (fiji/Auto_Threshold at 1.18.1-SNAPSHOT, with ImageJ 1.54p) on the images + * produced by {@link #blobs}, with "white objects on black background" and no + * stack histogram. Fiji marks pixels strictly above the threshold as + * foreground, so each op must produce the mask {@code pixel > threshold}. + *

+ * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +public class AutoThresholdParityTest extends AbstractOpTest { + + private static final int W = 64, H = 64; + + private static final String[] OPS = { "ij1", "intermodes", "isoData", "li", + "maxEntropy", "mean", "minError", "minimum", "moments", "otsu", + "percentile", "renyiEntropy", "shanbhag", "triangle", "yen", "huang", + "huang2" }; + + /** An 8-bit image spanning the full range, including 0 and 255. */ + @Test + public void testBlobs8() { + assertFiji(blobs8(), 112, 119, 112, 93, 71, 69, 69, 155, 107, 112, 45, 72, + 156, 73, 67, 114, 115); + } + + /** The "Ignore black" and "Ignore white" options. */ + @Test + public void testBlobs8IgnoreBlackWhite() { + assertFiji(blobs8(), true, false, 112, 119, 113, 92, 71, 69, 69, 155, 108, + 112, 45, 73, 156, 73, 67, 121, 113); + assertFiji(blobs8(), false, true, 111, 119, 111, 92, 71, 68, 68, 155, 105, + 111, 45, 72, 155, 73, 67, 118, 116); + assertFiji(blobs8(), true, true, 111, 119, 111, 92, 71, 68, 68, 155, 106, + 111, 45, 72, 156, 73, 67, 121, 124); + assertFiji(blobs8(), false, false, 112, 119, 112, 93, 71, 69, 69, 155, + 107, 112, 45, 72, 156, 73, 67, 114, 115); + } + + /** An 8-bit image whose values lie in [70, 150]. */ + @Test + public void testNarrow8() { + assertFiji(bytes(blobs(2, 90, 6, 130, 6, 70, 150, false)), 109, 119, 108, + 105, 103, 99, 103, 112, 103, 108, 93, 102, 114, 108, 101, 108, 112); + } + + /** A low-contrast 8-bit image whose values lie in [3, 60]. */ + @Test + public void testLowContrast8() { + assertFiji(bytes(blobs(5, 20, 4, 34, 5, 3, 60, false)), 22, 36, 24, 24, 27, + 22, 22, 50, 24, 25, 21, 27, 25, 29, 27, 24, 21); + } + + /** A 16-bit image, thresholded with one histogram bin per grey level. */ + @Test + public void testImg16() { + assertFiji(shorts(blobs(3, 1200, 150, 3000, 300, 0, 65535, false)), 2047, + 2047, 2047, 1842, 1533, 1575, 1584, 2001, 927, 2040, 1268, 1492, 2097, + 1225, 1470, 2001, 2017); + } + + /** + * An 8-bit image with only the grey levels 50 and 180, for which Fiji + * returns 179 with every method. + */ + @Test + public void testTwoLevel8() { + final var expected = new int[OPS.length]; + Arrays.fill(expected, 179); + assertFiji(bytes(blobs(4, 50, 0, 180, 0, 0, 255, false)), expected); + } + + /** + * An 8-bit image with the grey levels 10, 20 and 200, on which the MinError + * iteration hits NaN; Fiji then keeps the previous estimate (41). + */ + @Test + public void testMinErrorNotConverging() { + assertMask(bytes(gaps()), "threshold.minError", 41); + } + + /** + * Otsu ties: any threshold in an empty gap of the histogram yields the same + * between-class variance. Fiji picks the lowest one (20 for the grey levels + * 10, 20 and 200; 2040 for the 16-bit image). + */ + @Test + public void testOtsuTies() { + assertThresholdValue(bytes(gaps()), "threshold.otsu", 20); + assertThresholdValue(shorts(blobs(3, 1200, 150, 3000, 300, 0, 65535, + false)), "threshold.otsu", 2040); + } + + static > void assertThresholdValue(final Img img, + final String op, final double expected) + { + final var histogram = Thresholds.histogram(img, null); + final var threshold = img.firstElement().createVariable(); + ops.op(op).input(histogram).output(threshold).compute(); + assertEquals(expected, threshold.getRealDouble(), 0, op); + } + + static int[] gaps() { + final var px = new int[W * H]; + for (var i = 0; i < px.length; i++) { + px[i] = i % 7 == 0 ? 200 : i % 2 == 0 ? 10 : 20; + } + return px; + } + + private > void assertFiji(final Img img, + final int... expected) + { + final List checks = new ArrayList<>(); + for (var i = 0; i < OPS.length; i++) { + final var op = "threshold." + OPS[i]; + final var t = expected[i]; + checks.add(() -> assertMask(img, op, t)); + } + assertAll(checks); + } + + private > void assertFiji(final Img img, + final Boolean ignoreBlack, final Boolean ignoreWhite, final int... expected) + { + final List checks = new ArrayList<>(); + for (var i = 0; i < OPS.length; i++) { + final var op = "threshold." + OPS[i]; + final var t = expected[i]; + checks.add(() -> assertMask(img, op, t, ignoreBlack, ignoreWhite)); + } + assertAll(checks); + } + + static > void assertMask(final Img img, + final String op, final double threshold, final Object... args) + { + final Img actual = ArrayImgs.bits(W, H); + if (args.length == 0) ops.op(op).input(img).output(actual).compute(); + else ops.op(op).input(img, args[0], args[1]).output(actual).compute(); + final var mismatches = new long[1]; + LoopBuilder.setImages(img, actual).forEachPixel((p, b) -> { + if (b.get() != p.getRealDouble() > threshold) mismatches[0]++; + }); + assertEquals(0, mismatches[0], op + ": pixels differing from mask > " + + threshold); + } + + static Img blobs8() { + return bytes(blobs(1, 40, 12, 200, 15, 0, 255, true)); + } + + static Img bytes(final int[] px) { + final var img = ArrayImgs.unsignedBytes(W, H); + var i = 0; + for (final var t : img) + t.set(px[i++]); + return img; + } + + static Img shorts(final int[] px) { + final var img = ArrayImgs.unsignedShorts(W, H); + var i = 0; + for (final var t : img) + t.set(px[i++]); + return img; + } + + /** + * Generates a deterministic test image: nine discs on a background, with + * Gaussian noise and soft edges, clamped to {@code [lo, hi]}. With zero + * noise, the image has exactly two grey levels. + */ + static int[] blobs(final long seed, final double bg, final double bgSd, + final double fg, final double fgSd, final int lo, final int hi, + final boolean extremes) + { + final var r = new Random(seed); + var mask = new double[W * H]; + for (var k = 0; k < 9; k++) { + final var cx = 6 + r.nextDouble() * (W - 12); + final var cy = 6 + r.nextDouble() * (H - 12); + final var rad = 3 + r.nextDouble() * 5; + for (var y = 0; y < H; y++) { + for (var x = 0; x < W; x++) { + if ((x - cx) * (x - cx) + (y - cy) * (y - cy) <= rad * rad) { + mask[y * W + x] = 1; + } + } + } + } + if (bgSd != 0 || fgSd != 0) { + final var b = new double[W * H]; + for (var y = 0; y < H; y++) { + for (var x = 0; x < W; x++) { + double s = 0; + var n = 0; + for (var dy = -1; dy <= 1; dy++) { + for (var dx = -1; dx <= 1; dx++) { + final var xx = x + dx; + final var yy = y + dy; + if (xx < 0 || yy < 0 || xx >= W || yy >= H) continue; + s += mask[yy * W + xx]; + n++; + } + } + b[y * W + x] = s / n; + } + } + mask = b; + } + final var px = new int[W * H]; + for (var i = 0; i < px.length; i++) { + final var m = mask[i]; + final var v = bg * (1 - m) + fg * m + r.nextGaussian() * (bgSd * (1 - + m) + fgSd * m); + px[i] = (int) Math.max(lo, Math.min(hi, Math.round(v))); + } + if (extremes) { + for (var k = 0; k < 15; k++) { + px[r.nextInt(px.length)] = 0; + px[r.nextInt(px.length)] = 255; + } + } + return px; + } +} diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/threshold/ComputeThresholdTest.java b/scijava-ops-image/src/test/java/org/scijava/ops/image/threshold/ComputeThresholdTest.java index aa852b9e1..91f8c3f29 100644 --- a/scijava-ops-image/src/test/java/org/scijava/ops/image/threshold/ComputeThresholdTest.java +++ b/scijava-ops-image/src/test/java/org/scijava/ops/image/threshold/ComputeThresholdTest.java @@ -53,6 +53,15 @@ public void testHuang() { testComputeThresholdOp(36874, "threshold.huang"); } + /** + * Tests {@link org.scijava.ops.image.threshold.huang2.ComputeHuang2Threshold}. + */ + @Test + public void testHuang2() { + // Note: Fiji Auto_Threshold Huang and Huang2 agree on this histogram. + testComputeThresholdOp(36874, "threshold.huang2"); + } + /** Tests {@link org.scijava.ops.image.threshold.ij1.ComputeIJ1Threshold}. */ @Test public void testIJ1() { @@ -141,7 +150,8 @@ public void testMoments() { */ @Test public void testOtsu() { - testComputeThresholdOp(34103, "threshold.otsu"); + // Note: Fiji Auto_Threshold.Otsu on the same histogram yields bin 128. + testComputeThresholdOp(32591, "threshold.otsu"); } /** diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/threshold/LocalHistogramThresholdTest.java b/scijava-ops-image/src/test/java/org/scijava/ops/image/threshold/LocalHistogramThresholdTest.java new file mode 100644 index 000000000..7a9172102 --- /dev/null +++ b/scijava-ops-image/src/test/java/org/scijava/ops/image/threshold/LocalHistogramThresholdTest.java @@ -0,0 +1,119 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.threshold; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.Random; + +import net.imglib2.algorithm.neighborhood.HyperSphereShape; +import net.imglib2.histogram.Histogram1d; +import net.imglib2.histogram.Real1dBinMapper; +import net.imglib2.img.Img; +import net.imglib2.img.array.ArrayImgs; +import net.imglib2.type.logic.BitType; +import net.imglib2.type.numeric.integer.UnsignedShortType; +import net.imglib2.view.Views; + +import org.junit.jupiter.api.Test; +import org.scijava.ops.image.AbstractOpTest; + +/** + * Tests the histograms of the local histogram-based threshold methods (such as + * {@code threshold.otsu(image, shape)}). + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +public class LocalHistogramThresholdTest extends AbstractOpTest { + + /** + * Neighborhoods spanning at most 256 grey levels get one histogram bin per + * grey level, whatever the type, so a 16-bit image with an offset gives the + * same mask as the 8-bit image. + */ + @Test + public void testOneBinPerLevel() { + final var bytes = AutoThresholdParityTest.blobs8(); + final var shorts = ArrayImgs.unsignedShorts(bytes.dimensionsAsLongArray()); + final var c = bytes.cursor(); + for (final var t : shorts) + t.set(c.next().get() + 1000); + final var shape = new HyperSphereShape(3); + for (final var method : new String[] { "threshold.otsu", "threshold.huang", + "threshold.li" }) + { + final Img expected = ArrayImgs.bits(bytes + .dimensionsAsLongArray()); + ops.op(method).input(bytes, shape).output(expected).compute(); + final Img actual = ArrayImgs.bits(bytes.dimensionsAsLongArray()); + ops.op(method).input(shorts, shape).output(actual).compute(); + final var ec = expected.cursor(); + for (final var b : actual) + assertEquals(ec.next().get(), b.get(), method); + } + } + + /** + * Neighborhoods spanning more than 256 grey levels get 256 equal bins over + * their range. Before, a 16-bit neighborhood got one bin per grey level, up + * to 65536 bins per pixel, which made local thresholds of 16-bit images + * very slow. + */ + @Test + public void testWideRange() { + final var rnd = new Random(4); + final var img = ArrayImgs.unsignedShorts(96, 96); + img.forEach(t -> t.set(rnd.nextInt(65536))); + final var shape = new HyperSphereShape(2); + final Img out = ArrayImgs.bits(img.dimensionsAsLongArray()); + ops.op("threshold.otsu").input(img, shape).output(out).compute(); + + final var neighborhoods = shape.neighborhoodsRandomAccessible(Views + .extendBorder(img)).randomAccess(); + final var oc = out.localizingCursor(); + final var threshold = new UnsignedShortType(); + while (oc.hasNext()) { + final var b = oc.next().get(); + final var nb = neighborhoods.setPositionAndGet(oc); + var min = Double.POSITIVE_INFINITY; + var max = Double.NEGATIVE_INFINITY; + for (final var t : nb) { + min = Math.min(min, t.get()); + max = Math.max(max, t.get()); + } + final var histogram = new Histogram1d( + new Real1dBinMapper<>(min, max, 256, false)); + histogram.countData(nb); + ops.op("threshold.otsu").input(histogram).output(threshold).compute(); + assertEquals(img.getAt(oc).get() > threshold.get(), b); + } + } +} diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/threshold/apply/LocalThresholdTest.java b/scijava-ops-image/src/test/java/org/scijava/ops/image/threshold/apply/LocalThresholdTest.java index 34e7b9e05..9a89bd351 100644 --- a/scijava-ops-image/src/test/java/org/scijava/ops/image/threshold/apply/LocalThresholdTest.java +++ b/scijava-ops-image/src/test/java/org/scijava/ops/image/threshold/apply/LocalThresholdTest.java @@ -58,6 +58,7 @@ import org.scijava.ops.image.util.TestImgGeneration; import net.imglib2.Cursor; import net.imglib2.RandomAccessibleInterval; +import net.imglib2.algorithm.neighborhood.CenteredRectangleShape; import net.imglib2.algorithm.neighborhood.RectangleShape; import net.imglib2.algorithm.neighborhood.Shape; import net.imglib2.img.Img; @@ -319,7 +320,9 @@ public void testLocalContrastThreshold() { new OutOfBoundsMirrorFactory>( Boundary.SINGLE), out); - assertEquals(false, out.firstElement().get()); + // Note: The first pixel is closer to its local maximum than to its local + // minimum, so it is foreground. + assertEquals(true, out.firstElement().get()); } /** @@ -831,6 +834,108 @@ public void testLocalPhansalkar() { assertEquals(true, out.firstElement().get()); } + /** + * Tests that the local threshold ops give the same masks when they compute + * rectangular neighborhoods via integral images as when they compute them + * directly. + */ + @Test + public void testIntegralMatchesNonIntegral() { + // Note: A CenteredRectangleShape yields the same neighborhoods as the + // RectangleShape, but is not computed via integral images. + final Shape integral = new RectangleShape(3, false); + final Shape direct = new CenteredRectangleShape(new int[] { 3, 3 }, false); + final var oob = + new OutOfBoundsMirrorFactory>( + Boundary.SINGLE); + for (final var name : new String[] { "localMean", "localNiblack", + "localSauvola" }) + { + final Img expected = ArrayImgs.bits(in.dimensionsAsLongArray()); + final Img actual = ArrayImgs.bits(in.dimensionsAsLongArray()); + computeLocal(name, direct, oob, expected); + computeLocal(name, integral, oob, actual); + testRandomAccessibleInterval(expected, actual); + } + } + + private void computeLocal(final String name, final Shape shape, + final OutOfBoundsFactory> oob, + final Img output) + { + final var op = "threshold." + name; + switch (name) { + case "localMean": + ops.op(op).input(in, shape, 0.0, oob).output(output).compute(); + break; + case "localNiblack": + ops.op(op).input(in, shape, 0.0, 0.2, oob).output(output).compute(); + break; + default: + ops.op(op).input(in, shape, 0.5, 128.0, oob).output(output).compute(); + } + } + + /** + * Tests that {@code threshold.localPhansalkar} stretches its input to [0, 1], + * so that a raw image and its normalized copy (stretched to the 256 grey + * levels of ByteType and rounded down, as ImageJ does) give the same mask, + * with neighborhoods computed directly or via integral images; and that + * {@code normalize = false} uses the values as they are. + */ + @Test + public void testLocalPhansalkarStretchesInput() { + final var shape = new RectangleShape(3, false); + final var oob = + new OutOfBoundsMirrorFactory>( + Boundary.SINGLE); + ops.op("threshold.localPhansalkar").input(in, shape, 0.25, 0.5, oob) + .output(out).compute(); + + var min = Double.POSITIVE_INFINITY; + var max = Double.NEGATIVE_INFINITY; + for (final var t : in) { + min = Math.min(min, t.get()); + max = Math.max(max, t.get()); + } + final var quantized = ArrayImgs.doubles(in.dimensionsAsLongArray()); + final var c = in.cursor(); + for (final var t : quantized) + t.set(Math.floor((c.next().get() - min) / (max - min) * 255) / 255); + final var doubleOob = + new OutOfBoundsMirrorFactory>( + Boundary.SINGLE); + final Img expected = ArrayImgs.bits(in.dimensionsAsLongArray()); + ops.op("threshold.localPhansalkar").input(quantized, shape, 0.25, 0.5, + doubleOob, false).output(expected).compute(); + testRandomAccessibleInterval(expected, out); + + // Note: A CenteredRectangleShape yields the same neighborhoods as the + // RectangleShape, but is not computed via integral images. + final Img nonIntegral = ArrayImgs.bits(in.dimensionsAsLongArray()); + ops.op("threshold.localPhansalkar").input(in, new CenteredRectangleShape( + new int[] { 3, 3 }, false), 0.25, 0.5, oob).output(nonIntegral) + .compute(); + testRandomAccessibleInterval(expected, nonIntegral); + + // Floating-point images are stretched exactly. + final Img stretched = ArrayImgs.bits(in.dimensionsAsLongArray()); + ops.op("threshold.localPhansalkar").input(normalizedIn, shape, 0.25, 0.5, + doubleOob).output(stretched).compute(); + final var scaled = ArrayImgs.doubles(in.dimensionsAsLongArray()); + final var nc = normalizedIn.cursor(); + for (final var t : scaled) + t.set(nc.next().get() * 3 - 7); + final Img scaledOut = ArrayImgs.bits(in.dimensionsAsLongArray()); + ops.op("threshold.localPhansalkar").input(scaled, shape, 0.25, 0.5, + doubleOob).output(scaledOut).compute(); + testRandomAccessibleInterval(stretched, scaledOut); + final Img raw = ArrayImgs.bits(in.dimensionsAsLongArray()); + ops.op("threshold.localPhansalkar").input(normalizedIn, shape, 0.25, 0.5, + doubleOob, false).output(raw).compute(); + testRandomAccessibleInterval(stretched, raw); + } + // /** // * @see LocalPhansalkarThresholdIntegral // */ @@ -921,7 +1026,23 @@ public void testLocalSauvola() { opToTest.compute(in, new RectangleShape(2, false), out); - assertEquals(true, out.firstElement().get()); + // Note: the defaults are k = 0.5 and r = 128 (half the range of ByteType), + // and the 5x5 neighborhood of the first pixel is extended at the border. + final var ra = Views.extendBorder(in).randomAccess(); + var sum = 0.0; + var sum2 = 0.0; + for (var y = -2; y <= 2; y++) { + for (var x = -2; x <= 2; x++) { + final double v = ra.setPositionAndGet(x, y).get(); + sum += v; + sum2 += v * v; + } + } + final var mean = sum / 25; + final var sd = Math.sqrt(sum2 / 25 - mean * mean); + final var expected = in.firstElement().get() > mean * (1 + 0.5 * (sd / + 128 - 1)); + assertEquals(expected, out.firstElement().get()); } // /** diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/threshold/hysteresis/DefaultHysteresisThresholdTest.java b/scijava-ops-image/src/test/java/org/scijava/ops/image/threshold/hysteresis/DefaultHysteresisThresholdTest.java new file mode 100644 index 000000000..01688602e --- /dev/null +++ b/scijava-ops-image/src/test/java/org/scijava/ops/image/threshold/hysteresis/DefaultHysteresisThresholdTest.java @@ -0,0 +1,211 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.threshold.hysteresis; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Random; + +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.algorithm.neighborhood.DiamondShape; +import net.imglib2.algorithm.neighborhood.Shape; +import net.imglib2.img.array.ArrayImg; +import net.imglib2.img.array.ArrayImgs; +import net.imglib2.img.basictypeaccess.array.ByteArray; +import net.imglib2.type.logic.BitType; +import net.imglib2.type.numeric.integer.UnsignedByteType; +import net.imglib2.view.Views; + +import org.junit.jupiter.api.Test; +import org.scijava.ops.image.AbstractOpTest; +import org.scijava.types.Nil; + +/** + * Tests {@link DefaultHysteresisThreshold}. + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +public class DefaultHysteresisThresholdTest extends AbstractOpTest { + + private static final Nil> BITS = + new Nil<>() + {}; + + @Test + public void test1D() { + final var in = ArrayImgs.unsignedBytes(new byte[] { // + 0, 5, 5, 10, 5, 0, 5, 6, 5, 0, 12, 0, 4, 5 }, 14); + final var out = ops.op("threshold.hysteresis").input(in, 5.0, 10.0) + .outType(BITS).apply(); + assertBits(out, 0, 1, 1, 1, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0); + } + + @Test + public void testConnectivity() { + // A diagonal chain of weak pixels from a strong pixel. + final var in = ArrayImgs.unsignedBytes(new byte[] { // + 20, 0, 0, // + 0, 8, 0, // + 0, 0, 8 }, 3, 3); + final var eight = ops.op("threshold.hysteresis").input(in, 5.0, 10.0) + .outType(BITS).apply(); + assertBits(eight, 1, 0, 0, 0, 1, 0, 0, 0, 1); + final var four = ops.op("threshold.hysteresis").input(in, 5.0, 10.0, + new DiamondShape(1)).outType(BITS).apply(); + assertBits(four, 1, 0, 0, 0, 0, 0, 0, 0, 0); + } + + @Test + public void testEqualThresholdsIsPlainThreshold() { + final var in = randomImage(new Random(3), 9, 7, 5); + final var out = ops.op("threshold.hysteresis").input(in, 100.0, 100.0) + .outType(BITS).apply(); + final var c = in.cursor(); + final var oc = out.cursor(); + while (c.hasNext()) + assertEquals(c.next().get() >= 100, oc.next().get()); + } + + /** Compares against a naive fixed-point iteration on random 3D images. */ + @Test + public void testRandomAgainstReference() { + final var rnd = new Random(42); + for (final Shape shape : new Shape[] { null, new DiamondShape(1) }) { + final var in = randomImage(rnd, 12, 10, 6); + final var out = ops.op("threshold.hysteresis").input(in, 230.0, 250.0, + shape).outType(BITS).apply(); + final var expected = reference(in, 230, 250, shape != null); + final var oc = out.cursor(); + var i = 0; + var fg = 0; + var weakOnly = 0; + final var ic = in.cursor(); + while (oc.hasNext()) { + final var b = oc.next().get(); + final var v = ic.next().get(); + assertEquals(expected[i++], b); + if (b) fg++; + if (!b && v >= 230) weakOnly++; + } + // Make sure the test is not trivial. + assertTrue(fg > 10, "fg=" + fg); + assertTrue(weakOnly > 10, "weakOnly=" + weakOnly); + } + } + + @Test + public void testLowAboveHighFails() { + final var in = ArrayImgs.unsignedBytes(3); + assertThrows(RuntimeException.class, () -> ops.op("threshold.hysteresis") + .input(in, 10.0, 5.0).outType(BITS).apply()); + } + + private static ArrayImg randomImage( + final Random rnd, final long... dims) + { + final var img = ArrayImgs.unsignedBytes(dims); + img.forEach(t -> t.set(rnd.nextInt(256))); + return img; + } + + private static boolean[] reference( + final ArrayImg in, final int low, + final int high, final boolean faces) + { + final long[] dims = in.dimensionsAsLongArray(); + final int w = (int) dims[0], h = (int) dims[1], dd = (int) dims[2]; + final var v = new int[w * h * dd]; + var i = 0; + for (final var t : in) + v[i++] = t.get(); + final var on = new boolean[v.length]; + for (i = 0; i < v.length; i++) + on[i] = v[i] >= high; + var changed = true; + while (changed) { + changed = false; + for (var z = 0; z < dd; z++) + for (var y = 0; y < h; y++) + for (var x = 0; x < w; x++) { + final var idx = x + w * (y + h * z); + if (on[idx] || v[idx] < low) continue; + for (var dz = -1; dz <= 1 && !on[idx]; dz++) + for (var dy = -1; dy <= 1 && !on[idx]; dy++) + for (var dx = -1; dx <= 1 && !on[idx]; dx++) { + if (faces && Math.abs(dx) + Math.abs(dy) + Math.abs(dz) != 1) + continue; + final int xx = x + dx, yy = y + dy, zz = z + dz; + if (xx < 0 || yy < 0 || zz < 0 || xx >= w || yy >= h || + zz >= dd) continue; + if (on[xx + w * (yy + h * zz)]) { + on[idx] = true; + changed = true; + } + } + } + } + return on; + } + + /** A translated input gives the same mask, at the translated coordinates. */ + @Test + public void testTranslatedInput() { + final var in = randomImage(new Random(8), 9, 7, 5); + final var expected = ops.op("threshold.hysteresis").input(in, 80.0, 160.0) + .outType(BITS).apply(); + final var out = ops.op("threshold.hysteresis").input(Views.translate(in, 4, + -2, 6), 80.0, 160.0).outType(BITS).apply(); + assertArrayEquals(new long[] { 4, -2, 6 }, out.minAsLongArray()); + final var ec = Views.flatIterable(expected).cursor(); + for (final var b : Views.flatIterable(out)) + assertEquals(ec.next().get(), b.get()); + } + + private static void assertBits(final RandomAccessibleInterval out, + final int... expected) + { + var i = 0; + for (final var b : out) + assertEquals(expected[i++] == 1, b.get(), "index " + (i - 1)); + } + + /** NaN pixels are never foreground and do not connect their neighbors. */ + @Test + public void testNaN() { + final var in = ArrayImgs.floats(new float[] { 20, 8, Float.NaN, 8, 0 }, 5); + final var out = ops.op("threshold.hysteresis").input(in, 5.0, 10.0) + .outType(BITS).apply(); + assertBits(out, 1, 1, 0, 0, 0); + } +} diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/threshold/rats/RatsTest.java b/scijava-ops-image/src/test/java/org/scijava/ops/image/threshold/rats/RatsTest.java new file mode 100644 index 000000000..e502c0816 --- /dev/null +++ b/scijava-ops-image/src/test/java/org/scijava/ops/image/threshold/rats/RatsTest.java @@ -0,0 +1,230 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.threshold.rats; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Random; + +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.img.array.ArrayImgs; +import net.imglib2.type.logic.BitType; +import net.imglib2.type.numeric.real.FloatType; +import net.imglib2.view.Views; + +import org.junit.jupiter.api.Test; +import org.scijava.ops.image.AbstractOpTest; +import org.scijava.types.Nil; + +/** + * Tests {@link Rats} and {@link RatsSurface}. + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +public class RatsTest extends AbstractOpTest { + + private static final Nil> BITS = + new Nil<>() + {}; + + private static final Nil> FLOATS = + new Nil<>() + {}; + + /** A step edge gives the mid-level as threshold everywhere. */ + @Test + public void testStepEdge() { + final var in = ArrayImgs.unsignedBytes(64, 40); + final var c = in.localizingCursor(); + while (c.hasNext()) + c.next().set(c.getIntPosition(0) < 32 ? 10 : 90); + final var surface = ops.op("threshold.ratsSurface").input(in, 1.0, 3.0, + 16).outType(FLOATS).apply(); + assertArrayEquals(new long[] { 64, 40 }, surface.dimensionsAsLongArray()); + for (final var t : surface) + assertEquals(50, t.get(), 1e-5); + final var out = ops.op("threshold.rats").input(in, 1.0, 3.0, 16).outType( + BITS).apply(); + final var oc = out.localizingCursor(); + while (oc.hasNext()) { + final var b = oc.next().get(); + assertEquals(oc.getIntPosition(0) >= 32, b); + } + } + + @Test + public void testStepEdge3D() { + final var in = ArrayImgs.floats(12, 10, 20); + final var c = in.localizingCursor(); + while (c.hasNext()) + c.next().set(c.getIntPosition(2) < 7 ? 100 : 40); + final var out = ops.op("threshold.rats").input(in, 0.5, 3.0, 4).outType( + BITS).apply(); + final var oc = out.localizingCursor(); + while (oc.hasNext()) { + final var b = oc.next().get(); + assertEquals(oc.getIntPosition(2) < 7, b); + } + } + + @Test + public void testFlatImage() { + final var in = ArrayImgs.floats(20, 10, 3); + in.forEach(t -> t.set(5)); + final var surface = ops.op("threshold.ratsSurface").input(in).outType( + FLOATS).apply(); + for (final var t : surface) + assertEquals(5, t.get(), 0); + final var out = ops.op("threshold.rats").input(in).outType(BITS).apply(); + for (final var b : out) + assertFalse(b.get()); + } + + /** + * Bright disks on a strong illumination ramp, with noise: no global + * threshold separates them (the background on the right is brighter than + * the disks on the left), but RATS does. + */ + @Test + public void testUnevenIllumination() { + final int w = 128, h = 64; + final var rnd = new Random(17); + final var in = ArrayImgs.floats(w, h); + final var truth = new boolean[w * h]; + final int[][] disks = { { 16, 16 }, { 48, 40 }, { 80, 20 }, { 112, 44 }, + { 20, 48 }, { 100, 12 } }; + final var c = in.localizingCursor(); + var i = 0; + while (c.hasNext()) { + c.fwd(); + final int x = c.getIntPosition(0), y = c.getIntPosition(1); + var inside = false; + for (final var d : disks) + inside |= (x - d[0]) * (x - d[0]) + (y - d[1]) * (y - d[1]) <= 36; + truth[i++] = inside; + c.get().set((float) (20 + 0.6 * x + (inside ? 60 : 0) + 3 * rnd + .nextGaussian())); + } + // Sanity check: the darkest disk is darker than the brightest + // background. + assertTrue(20 + 0.6 * 10 + 60 < 20 + 0.6 * 127); + + for (final Double sigma : new Double[] { 3.0, null }) { + final var out = ops.op("threshold.rats").input(in, sigma, 3.0, 16) + .outType(BITS).apply(); + var errors = 0; + i = 0; + for (final var b : out) + if (b.get() != truth[i++]) errors++; + assertTrue(errors < 0.02 * w * h, "sigma=" + sigma + ": " + errors + + " errors"); + } + } + + /** + * A quantized, low-noise 8-bit image: a background of 10 with about 10% of + * the pixels at 11, and a bright disk. More than half of the horizontal + * differences are 0, so the median absolute deviation of the differences is + * 0. Before the noise estimate was floored at the quantization noise, sigma + * was 0 and every isolated 11 became foreground speckle. + */ + @Test + public void testQuantizedLowNoise() { + final int w = 64, h = 64; + final var rnd = new Random(5); + final var in = ArrayImgs.unsignedBytes(w, h); + final var truth = new boolean[w * h]; + final var c = in.localizingCursor(); + var i = 0; + while (c.hasNext()) { + c.fwd(); + final int x = c.getIntPosition(0) - 32, y = c.getIntPosition(1) - 32; + final var inside = x * x + y * y <= 100; + truth[i++] = inside; + c.get().set(inside ? 100 : rnd.nextDouble() < 0.1 ? 11 : 10); + } + final var out = ops.op("threshold.rats").input(in, null, 3.0, 16) + .outType(BITS).apply(); + var errors = 0; + i = 0; + for (final var b : out) + if (b.get() != truth[i++]) errors++; + assertEquals(0, errors); + } + + /** Inputs with a non-zero min give results at the same coordinates. */ + @Test + public void testTranslatedInput() { + final var in = ArrayImgs.unsignedBytes(64, 40); + final var c = in.localizingCursor(); + while (c.hasNext()) + c.next().set(c.getIntPosition(0) < 20 ? 10 : 90); + final var translated = Views.translate(in, 7, -3); + final var surface = ops.op("threshold.ratsSurface").input(translated, 1.0, + 3.0, 16).outType(FLOATS).apply(); + assertArrayEquals(new long[] { 7, -3 }, surface.minAsLongArray()); + assertArrayEquals(new long[] { 70, 36 }, surface.maxAsLongArray()); + final var out = ops.op("threshold.rats").input(translated, 1.0, 3.0, 16) + .outType(BITS).apply(); + assertArrayEquals(new long[] { 7, -3 }, out.minAsLongArray()); + final var oc = out.localizingCursor(); + final var ra = translated.randomAccess(); + while (oc.hasNext()) { + final var b = oc.next().get(); + assertEquals(ra.setPositionAndGet(oc).get() == 90, b); + } + } + + /** NaN pixels are ignored and do not spoil the other pixels. */ + @Test + public void testNaN() { + final var rnd = new Random(9); + final var in = ArrayImgs.floats(64, 40); + final var c = in.localizingCursor(); + while (c.hasNext()) { + c.fwd(); + c.get().set(rnd.nextDouble() < 0.02 ? Float.NaN : c.getIntPosition( + 0) < 32 ? 10 : 90); + } + final var out = ops.op("threshold.rats").input(in).outType(BITS) + .apply(); + final var oc = out.localizingCursor(); + final var ic = in.cursor(); + while (oc.hasNext()) { + final var b = oc.next().get(); + final var v = ic.next().get(); + assertEquals(!Float.isNaN(v) && oc.getIntPosition(0) >= 32, b); + } + } +} diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/transform/bin/BinTest.java b/scijava-ops-image/src/test/java/org/scijava/ops/image/transform/bin/BinTest.java new file mode 100644 index 000000000..02b97ca21 --- /dev/null +++ b/scijava-ops-image/src/test/java/org/scijava/ops/image/transform/bin/BinTest.java @@ -0,0 +1,177 @@ +/*- + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.transform.bin; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.img.array.ArrayImgs; +import net.imglib2.type.numeric.integer.UnsignedByteType; +import net.imglib2.type.numeric.real.DoubleType; +import net.imglib2.type.numeric.real.FloatType; +import net.imglib2.view.Views; + +import org.junit.jupiter.api.Test; +import org.scijava.ops.image.AbstractOpTest; +import org.scijava.types.Nil; + +/** + * Tests {@link DefaultBin} and {@link DefaultBinFunction}. + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +public class BinTest extends AbstractOpTest { + + // This is a 5x4 image whose last column is dropped when binning by 2 in x. + private static final double[] DATA = { // + 1, 2, 3, 4, 99, // + 5, 6, 7, 1, 99, // + 1, 1, 9, 9, 99, // + 1, 3, 9, 2, 99 // + }; + + private RandomAccessibleInterval binned(final String method) { + final var img = ArrayImgs.floats(toFloats(DATA), 5, 4); + return ops.op("transform.bin").input(img, new long[] { 2, 2 }, method) + .outType(new Nil>() + {}).apply(); + } + + @Test + public void testMethods() { + // The blocks are {1,2,5,6}, {3,4,7,1}, {1,1,1,3} and {9,9,9,2}. + assertArrayEquals(new double[] { 3.5, 3.75, 1.5, 7.25 }, asArray(binned( + "mean")), 0); + assertArrayEquals(new double[] { 3.5, 3.75, 1.5, 7.25 }, asArray(binned( + null)), 0); + assertArrayEquals(new double[] { 14, 15, 6, 29 }, asArray(binned("sum")), + 0); + assertArrayEquals(new double[] { 1, 1, 1, 2 }, asArray(binned("min")), 0); + assertArrayEquals(new double[] { 6, 7, 3, 9 }, asArray(binned("max")), 0); + assertArrayEquals(new double[] { 3.5, 3.5, 1, 9 }, asArray(binned( + "median")), 0); + assertArrayEquals(new double[] { 1, 1, 1, 9 }, asArray(binned("mode")), 0); + } + + @Test + public void testDimensions() { + final var out = binned("max"); + assertArrayEquals(new long[] { 2, 2 }, out.dimensionsAsLongArray()); + assertArrayEquals(new long[] { 0, 0 }, out.minAsLongArray()); + } + + @Test + public void testPartialFactorsAndOffsetInput() { + // A 3D image with non-zero min is binned by 3 along x only. + final var img = ArrayImgs.doubles(6, 2, 2); + var i = 0; + for (final var t : img) + t.set(i++); + final var translated = Views.translate(img, 10, -4, 7); + final RandomAccessibleInterval out = ops.op("transform.bin") + .input(translated, new long[] { 3 }, "sum").outType( + new Nil>() + {}).apply(); + assertArrayEquals(new long[] { 2, 2, 2 }, out.dimensionsAsLongArray()); + // Row k holds 6k..6k+5, so the sums are 18k+3 and 18k+12. + assertArrayEquals(new double[] { 3, 12, 21, 30, 39, 48, 57, 66 }, asArray( + out), 0); + } + + @Test + public void testIntegerRoundingAndClamping() { + final var img = ArrayImgs.unsignedBytes(new byte[] { 1, 2, (byte) 200, + (byte) 100 }, 4, 1); + final RandomAccessibleInterval mean = ops.op( + "transform.bin").input(img, new long[] { 2, 1 }, "mean").outType( + new Nil>() + {}).apply(); + // 1.5 rounds half up to 2. + assertArrayEquals(new double[] { 2, 150 }, asArray(mean), 0); + final RandomAccessibleInterval sum = ops.op( + "transform.bin").input(img, new long[] { 2, 1 }, "sum").outType( + new Nil>() + {}).apply(); + // 300 saturates at 255. + assertArrayEquals(new double[] { 3, 255 }, asArray(sum), 0); + + // The computer allows a wider output type. + final var wide = ArrayImgs.doubles(2, 1); + ops.op("transform.bin").input(img, new long[] { 2, 1 }, "sum").output(wide) + .compute(); + assertArrayEquals(new double[] { 3, 300 }, asArray(wide), 0); + } + + @Test + public void testMeanPreservesTotal() { + // Binning by mean then multiplying by block size preserves the total. + final var img = ArrayImgs.doubles(8, 6); + var i = 0; + for (final var t : img) + t.set(Math.sin(i++)); + final RandomAccessibleInterval out = ops.op("transform.bin") + .input(img, new long[] { 4, 3 }, "mean").outType( + new Nil>() + {}).apply(); + double inSum = 0, outSum = 0; + for (final var t : img) + inSum += t.get(); + for (final var t : asArray(out)) + outSum += t; + assertEquals(inSum, outSum * 12, 1e-10); + } + + @Test + public void testInvalidArguments() { + final var img = ArrayImgs.floats(4, 4); + assertThrows(IllegalArgumentException.class, () -> ops.op("transform.bin") + .input(img, new long[] { 0, 1 }, "mean").outType( + new Nil>() + {}).apply()); + assertThrows(IllegalArgumentException.class, () -> ops.op("transform.bin") + .input(img, new long[] { 5, 1 }, "mean").outType( + new Nil>() + {}).apply()); + assertThrows(IllegalArgumentException.class, () -> ops.op("transform.bin") + .input(img, new long[] { 2, 2 }, "average!").outType( + new Nil>() + {}).apply()); + } + + private static float[] toFloats(final double[] values) { + final var f = new float[values.length]; + for (var i = 0; i < f.length; i++) + f[i] = (float) values[i]; + return f; + } +} diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/transform/focus/FocusStackTest.java b/scijava-ops-image/src/test/java/org/scijava/ops/image/transform/focus/FocusStackTest.java new file mode 100644 index 000000000..0ae707b09 --- /dev/null +++ b/scijava-ops-image/src/test/java/org/scijava/ops/image/transform/focus/FocusStackTest.java @@ -0,0 +1,188 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.transform.focus; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.algorithm.gauss3.Gauss3; +import net.imglib2.img.array.ArrayImgs; +import net.imglib2.type.numeric.integer.IntType; +import net.imglib2.type.numeric.real.FloatType; +import net.imglib2.view.Views; + +import org.junit.jupiter.api.Test; +import org.scijava.ops.image.AbstractOpTest; +import org.scijava.types.Nil; + +/** + * Tests {@link FocusStack} and {@link FocusStackIndex}. + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +public class FocusStackTest extends AbstractOpTest { + + private static final Nil> FLOATS = + new Nil<>() + {}; + + private static final Nil> INTS = + new Nil<>() + {}; + + private static final int W = 48, H = 24; + + /** A checkerboard texture of 2x2-pixel squares. */ + private static double sharp(final long x, final long y) { + return ((x / 2 + y / 2) % 2 == 0) ? 200 : 50; + } + + /** + * Two slices: slice 0 is sharp in the left half and blurred in the right + * half, slice 1 the other way around. The stack is laid out as (z, x, y) + * to test a focus dimension other than the last. + */ + private static RandomAccessibleInterval stack() { + final var texture = ArrayImgs.floats(W, H); + final var c = texture.localizingCursor(); + while (c.hasNext()) { + c.fwd(); + c.get().setReal(sharp(c.getLongPosition(0), c.getLongPosition(1))); + } + final var blurred = ArrayImgs.floats(W, H); + Gauss3.gauss(3, Views.extendBorder(texture), blurred); + final var stack = ArrayImgs.floats(2, W, H); + final var ra = stack.randomAccess(); + final var tr = texture.randomAccess(); + final var br = blurred.randomAccess(); + for (var y = 0; y < H; y++) + for (var x = 0; x < W; x++) { + final var s = tr.setPositionAndGet(x, y).get(); + final var b = br.setPositionAndGet(x, y).get(); + ra.setPositionAndGet(0, x, y).set(x < W / 2 ? s : b); + ra.setPositionAndGet(1, x, y).set(x < W / 2 ? b : s); + } + return stack; + } + + @Test + public void testMax() { + final var out = ops.op("transform.focusStack").input(stack(), 0).outType( + FLOATS).apply(); + assertArrayEquals(new long[] { W, H }, out.dimensionsAsLongArray()); + final var ra = out.randomAccess(); + for (var y = 0; y < H; y++) + for (var x = 0; x < W; x++) { + if (Math.abs(x - W / 2) < 4) continue; + assertEquals(sharp(x, y), ra.setPositionAndGet(x, y).get(), 1e-4, + "at " + x + "," + y); + } + final var index = ops.op("transform.focusStackIndex").input(stack(), 0) + .outType(INTS).apply(); + final var ir = index.randomAccess(); + for (var y = 0; y < H; y++) + for (var x = 0; x < W; x++) { + if (Math.abs(x - W / 2) < 4) continue; + assertEquals(x < W / 2 ? 0 : 1, ir.setPositionAndGet(x, y).get()); + } + } + + @Test + public void testWeighted() { + final var out = ops.op("transform.focusStack").input(stack(), 0, 2.0, + "weighted").outType(FLOATS).apply(); + final var ra = out.randomAccess(); + double err = 0, errBlur = 0; + final var st = stack().randomAccess(); + for (var y = 0; y < H; y++) + for (var x = 0; x < W; x++) { + if (Math.abs(x - W / 2) < 4) continue; + final var s = sharp(x, y); + err += Math.abs(ra.setPositionAndGet(x, y).get() - s); + errBlur += Math.abs(st.setPositionAndGet(x < W / 2 ? 1 : 0, x, y) + .get() - s); + } + assertTrue(err < 0.05 * errBlur, "error " + err + " vs " + errBlur); + } + + @Test + public void testConstantStack() { + final var in = ArrayImgs.unsignedBytes(5, 6, 3); + in.forEach(t -> t.set(77)); + for (final var method : new String[] { "max", "weighted" }) { + final var out = ops.op("transform.focusStack").input(in, 2, 1.0, method) + .outType(FLOATS).apply(); + assertArrayEquals(new long[] { 5, 6 }, out.dimensionsAsLongArray()); + for (final var t : out) + assertEquals(77, t.get(), 1e-6); + } + } + + /** + * A translated stack gives the results of the original stack, at the + * translated coordinates (without the focus dimension). + */ + @Test + public void testTranslatedInput() { + final var translated = Views.translate(stack(), 3, -5, 7); + for (final var method : new String[] { "max", "weighted" }) { + final var expected = ops.op("transform.focusStack").input(stack(), 0, + 1.0, method).outType(FLOATS).apply(); + final var out = ops.op("transform.focusStack").input(translated, 0, 1.0, + method).outType(FLOATS).apply(); + assertArrayEquals(new long[] { -5, 7 }, out.minAsLongArray()); + assertArrayEquals(new long[] { W - 6, H + 6 }, out.maxAsLongArray()); + final var er = expected.randomAccess(); + final var oc = out.localizingCursor(); + while (oc.hasNext()) { + final var v = oc.next().get(); + er.setPosition(new long[] { oc.getLongPosition(0) + 5, oc + .getLongPosition(1) - 7 }); + assertEquals(er.get().get(), v, 1e-5, method); + } + } + final var expectedIndex = ops.op("transform.focusStackIndex").input( + stack(), 0).outType(INTS).apply(); + final var index = ops.op("transform.focusStackIndex").input(translated, 0) + .outType(INTS).apply(); + assertArrayEquals(new long[] { -5, 7 }, index.minAsLongArray()); + final var er = expectedIndex.randomAccess(); + final var ic = index.localizingCursor(); + while (ic.hasNext()) { + final var v = ic.next().get(); + er.setPosition(new long[] { ic.getLongPosition(0) + 5, ic + .getLongPosition(1) - 7 }); + assertEquals(er.get().get(), v); + } + } +} diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/transform/hough/HoughCircleTransformTest.java b/scijava-ops-image/src/test/java/org/scijava/ops/image/transform/hough/HoughCircleTransformTest.java new file mode 100644 index 000000000..921567a45 --- /dev/null +++ b/scijava-ops-image/src/test/java/org/scijava/ops/image/transform/hough/HoughCircleTransformTest.java @@ -0,0 +1,166 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.transform.hough; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.img.array.ArrayImgs; +import net.imglib2.type.logic.BitType; +import net.imglib2.type.numeric.real.DoubleType; +import net.imglib2.util.Intervals; +import net.imglib2.view.Views; + +import org.junit.jupiter.api.Test; +import org.scijava.ops.image.AbstractOpTest; +import org.scijava.types.Nil; + +/** + * Tests {@link HoughCircleTransform}. + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +public class HoughCircleTransformTest extends AbstractOpTest { + + private static final Nil> VOTES = + new Nil<>() + {}; + + /** + * Draws the discrete sphere (offsets whose norm rounds to the radius) into + * the image, and returns its number of pixels. + */ + static int drawSphere(final RandomAccessibleInterval img, + final long[] center, final double radius) + { + var count = 0; + final var c = Views.iterable(img).localizingCursor(); + while (c.hasNext()) { + c.fwd(); + var r2 = 0.0; + for (var d = 0; d < center.length; d++) { + final var dx = c.getDoublePosition(d) - center[d]; + r2 += dx * dx; + } + if (Math.round(Math.sqrt(r2)) == Math.round(radius)) { + c.get().set(true); + count++; + } + } + return count; + } + + private RandomAccessibleInterval hough( + final RandomAccessibleInterval img, final int minRadius, + final int maxRadius, final Integer stepRadius) + { + return ops.op("transform.houghCircle") // + .input(img, minRadius, maxRadius, stepRadius) // + .outType(VOTES).apply(); + } + + private static double at(final RandomAccessibleInterval votes, + final long... pos) + { + return votes.getAt(pos).get(); + } + + /** A single pixel votes once at every point of each sphere around it. */ + @Test + public void testSinglePixel() { + final var img = ArrayImgs.bits(41, 41); + img.getAt(20, 20).set(true); + final var votes = hough(img, 3, 11, 4); + assertArrayEquals(new long[] { 41, 41, 3 }, votes.dimensionsAsLongArray()); + for (var i = 0; i < 3; i++) { + final var r = 3 + 4 * i; + final var slice = Views.hyperSlice(votes, 2, i); + final var expected = drawSphere(ArrayImgs.bits(41, 41), new long[] { 20, + 20 }, r); + var sum = 0.0; + for (final var v : Views.iterable(slice)) + sum += v.get(); + assertEquals(expected, sum, 0); + assertEquals(1, at(votes, 20 + r, 20, i), 0); + assertEquals(1, at(votes, 20, 20 - r, i), 0); + assertEquals(0, at(votes, 20, 20, i), 0); + } + } + + /** A ring gets all its votes at its center and true radius. */ + @Test + public void testRing() { + final var img = ArrayImgs.bits(51, 45); + final var n = drawSphere(img, new long[] { 25, 21 }, 10); + final var votes = hough(img, 6, 14, null); + assertEquals(9, votes.dimension(2)); + assertEquals(n, at(votes, 25, 21, 4), 0); + for (final var v : Views.iterable(votes)) + assertEquals(true, v.get() <= n); + // The circumference is close to 2 pi r. + assertEquals(2 * Math.PI * 10, n, 0.15 * 2 * Math.PI * 10); + } + + /** The spatial dimensions of the vote image match the input interval. */ + @Test + public void testOffsetInput() { + final var img = ArrayImgs.bits(31, 31); + drawSphere(img, new long[] { 15, 15 }, 7); + final var shifted = Views.translate(img, -5, 12); + final var votes = hough(shifted, 5, 9, null); + assertArrayEquals(new long[] { -5, 12, 0 }, Intervals.minAsLongArray( + votes)); + assertEquals(at(hough(img, 5, 9, null), 15, 15, 2), at(votes, 10, 27, 2), + 0); + } + + /** Spheres are detected in 3D too. */ + @Test + public void testSphere3D() { + final var img = ArrayImgs.bits(21, 21, 21); + final var n = drawSphere(img, new long[] { 10, 9, 11 }, 6); + final var votes = hough(img, 4, 8, null); + assertEquals(n, at(votes, 10, 9, 11, 2), 0); + for (final var v : Views.iterable(votes)) + assertEquals(true, v.get() <= n); + } + + @Test + public void testInvalidRadii() { + final var img = ArrayImgs.bits(10, 10); + assertThrows(IllegalArgumentException.class, () -> hough(img, 0, 5, + null)); + assertThrows(IllegalArgumentException.class, () -> hough(img, 5, 4, + null)); + assertThrows(IllegalArgumentException.class, () -> hough(img, 1, 4, 0)); + } +} diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/util/CalibrationsTest.java b/scijava-ops-image/src/test/java/org/scijava/ops/image/util/CalibrationsTest.java new file mode 100644 index 000000000..256339712 --- /dev/null +++ b/scijava-ops-image/src/test/java/org/scijava/ops/image/util/CalibrationsTest.java @@ -0,0 +1,106 @@ +/* + * #%L + * Image processing operations for SciJava Ops. + * %% + * Copyright (C) 2014 - 2025 SciJava 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.scijava.ops.image.util; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import net.imglib2.RandomAccessibleInterval; +import net.imglib2.algorithm.neighborhood.DiamondShape; +import net.imglib2.img.array.ArrayImgs; +import net.imglib2.type.numeric.real.FloatType; + +import org.junit.jupiter.api.Test; +import org.scijava.ops.image.AbstractOpTest; +import org.scijava.types.Nil; + +/** + * Tests {@link Calibrations} and {@link Neighborhoods}. + * + * @author Curtis Rueden + * @author Claude Opus 5.5 + */ +public class CalibrationsTest extends AbstractOpTest { + + @Test + public void testSpacing() { + assertArrayEquals(new double[] { 1, 1, 1 }, Calibrations.spacing(null, 3, + "spacing")); + final var s = new double[] { 0.5, 2 }; + assertSame(s, Calibrations.spacing(s, 2, "spacing")); + assertThrows(IllegalArgumentException.class, () -> Calibrations.spacing( + new double[] { 1 }, 2, "spacing")); + for (final var bad : new double[] { 0, -1, Double.NaN, + Double.POSITIVE_INFINITY }) + { + assertThrows(IllegalArgumentException.class, () -> Calibrations.spacing( + new double[] { 1, bad }, 2, "spacing")); + } + } + + /** The ops sharing the helper reject a non-positive spacing. */ + @Test + public void testOpsRejectBadSpacing() { + final var mask = ArrayImgs.bits(5, 4); + mask.randomAccess().setPositionAndGet(2, 2).set(true); + assertThrows(IllegalArgumentException.class, () -> ops.op( + "image.signedDistanceTransform").input(mask, new double[] { 1, 0 }) + .outType(new Nil>() + {}).apply()); + } + + @Test + public void testNeighborOffsets() { + final var full = Neighborhoods.neighborOffsets(null, 2); + assertArrayEquals(new int[] { -1, -1 }, full[0]); + assertArrayEquals(new int[] { 1, 1 }, full[1]); + assertEquals(8, full.length); + final var face = Neighborhoods.neighborOffsets(new DiamondShape(1), 3); + assertEquals(6, face.length); + } + + @Test + public void testRasterOffsets() { + // Dimension 0 varies fastest, as in a flat iteration. + final var full = Neighborhoods.rasterOffsets(null, 2); + final int[][] expected = { { -1, -1 }, { 0, -1 }, { 1, -1 }, { -1, 0 }, { + 1, 0 }, { -1, 1 }, { 0, 1 }, { 1, 1 } }; + assertEquals(expected.length, full.length); + for (var i = 0; i < expected.length; i++) + assertArrayEquals(expected[i], full[i]); + final var face = Neighborhoods.rasterOffsets(new DiamondShape(1), 2); + final int[][] expectedFace = { { 0, -1 }, { -1, 0 }, { 1, 0 }, { 0, 1 } }; + assertEquals(expectedFace.length, face.length); + for (var i = 0; i < expectedFace.length; i++) + assertArrayEquals(expectedFace[i], face[i]); + } +} diff --git a/scijava-ops-image/src/test/resources/org/scijava/ops/image/segment/phantast/phantast-halo.txt b/scijava-ops-image/src/test/resources/org/scijava/ops/image/segment/phantast/phantast-halo.txt new file mode 100644 index 000000000..1a12eefbd --- /dev/null +++ b/scijava-ops-image/src/test/resources/org/scijava/ops/image/segment/phantast/phantast-halo.txt @@ -0,0 +1,64 @@ +................................................................ +................................................................ +................................................................ +................................................................ +.....................#.......................................... +...................................................###.......... +.................#.....#........................................ +.............#............#.........................##.......... +...................................................####......... +........#.#........###...........................##..####....... +...............###########.....#.................##..###........ +......#.......#############........................###.......... +............#################......#...............####......... +...........###################....#............................. +....#.....#####################.....................#........... +..........#####################....................#............ +.........#######################................................ +........#########################............................... +........#########################............................... +........#########################............................... +........#########################............................... +.......##########################............................... +........##########################...#.......................... +...##..##########################...#........................... +...###..#########################...##.......................... +....#...#########################............................... +...#....#########################...#........................... +........#########################............................... +.........#######################..............#................. +..........######################...#......#..................... +......#...####################............#.....#............... +...........####################........................#........ +............#################................................... +........#.....#############.......#..........#.#................ +...............###########.......##.......#########.....#....... +...................###..........##......############.......#.... +.................................##....###############....#..... +.............##............#..#..##..#################......#... +...................#........#....#...###################........ +....................................####################........ +....................##..............#####################....... +....................................#####################....... +....................................#####################...#... +...................................#######################.##... +....................................#########################... +...................................#######################.##... +....................................#####################...#... +....................................#####################....... +................................#...#####################....... +.....................................###################........ +......................................##################........ +......................................#################.....#... +........................................##############.......... +........................................############............ +....................................#.....#########............. +.............................................###................ +....................................#........................... +........................................#....................... +.......................................#.........##............. +.........................................#........#............. +................................................................ +................................................................ +................................................................ +................................................................ diff --git a/scijava-ops-image/src/test/resources/org/scijava/ops/image/segment/phantast/phantast-nohalo.txt b/scijava-ops-image/src/test/resources/org/scijava/ops/image/segment/phantast/phantast-nohalo.txt new file mode 100644 index 000000000..5cfe069a5 --- /dev/null +++ b/scijava-ops-image/src/test/resources/org/scijava/ops/image/segment/phantast/phantast-nohalo.txt @@ -0,0 +1,64 @@ +................................................................ +................................................................ +................................................................ +................................................................ +.....................#.......................................... +...............###########.........................###.......... +............#################.....................#####......... +...........###################...................#######........ +.........#######################................#########....... +........#########################...............#########....... +.......###########################..............#########....... +......#############################.............#########....... +......##############################............#########....... +.....###############################.............#######........ +....#################################.............#####......... +....#################################..............###.......... +....##################################.......................... +...###################################.......................... +...###################################.......................... +...###################################.......................... +...###################################.......................... +...###################################.......................... +...###################################.......................... +...###################################.......................... +...###################################.......................... +...###################################.......................... +...###################################.......................... +...###################################.......................... +....##################################.......##................. +....#################################....###########............ +....#################################..###############.......... +.....###############################.###################........ +......###################################################....... +......####################################################...... +.......####################################################..... +........####################################################.... +.........###################################################.... +...........####################.#############################... +............#################...#############################... +...............###########.....###############################.. +....................##.........###############################.. +...............................###############################.. +...............................###############################.. +...............................################################. +...............................################################. +...............................###############################.. +...............................###############################.. +...............................###############################.. +...............................###############################.. +...............................###############################.. +................................#############################... +................................#############################... +.................................###########################.... +.................................###########################.... +..................................#########################..... +...................................#######################...... +....................................#####################....... +.....................................###################........ +.......................................###############.......... +.........................................###########............ +.............................................##................. +................................................................ +................................................................ +................................................................ diff --git a/scijava-ops-image/src/test/resources/org/scijava/ops/image/threshold/fiji-local-masks.txt b/scijava-ops-image/src/test/resources/org/scijava/ops/image/threshold/fiji-local-masks.txt new file mode 100644 index 000000000..904177924 --- /dev/null +++ b/scijava-ops-image/src/test/resources/org/scijava/ops/image/threshold/fiji-local-masks.txt @@ -0,0 +1,36 @@ +# Masks of Fiji Auto_Local_Threshold 1.11.1-SNAPSHOT (ImageJ 1.54p), white objects, +# default parameters, on AutoThresholdParityTest images (64x64, row-major). +# Fiji was run with radius sqrt(r^2 - 0.5), whose circular kernel equals HyperSphereShape(r). +# Format: image method r mask, mask as hex digits of 4 pixels each (most significant first). +blobs8 Bernsen 3 d60099ccf28001b6b0432b7eae0240ff7610cfaa3983f0be96018a864b0ff83ba700dc00de1ffc3f142bb800d21dbc3faa18c000081e1c2711330070251e7c37c3fc42fc183ff835b7e983fe183ffc38dbc087ff087cf03227b0074f00ffc011d00e87d700fbc174c0040e1b8071c0e0a8420f7f00f7c05a020d0ff700ffc05bba010fbe007f802e823647fe103f85022bbb03fc1c1d06bc400801f81400003e8809800060000007238d10002a004004b805380494000601e170bb81c4000fc31de3b7d7383c1ff0995d9022b03f1ff0189fb059487f1f70a074c03430f71ff0a0b2001f987f1ff1c42c0f07a07f1fe160701f02383f0fc3e0c43f03441807c133cc3d847e080001d4083f0d624000069dda1f01cf00000f4bc40d072ea0000cb3190011dd4000447bb2400ffc87000537034013a60fc00f985d0115e01fe09b06fa04d2ad1fc45c01b62089ae8fc377203006ae9d1fc2c1064404b12d0f0101062818484f87040855dc82cb7b0000083767b0f0612000013f818d9aa0c00000d12018e400a007f03f607d6784220ffcaec43c9720a00ffc5ae0a00bc0003f7f56800001fbc87f8f4f73c0011420781ddc978382a6b07e5f26df83c00df47d11a41d87e2fff87a8738af87e10fc47f7f24d503c23f643ddfc149838233e43f7e652e5100ceba1ffca1e3a00e59bd0ff82c238007e4e100307263e494b0468080 +blobs8 Bernsen 7 c0001985f000000780402800a803c00e601000003003f00680000000100ff803a0000000101ff80730000000001ffc03a8000000001ffc0718000070001ffc03d00002fc001ff805300003fe003ffc02d00007ff007ff006200007ff00ffe001c00007ff80ffc000c0000fff80ffc00000400fff00ffe01a000007ff00ffe01b000007ff007f8006800007fe003f8002200003fc001d0000400001f8000000008000000000000000218d0000000000000285000000000600c060000000000fc000200000003e1fe0001c0000803f1ff000080001007f1ff00000000000ff1ff000000001807f9ff004000e00807f1fe000001f00403f0fc000003f80c01c030000003f806000000000003f802000000080301f80c0000000c0400d002400000003f000019800004001b00000b807800000000003a00fc00000500005e01fe00000700002a01fc00000202009a00fc0002030000ed01fc00000400031c00700000000004880020008000000cf0000000800000090000000000000001e0000000000000002000007e09a00000004020ff8ac04000000000ffcd800000000003fffd600000000003fff4e00000000007fffcc00038000007fff428007c000407fffa40007e00bc07fff18a007e00fc07fff24c007c01f403fff8348030013e03ffe65a600000e801ffca1e000005db00ff82ea30000fca007f00263000030d001a0 +blobs8 Contrast 3 d60099ccf2800196b0412b7eae02407f7610cfaa3983f0be96018a864307f83ba700dc005e1ffc3f142bb800521dbc3faa18c000081e1c2711330070251e7c37c3fc42fc083ff835b7e983fe183e7c38dbc087ff087cf03207b0074f00ffc011d00e87d700fbc174c0040e1b8071c0e0a8420f7f00f7c05a020d0ff700ffc05bba010fbe007f8026823647fe103f85020bbb03fc1c1d06bc400801f8140000368809800060000007238d10002a004004b805380494000600e170bb81c4000fc315e3b7d7383c1ff0995d9022b03f1ff0189bb059487f1f70a064c03430f71ff080b20017987f1ff1c42c0f07a07f1fe160401f02383f0fc3c0c43f03401807c133cc3d807e080001d4083f0d224000069cda1f01cf00000f4b440d072ea0000cb31900119d4000447bb2400ffc07000437034013a60fc00f985d0115e01fe09b06fa04d2ad1fc45c00b62089ae8fc376203006ae9d1fc2c1064404b12d0f0101062818484f87040854dc82cb7b0000082767b0f0602000013f818d9aa0c00000d12018e400a007f03f607d6784020ffcaec43c9720a00ffc5ae0a00bc0003f5f56800001fbc87f8f4f73c0011420781d8c978382a6907e5f06df83c00df47d11a41d87e2bff87a8738af87e10fc47f7f24d503c23f643d9fc149830233e43f7e652e5100ceba1ffca1e3a00e59bd0ff82c238007e4a100307263e484b0468080 +blobs8 Contrast 7 c0000985f000000780402800a803c00e601000003003f00680000000000ff803a0000000101ff80730000000001ffc03a8000000001ffc0718000070001ffc03d00002fc001ff805300003fe003ffc02d00007ff007ff006200007ff00ffe001c00007ff80ffc000c0000fff80ffc00000400fff00ffc01a000007ff00ffe01b000007ff007f8006800007fe003f8002000003fc001d0000400001f8000000008000000000000000218d0000000000000285000000000600c060000000000fc000200000003c1fe0001c0000803f1ff000080001007f1ff00000000000ff1ff000000001807f9ff004000e00807f1fe000001f00403f0fc000003f80401c030000003f806000000000003f802000000080201f80c000000040400d002400000003f000019800004001b00000b807800000000003a00fc00000500005e01fc00000700002a01fc00000202009a00fc0002030000ed01fc00000400031c00700000000004880020008000000cf0000000800000090000000000000001e0000000000000002000007e09a00000004020ff8ac04000000000ffcd800000000003fffd600000000003fff4e00000000007fffcc00038000007fff028007c000407fffa40007e00bc07fff18a007e00fc07fff24c007c01f403fff8348030013e03ffe65a600000e801ffca1e0000059a00ff82ea30000fca006f002630000305000a0 +blobs8 Mean 3 d30199c4f2a28296b06f2b1e2e02407a771cc6a239c3f1a496098a86430ffc90a738dde6fe1ffc8f16ab8841d21dbe2faa184001081e1c65b1326078251a7e26d3bd62fe401fb8d537eb8bfd181e7c38db408f7f28f0f0332494474f81f6c411d0ae8ed781f8c376c4641e1bc451c1e0294a1d7b00f7e15a026ddff380ffe05bba810faf28f7802ed67657af107d8706bbbb43fe1c7d06bc68c801f854000c3a9889a111e1004057b38d10002a80604c9a85380495800780e570bbb1c4080fe617e3b7d73abf1ff2995d1022b23f1df1189bb05948ff1978a160407430f71ff091a3841f98ff9ff2d42c0f06a07f9fe168473f90387f1fc782c53f89e07a07d137cc7dc87c180309d4083f8c224000069c7b3f89cf00600f43451f072ca00a0c931901119d6328c47bb2c02f9c47c16427024113a68fc41fb85d2115e01fe59b26fa45d2a9bfc4cc03b36189adafc37724b006aedd9fc2e1864764b1284f4101060a98484c87060854dd82cb6a0001082767b2d0602800013f83ed9eafc48080d12418c481a40ff81a6e752694320ffeaed435d770b00f7c4861a45bc9503f5f96803409fbc87b8e4f7391441420f11dcc931fe3a294765e26d8cfc04df46510a41487e2ff587aa73cadc7f5cfc47f7f24c517c257247dd6c349a3ca13e43d3c65a65100ce121ebca9e1b01c59bd0ff86e218227ec8184102a636794b046c020 +blobs8 Mean 7 d10589c5f2038016a06f291fac03c03a731c86023807f00486b88a02400ffc10af3c40005e1ffc0f162b0800001ffe2faa580000001ffc0510320078001ffe07c3bc02fe001ffc1533e90fff003ffc08db400fff00fef002240007ff80ffc011d0a40fff81ffc030c0e41fffc0ffc0a0295a1fff80ffe07a027c1fff80ffe05bfa810fff00ffc02ed27417ff007f8106bbbb03fe007f000c68c801f8100000229809801000000003b38d0000200000009a85000090000780e570b00104080fe003e38043083e1ff0895c0002a07f1ff0189a100140ff1ff8a360000020ff1ff099b00c1790ff9ff0d4201f07a07f9ff06c403f80787f0fe302c03f81c07e07c037c87fc07c180301d4083f842240000098783fc1cc00000343401f832c80000c935807119c03204479b000079c07c07407020003a40fc019b85d2105801fe019a4fa4052a83fe0482bb3608988afe232a4b074ae9c1fc021864764b1201fc000060a58484c07000854dc80cfe80000082767b0d0e00000002e83ed9fafc00080d1251884938007f81a6f742494120ffcaedc001370801ffcd86b8000cd003fff96840001ff407ffe4f7501c01400ffbfcc920fe0a280ff5f22d80ff009f07d17a4148ff0f7d07faf3ca90ff18fc47fff24c11fe25f207fffc34987c213e03ffe65e61380ce101ffca9e1a00c59b80ff86ea18007fc8004f02ae34400b04600e0 +blobs8 Median 3 530499c4e280829630ef281e2e23c43a731ccea231cbf5a486b98aa64337bd90a7385de67eb67e8d16238841d21dbeafaa1841290a0a1ce530327a7d250a7e82c39d6e7e440f9ad533eb8fed98063c38db408f6d6bb0c8332414474fb1f6861150ae8cd7c3d8c376c4e45e1bc400c1e0295abd7940f6f95a026dd5b285f7f05b3a814f8bb9a7802e96765faf91fde70633bb41f61c7906b868c801aa546f5c3a9889bb3de10a4153238d90a02ac2624c1a85080495803f40e570bbb1c45d26f605e3b7d73aff1ffa995d1022b2b70df8189bb85949ff297aa360427033f31bfc99b38d9798d69ff2d42c3f27a07f99f06c436fd0397f9f6702c57fc9c27f9ff037ccfdc87c5c3729d4287fdc22420406987b3fc9cd04668f53453dc72ca98b1c931937b19c6faac47bb2427e9c47e1f4070a6513a69fc419b85d69148039ed9ba4fa41d2a9b9e4482bb369898daee372a4b05eae9dbff2e18e4664b1285f6935862a18484c9fc62854dc82cb6a0f61ba2767b2d0e02904017e81ed9fafcd8a98d12418c493ac0f901a6e342494728fecaedc309330a4437c5063a4d3cd567b5e96843489ffc9fb864e7795e81423f11dcc931ffba295b65e22d8dff409f62510a4148ff2f75a6a873cad4ff5cf84cf7b64c11ff65724d192c359afca13e43c3465a61b80ca122ebca9f0b01c59bd5ffc6e2982e7eca114b022626584b0469020 +blobs8 Median 7 d10581c5f2038016206f291fae23c03a771cc6a238cff5a486b88b86423ffd90af3c5de47e3ffe8d16ab0841123dffafaa7c4179080ffea5113a13fd010bfe86c3bc6fff400ffe9533e98fff980e7c38db401fffebf0c83224805fff81fe8411d0ae9fffc3fac370c0e45fbbc5ffc0e0295a1f7fc1fff87a027ddfffc1fff05b7a815fff81ffe02e96765fff81ffc506bbbb4fff1c7f809c68c807fe545f003e988983fde00001d7a38d10802a00034c1a85080491000fe0e570bb81c4380ff603e1b6d33aff1ff8995d1022a2ff1ff8189b385941ff1ff8a360427023ff1ffc99b38f9798ff9ffad4247fa7a0ff9ff86e417fd0797f1ff70ac5ffc1c07e0ff137ccffc07c5c07e9d4087fcc2240040698783fc98d00020b43443fc72c80001c93183fb19c6fa0c479b0427f9c4fe17427026513a69fe01fb85d6915803fe99ba6fa40d2a9bfe4482bb7e88988afe372a5b07cae9cbff2e1864764b1207fc135860ad8484cbf802855dd80cfea0f01382767b0d0e02804012e83ed9bafcc0fd8d10518c493a40ff81a6f742494321ff8aedc109370a07f7c586ba4d3cd007fde96841489ff41ff8e4f7797e01403fd1dcc921ffaa285fe5f02d8dff009f6fd11a4148ff2f753faaf3cad4ff58fc4ff7f24c11ff25720ffd6c349affa13e07f7c67e65bc0ce127ffcabe1b01c59bc5ff86ea18207fc8104f07ae36584b04680e0 +blobs8 MidGrey 3 d60099c4f2800096b0412b7eae02407f7610cfaa3983f0be96018a864307f83ba700dc005e1ff83f142bb800501dbc3f8a18c000081e1c2711330070251e7c37c3fc42fc083ff835b7e983fe183e7c38dbc087ff087cf03207b0074f00ffc011d00687d700fbc174c0040e1b8071c0e028420f7f00e7c05a020d0fb700ffc05bba010fbe007f8026823647fe103f85020bbb03fc1c1d06bc400801f8140000368809800060000007218d10002a0040049805380494000600e160bb81c4000fc314e3b7d7383c1fe0895d9022b03f1ff0189b3059487f1f70a064c03030f71ff080b20017987f1ff1c42c0e06a07f1fe160401f02383f0fc3c0c43f03401807c133cc3d807e080001d4083f0d224000069cda1f01cf00000f4b400d072ea0000cb31900119d4000447bb24007bc07000437034013a60fc00f985d0115e01fe01b06fa04d2ad1fc45c00b22089ac8fc336203006ae9d1fc2c1064404b12d0f0101062818484f87040854dc80cb6b000008276790d0602000013f818d9aa0c00000d12018e400a007f03f607d6784020ffcaec43c9720a00ffc5a60a00bc0003f5f56800001fbc07f8e4f73c0011400781d8c978382a6907e5f069f83c00df47d11a41d87e0bfd87a8738af87e10fc47f7f24d503c21f643d9fc149830213e43f7e652e5100ceba1ffca163a00e59bd0ff82c238007e4a100306263e484b0468080 +blobs8 MidGrey 7 c0000985f000000780402800a803c00e601000003003f00680000000000ff803a0000000101ff80730000000001ffc03a8000000001ffc0718000070001ffc03d00002fc001ff805300003fe003ff802d00007ff007ff006200007ff00ffe001800007ff80ffc000c0000fff80ffc00000400fff00ffc01a000007ff00ffe01b000007ff007f8006800007fe003f8002000003fc001d0000400001e8000000008000000000000000218d0000000000000285000000000600c060000000000fc000200000003c1fe0001c0000803f1ff000080001007f1ff00000000000ff1ff000000001807f9ff004000e00807f1fe000001f00403f0fc000003f00401c030000003f806000000000003f802000000080201f80c000000040400d002400000003f000019800004001b00000b807800000000003a00fc00000500005e01fc00000700002a01fc00000202009a00fc0002030000ed01fc00000400031400700000000004880020008000000cf0000000800000090000000000000001e0000000000000000000007e09a00000004020ff8ac04000000000ffcd800000000003fffd600000000003fff4e00000000007fffcc00038000007fff028007c000407fffa40007e00bc07fff18a007e00fc07fff24c007c01f403fff8348030013e03ffe65a600000e801ffca1e0000059a00ff82ea10000eca006f002230000304000a0 +blobs8 Niblack 3 d20099c4f28002963041291e2e02403a7214c6a23183f0a086008a864307f810a7205ca45e1e780d16238801d21dbc2faa184000080e1c2510320070250a7a00c39c62fc000f989533e983ec181e3c38db40873f0830e0320400474f00f6c011d08e84d700d8c170c0640e1b8440c0e028420d7b00f6c052024d87b300ffe05b3a010f8e00778026967653ae103d85023bbb01f81c390688684801e85400003a9809a00060004057238d10002a80400c1a85080491000600e560bba18400078201e1b7d73a3c1fe0195d1022a23f0df0189b3019407f0970a060407020f31bf081a2801798560ff0c42c0f06a07f1be168411f10383b0fc302c53f014018070137cc3d807c180209d4083f0c22400006985a1f898d00600f43440d072c80080c93190011946128c479b2400e9c47404427024013a68fc009984d2114e01fe19904fa01d2a91dc44c033360898dafc332243006ae9c1fc2c1844424b120070101060a18484c87060854dc80cb6a0000082767b0d0602800012e81ed9aa8c00000d12018c400a007901a647524843207f8aec4309720a00b7c4860a443c050385e168020017bc03b864f7381001420701d8c930282a290365e02d8c3c009342500a41487e2f7506a873cad43f1cf846f7b24c1034257241596c149834213e43c3c65a61100ca120ebca1e1b01c59bd0ff82c218027ec81001022636484b046c000 +blobs8 Niblack 7 c00081c4f0038016806d280ea803c038711c06023007f00080388802000ff800a7384000541ffc0514280000001dfc07aa180000000ffc0510320078001ffe02c31c02fc001ff81523c903fc001ffc00d0400fff007ef002040007ff80ffc011d0a40fff80ffc030c0600fff80ffc02029480fff00ffc01a00680fff00ffe05bf2810fff00ff800e827407fe007f8006b3b203fc003d0008680001f8000000028809000000000003a38d0000200000001a85000090000700e160b00000000fe001608000083c1ff0095c0000a07f0ff0189a100140ff0ff8a160000020ff1ff091a0041790ff1ff054201f06a07f1fe008403f80207f0fc002c03f81403e07c025c07fc070080200c0083f802000000098783f818c00000143401f012c000004831801119c01004479b000069c07c04007020003a00fc001a0580004801fe00804f20012283fc00003336089882fc2002430002e981fc000844464b1000fc000060818484c070008540080cb6800000826469090600000002e010d9aaf000000d1041884018007f01a2e142094120ff8ae14001230000ffc486180008d003fff168400007b407ffe4e3501400000ff9d8c8007e082007f5f02880fe001007d17a4040ff0d7407baf1ca10ff10f807f7f24c007e057203fdec34987c013e03f7c65a61100ce101ffca1e1000059b00ff82ea18007e48000b022634000b0440020 +blobs8 Phansalkar 3 c0000084e203c006004d281e2803d03832140622108ff00080008a06001ffc10a7305c24561ffc0414210840001ffe228a084000003ffc2510320078013ffe00431c6afe003ffe9103c98fff183ffc2090400fff09fff802000047ff81fff41110868fffc1ffc130c0641fffc4ffc08029421fff80ffe05200419fff80fff05b12010fff00ff8006827457ff007f850003b203fe147f0488400001f8501b00328009811160004043218500002000404800050804900007c0e160b38104090fe2004092d30abf1ff0095c0002a23f3ff0189a300140ff3ff8a160406020ff9ff801a28c1598ff9ff044201f06a07f9ff108003f90387fbfe702c13f81007e0fd125c07fc07c1c0309c0083f8c0240000498183fc88d00000b41401f002480000c82080711146320c4382040060c47c04007024013808fc00180402104801fe01804f200d2293fe4480033608188afc32224300006141fc2c1844424a1005fc100060818404c870008540080426800010822468090202800012c01059aa8c00280d100188400800ff80a24342084120ffea014001000801fff4060a44080007fff148004007b407fff4e3101400000ffff88800fe28200ffff02888fe001147fffa4048ff2c4107fff18a14ff10f847fff244117e202007fff814987c213c03ffe65a6130082121ffc01c0b01c58b01ffc0c008007a40106f802624480204683e0 +blobs8 Phansalkar 7 c0000084e003c006006c280c2807e038311c0602100ff00080388002000ffc00a3384000541ffc0514200800001ffe238a080000001ffc0510300078001ffe00431003fe003ffc1103c90fff003ffc00d0400fff00fff80200000fff81ffe00110840fffc1ffe030c0601fffc0ffc080294a1fff80ffe01200601fff80ffe05b12810fff00ffc006827417ff00ff8100233203fe007f0008400001f8100000228009011000000002218500002000004000050000900007c0c160b00000080fe00060004208fe1ff0095c0000a0ff1ff0109a100140ff3ff8a160000000ff1ff811a00c1590ff9ff054201f06a0ff9ff008003f80207f1fe202003f81007e0fc024407fc070180300c0083fc00240000090183fc08c00000141401f80240000048208071114032044303000068c07c04007000003800fc00180400104801fe00804f20012283fe00000336081882fe20024300006001fc000844464a1001fc000060818404c0f8008440080426800000822448010200000002c01019aae000280d104188000800ff80a0e142094120ffca014001000001ffe4021800081007fff168000007b407fff4e3001e00000ffff88800fe00200ffff02800ff001007fffa4040ff082107fff18a00ff10f847fff20c01fe003007fff814987c013c03ffe65a6138002101ffc81e0000058b01ff80c018007a00007f002624000204401e0 +blobs8 Sauvola 3 ff0ff9cfffe7cfffb8efff7ffff7f7ffff1ffffffbdffdbfffbfbfffdfbffdfbeffedfffffbffeffffffff47ffbffffffffffd39ffbffefffffffbfd7f3ffef7dbffeffe7f3ffffff7ffcffffb7ffdfffbefdffffffffcffffbfffffb7ffffffdfffdfffffffe3ffffffdffffdffe7fdfdffffffddffffffffffdfffddfff3fffeafdfffbdffe7ffffffdfffbdffdf3fffffe7fe3e7fbfbffffa71fe7fff7fffbffffbffff8a7fffffffdea2ffc7734ffeeffcd7bfe3ffcbfffcfff7f7fd2fefffffffff7effbfffffffdfebfeff3ffbfffffbdddfffbffbfff7e3fefbffbfffffffcdfffeffbffbfdbfff37feff9ffb7effffdbfd7fbfe7ebffffdffeff9ff3ffffffdf7fdc777ffcff7fdfefcb942fdffb3fffff2efeaffbff3fdf7ff9ff1fffff77bfffffafe7ffffe2ffffe7ffffb7ffffbfa7afdfbfffffefffff3fef9ff7ff77ffafbfe7deaff7ebefbfafe7f7f7ffffffddffffe7efff75ffffffdff5efffffcfffff9769f7fdbfffffafffbfb77ffefefbe9f401ffffffbfffffbfd2dfeffdfffffe6ffaffefffffd7fb9ffeeefe7fdff5b47fffffffeffbfffe7ffffff77cffffffffffff7ff5effffffffffcd79fefeefffffff6fffff7fdfe7ffffe7dfff3ffffffffbfffcff5dfe7ffffeffdfff7ff7efffffbffffffffff3fff7ffefbdddfbe3fffbffff87f7fffdffcfffffef7ffffd7fafff7ffbffffff3f5 +blobs8 Sauvola 7 ff5ff9cfff83c0ffbaffff7ffe07f07fffffffeffb8ff8bfffffbb87df1ffc3beffcdde6fe1ffc3fffbfa8407e3ffe7fffffc028183ffe7fffbf63fc253ffeb7dbff6ffe003ffefff7ff8fff187ffc3ffbcf8fff09fff83fffb65fff81fff4ffdffe9fffc1ffe17ffdfc1fffc5ffe1fdfffe1fffc1ffe1ffffff9fff81fff0fffffb0fff81ffc0ffffff5fff01ffc53fffffc7fe1c7f82bfffda01fc541f003fbeffa1fd6c000057ffff90003e00024cffeff804bc000fc1fffcfb81f43c0fe2bfffbfdf7aff1ff0ffffd82bfaff3ff8ffffb85dc8ff3ff8fff6c076fbffbff8ffff8f9ff8ff9ff3fdbc7fa7f0ff9ff17ef77fd3f87fbfe7ebfdffcbec7f0ff1fffcffc57e1c072bfcf87fcdea400006dffa3fcfff00000ffbfc3fc77f80001ffbfd27b7ff6fa0eefffe407ffd47e1ffb7ff61bfa69fc1bfffffe95ffc3fed9ff7fbf7ffadbfe6deaff7e9efbdafe377ffffffffddbff2efffff75fffd5fc1bdeffffbcfdfbf8028f7fdbfffff0f0008b77ff6fef1280000ffbfffbfffcc03d0dbffdcefffec0ff8fffffd7fbffa1ffeeeff7ddff5b43ffffbffe4fbdfdc7ffffff73489ffd8ffffff7fd7edfff0fffffc971febeef5ffff76fedff4fdf47ffff67d8ff2fff8ffffbfffcff5dfe4ffffeffd1ff3ff6cfffffbfdeffafff43fff7ffe5bc1ffba3ffcbfffb01f7ffd1ffcffeffaa7fff507f8fff7f797fffe83f0 +narrow8 Bernsen 3 0186424c17e56004c2a60b204a6c4000ce680180035805e168c0b00381dc1ff98361fc0fe0503ff86623fe1ff0a1ff3a7c47ee3ef8f0eb78b207efff7923903cc18edfb9f1e3b1dcff07d9fb98e3dc58190fffdfb8e1f828d906bf5af8c1e238a81f2f9ff148e9785f0f170fe0ac7ff83587ff87e1864ff00801fe000074513c5e317c000428e37cb22c50490968fdde936201d882d9c1f7f7e2417bef73e1fc2dc30c4bdd71f7dc7f6d6823adb03f98e3fb90f1d3e817f8d6e7b8132ffc13f0efdb8e682f7d83e1eb73fbe62f0dc080e5dcdbc57b170c100bc4beafeedffc8a94e476ff4da3a7237d2257b7f1b3be71062fd29fe625bd01825d1efdfe3e1a63c1b7f918105d9fdf87e1df186671cb4eecda57df23cebeff8ef29586883eb9fdabbfd2771cc20993be8baa5cc409cff831f745133f22be758f995dc94d7e30e577c504f9befe3220ac411afb7ae6200070c02dfa9bf9c000eb1276ca73dd81c49e02cc0a7d4187f8cb4a574535118fffd1f0dcb518109f7ff1e07b4d321c1f3bc87f9f61e4673d438a0cbd06007f7d95e07a2803d8ffbf6cc13a0007b0fffc75100f00e351cefcc4c09a2fd010f2f738818d47f889ebffcf39de0ffc60f3cf7f6b93cebc20ff8fdf948e9c9c707d03fc92c31dce7c2001f83c1d0fdc6e0000205f821ffc7a8250004f7e8b7cf7429e093aad42e8b98b5205879403f1752c02fd +narrow8 Bernsen 7 ba80000000e00000f700000040400000ce000000004005e06a00a00380000ff08201fc0fe0003ff82603fe1ff000bffa7c07fe1ff800fff83807fffff803fffc400ffffff803fffce80ffffff807fffc081ffffff801fcf8c80fffdff803eef8a01fff9ff001fbf87e0fff1fe0007ff83407ff07e0006ff01801fe0000405ffc7e017c000000fbfcb20058000000ffde930000000080ffff738000000f01fffc054000000700fffc3760003a0d803ff823f900fbd3a017f842d428133dc013f0efdb0e6d6f6003e0eb73f9e72f080080e5dcdb413a1400008bc49fafeedd0000947476f74dba00027d0247b7f1b3a011062fd29fc675a500807d1afdfe7e1a43c1b7f918547d9fdf86e1df9967708a4ae49bf7dd638cbe0f8ef29586983ab929a087d3f71c820913aa03ab5cc409c00031f765173f22c0008399fdc94d6f800061c5e4fbfef60000a4419adb7aa0000070c06dfa9ac00000e112767a700001c01202cc2e780007f88342574530000fff10a09c2140001fff3180000100081ffb48390001c02f3fff8a088000007f7fffe178000000ffff7fc5f8000001fffff70c0c00e001ffffffc4980ff000ffffff8d884ff801ffefff39d81ffc00ffcfff6bf01ffc01ff8fffbc801ffc007d03fcb2c01ffe002001b83c180ffc000000205d801ffc000000007e600ffc00000000f8a803f8000000009d1403f00c000000 +narrow8 Contrast 3 bb86404c17ed6004fe260b204a6c4000ce480180015805e168c0b00381d41ff98301fc0fe0503ff82623fe1ff020ff3a7c47ee3ef8b0eb78b207efff7923903cc18edfb971a3b1dcff07d9fb98e3dc58190fffdfb8e1f828c906bf5af8c1e238a81f279ff148e978570f170fe0ac7ff83587ff87e1004ff00801fe000074513c5e1138000428e37ca22c50490968fdde932201d882d9c1e5f7e2417bef73e0fc2dc20c4bdd71f79c7f656823adb03f98e37b90f1d3e817f852c739132ffc13f0efdb8f682f7d8161e973f9e62f0dc000e5dcdbc53b170c108bc4bfafeedffc8a94e476ff4da3a7237d2257b7f1b3be71062fd29de625bd01825d1efdfe3e1a63c1b7f918105d9fdf87e1df186630cb4eecda57dd23cebe7f8ef29586883eb9fdab9fd2571c820993ba03aa48c4094ff831f745133f22be758f985dc94d7e10e577c524f9befe3220ac411afb7ae6200070c02dfa9af9c000ef12764a73dd81c01e02cc0a7d4187f8cb4a574535110fffd1f0dcb518109f7ff1e07b4d121c1f13c83f9e61e4653d438a0cbd06007f7d95e1fa2803d8ffbf6cc1fa0003b0fffc55100f00e351cefcc4c09a2fd010f2f738818d47f889ebffcf39de0ffc60f3cf5f6b924e3c20ff8fdf948e9c9c703d03fc92c31dce7c2001f83c190fdc6a0000205d821fdc7a8210004f768b7cf7429e0938a942e8b98b5205879403716d2c02f5 +narrow8 Contrast 7 ba80000000e00000f700000040400000ce000000004005e04a00a00380000ff08201f80fe0003ff82603fe1ff000bffa5c07fe1ff800fff83007fffff803fffc000ffffff803fffce80ffffff807fffc081ffffff801fcf8c80fffdff803eef8a01fff9ff001fbf87c0fff1fe0007ff83403ff07e0006ff01801fe0000405ffc7e017c000000fbfca20058000000ffde930000000080ffff738000000b01fffc054000000700fffc176000380d803ff8237900f9d3a017f842c428133dc013f06fdb0e6d6f600160ab7379e72f080000e5dcdb413a1400008bc49dafeedd0000947476f74dba00027d024397f1b3a011062fd29fc675a500807d1afdfe7e1a43c1b7f918547d9ddf86c15f1967708a4ae49a77dd638cbe0b8ee29586983ab929a087d3f71c020913aa032b5cc409c00031f765173f22c0008391fdc94d6f800061c5e4fbbef60000a4411adb7aa0000060c06dba9ac00000e112765a700001c01202cc2a780007f88342574530000fff10a09c2140001fff3180000100081ffb48190001c02f3fff8a088000007f7fffe168000000ffff7fc5f0000000fffff70c0c00e001ffffffc4980ff000ffffff8d884ff801ffefff39d81ffc00ffcfff6bf01ffc00ff8fffbc801ffc007d03fcb2c01ffe002001b83c180ffc000000205d801ffc000000007e600ffc00000000b8a803f8000000009d1403f00c000000 +narrow8 Mean 3 bb86c26d17ad6204f7664b20484c4010ce690184095805e16ac0b08389d61ff98349d70fe2513df82623fe1ee021bf1a7c47661afba0eb783217ab5f7923803c41ee473171c3b1dcaf44d9eb99679cdd091ffb1f18c5f868c9469f5ad8c5c638a8df271ff148e968570f170fe28c5fb03593ff86e9c54ed11901fe250474113c5e1d3c150429fb6ca22c58c9296afd9e932601d082d9c5e7f3e2497b7f13e0fc25425d49d571f79877256b3b8db4bf98e37b92f9d3aa14f8424728132cf813f46d9b8e6c6f798361885179e62c0d62c8e5dcdb402a1418108bc49dafeedfec9a946474f64db386637d02438331b3b2f11e2fd28dc621b508c27d1afdee3e1a63d1b7f818145d9fdf86c15c186670ca4aec9a57dd63cebe4f8ef29586983ab1f9aa97d2771c820993aa022a5cc40dcff831f745133f22fe5587995cc94c6f90e573c5e0f99c7e32a4ac490adb72a2304871c0adba9ae9c81ee31a761a71dca5c41602cc2a7d4807f88b4a57c534330fffd5f09cf558199f7d31a0514d321c1f994a3f9e61c4653d418e0c9d46037f3c95e9ea2883d8f6af6cf5f21057b1ff1c555c0f00f3519e7cc4c4982fd010d2e3308d8947d8cbebebcd39de1ffc70f3cf5f6b92663e21e78fceb48c9c9c703d01f892c33d8e7ca601b83c190fdc6244d42a59821fdc7a8270004e76ab7ce7429e1938a970c281cb52058d1503752d2c82f5 +narrow8 Mean 7 bb86000c17a50004e7020800400c0000ce400004015805f04ac0b00380961ff98309fc0fe0403ff82623fe1ff001bffa5c47fe1ff9a0fff8321fff5ff903febc000fffbff803fbfcec0fdffff827fedc081fffdff807f868c80fff5ff843ee38a81faf1ff109e9687e0fff0fe0005ff83497ff07e1004ff01801fe050040593c7e017c000021fb7cb22c58400903ffded30000800089fff7f3e2015a6f03fbfc254008495561fffc7725083b8d90fffca37b10f9d3a817f842c728132ce013f06fdb0a2c2f7803e1897379e62c0c02c0e5dcdb410a1400108bc49dafeedf608215e474f74db386227d024387b1b2b2f10e2fd28dc635a508807d1afdfe7e1a63c1b7f818147d9fdf86c15e1866708a4ae49a77dd63ccbe4f8ee09586983ab939a3b7d2571c820913aa832a48c409cfc031ff45133f22fc008799dcc94c6f900173c5e0f9bcfe3000ac490adb72e2200071c0edba9ae8c010e312761a719005c41602cc2a7d0007f88b4a574534000fff90b09cb550101ffd31a0110d001c1ffb4a7f1641c06c1f638a288006007f1dd5e97a2803c8ffaf6cc5f80043a1ff9ed51c0f01f251fffcccc49a2ff011feff7c8d8d4ff803ffefdf39de1ffc00ffcfff6b923ffc01ff8fffb48c1fbe00ff03fc92c33ffe44be01f83c181ffe2244002059801ffe6a0010004e602ffc2442880838ad43e8018350018c1403f02d2c0201 +narrow8 Median 3 3886424d1725620422264b20c80c43700c690184895815716ac1b08b89971bd98379d70fe2411df0263bee3ee825871a7c40661a3ba26b79321eab1f6923843841ee470171d3b1ceee60d9eb8c6f9cdd091ff3171cc5f868c9449d1adcc78638a8df061df149e140570b1717f20c4fb03593fb9669844e511901f6254c7401385e3d32150429eb6ca22c58c92d0bfd0e932601c082dbc5677362495a7f13e0f405025c495471d39837256b238094ae9ee37b92f1d22a14fc424328132cc852f46c9b0e2c2b78c361885079e62c0d6ac8e5dcdb000a1419108bc48d2fae9dec9a146474d64da386627d02438331b2b2f11e26d28cc621a508c27d1af5ee1e1262d1b7a818144d9dde06c15c186770ca48ac9a57dd63cebc0b8ee09586883ab139a297d2771cc20913aa022a58c409cfd031f745133722de0107995cc94c4610c571c5c0f9987e32a4ac490adb62a2304871d0ad1a9ae9ca5e631a760a61d4a5c41602cc2a7d4805f88b4a4745353335fed0b09cb51818d36d3180114d329e1e914a3f9e61c4e509418e089dc6036e1494696a2893d8f6876cc5f21057b13f1c550c0f10f2519c7cc4c4886dd01a92e338898147d6cbeaa9cd39de1ddcc2f18d5d6b92e62425e70cce948c9c9850fd81f882c3398a5caf03b83c190ddf6274d4aa59823fde6a8270884f72a87ce7429e1938ad70c281cb52058d1501652d2c8264 +narrow8 Median 7 ba86c24c17a52204a322cb20480c43708641218481581ff00ac1f807e9963ffd8349fc0ff041fffc2623fc0ff801bfbe5c47ee1fffa3ebfc321fef1ffd03febc016fff1ffd87b3deec7fd9effd6ffedd091fff1ffcc7f86cc95fbf1afc47ee38a8df2f1ff10be9607f0f1f0ff2044ff03497ff07e9000fd01807fe07c850013c7e1dfe150029fb6cb22c7849290bff9e930601c0809bfff773a2415a7f13fbfd05401c495561f7de3725483b8d94ffde237b90f9532815fc42c328132cc053fc2ddb0a282b7887e1885078e62c0d6ac8e5dcdb000a1411108bc49d2fae9dec8a146434d74da206227d024387b1b2a2f1062fd28dc6212508005d1afdee3e126381b7f818107d9fdf86c15c1866708a4ae49a77dd238c3e0f8ee09586983ab129a297d2571c020913aa822a08c409cec031f745133722fc000799dcc94c6e908563c5e0f89cfe32a0ac490adb72e2204871c0ed3a9ae8cadee312761a719007fc1602cc2a7d400ff88b4a574534311ffc10b09cb110181ffd31a01109029e1fb94a7f1e41c0fc0d418a2c954603fe0dd5e17a2983d8fe0f6cc5fa1057a1ff1cd50c0f01f251fe7cc4c49a2ff013fee7388d8d4ffe8befabcd39de1ffc43ff8f7f6b927ffe21ff0fdeb48c9ebe50ff03f892c33fde5cbe03f83c185fff227485e859803ffe6a8210804e66abfce4429e0838ad70e2018b52018c1501e02d2c0244 +narrow8 MidGrey 3 bb86404c17e56004f4260b204a6c0000c4480180015805e168c0b00381d41ff98301fc0fe0503ff82623fe1ff020ff3a7c47ee1ef8b0eb78b207afff7923903cc18e4fb970a3a1dcef06d9fb98e39c58190fffdf38e1f828c906bf5af8c1e038a81f279ff148e978570b170fe0a87ff83587ff87e1004ef00801fe000074513c5e1138000428e37ca22c10480928fdde932001d080d9c1e577e2417bef73e0fc2dc20c4bd571f79c7f656803adb03f98e37b90f1d3e817f852c738132dfc13f0efdb8f682f7d8161e951f9e62f0dc000e5dcdbc43b1708008bc4bfafee9ffc8a94e476f64da387227d0257a771b2be71062fd29de625a500825d1efd5e1e1a6381b7f818005d9fdf87e15e1866308b4eacda57dd23cebe7f8ef29500803cb9f9aa9f925614820993aa032a48c4094ff831f745133f22be758f981dc94d7e10c577c524f8befe3200ac410adb7ae6200070c02dda9af9c000eb12764a73dd81c01602cc0a7d4187f88b4a574535110fffd1f0dcb118009f7f31c0710d121c1f11c83f9e61c4653d438a0cbd06007f7d95e1ea2803d8ffbf6cc1f00003a0fffc55100f00e351cefcc4c08a27d010d2f738818d47f888ebffcf38de0ffc60f3cf5f6b924e3c20ff8fdf948c9c9c703d03fc92c31d8e7c2001f83c190fdc6a0000205d821fdc7a8210004f76837cf7429e0918a940e8998b5205871403716d2c02f5 +narrow8 MidGrey 7 ba80000000e00000f200000040400000ce000000000005e04a00a00380000ff08201f80fe0003ff82603fe1ff000bffa5c07fe1ff800fff83007fffff803fffc000ffffff803fffce80ffffff807fffc081ffffff801fcf8c80fffdff803eef8a01fff9ff001fbf87c0fff0fe0007ff83403ff07e0006ff01801fe0000005ffc7e017c000000fbfca20050000000ffde930000000080ffff738000000b01fffc054000000700fffc176000300d803ff8237900f9d3a017f842c408133dc013f06fdb022c6f600160ab7378e62c080000e5dcdb412a1400008bc49dafeedd0000947474f74dba00027d024397f1b3a011062fd29dc675a500807d1afdfe7e1a43c1b7f918546d9ddf86c15e1966708a4ae49a77dd63883e0b8ee295809838b929a087d3f71c020913aa032b58c409c00031f765173f22c00001907dc94d6e800061c5e4f9bef20000a4411adb7aa0000060c06dba9ac00000c112765a700001c012024c2a780007f88342574530000ffe10a09c2140001fff3180000100081ffb48190001c02f3f7f8a088000007f7fff6148000000ffff7fc5f0000000fffff70c0c00e001ffffffc0980ff000ffffff89880ff801ffefff39d81ffc00ffcfff6b901ffc00ff8fff94801ffc007d03fc92c01ffe002001b83c180ffc0000000059801ffc000000007e600ffc00000000b8a803f8000000009c1403f00c000000 +narrow8 Niblack 3 ba86424c17a56004e2264b20480c40008c400184015805e148c0a08209960bf18309d00fc04039f02623fe1ee021971a7c42661af9a06b783206ab1f7923803801ee47017183b1ccaf0499eb98659cd0091ff31718c1f828c9041f1ad841c238a09f071df108e140570b170fe00c4fb03493ff06e1800e510800fc20007401385e1130100428634ca22c5849290afd0e832601c080c8c16773e2415a6f13e0f405421c495471f798772569238d943e98e37b92f1d3aa14f8424328132ce810f02c9b0a282b788061885079e62c0d6288e55cdb400a14181083c48d2fae9d6c8a146474f64da306227d02438231b2b2f10e2fd28cc6212508805d1af5ee1e126381b7a818004d9ddf06c15c1866708a48ac9a57d9238c9c0b8ee095808838b139a08752571c820913aa022a58c409cff031f745133722fe1187995cc94c6610c571c540f89c7e32a0ac410adb62a2200070c02d1a9ae9c002e312760a619481c41602cc0a7d4005f88b4a474534110ffe90b09cb118109f6d3180110d32081e114a3f9e61c46419418a089546022f1c15e96a2883d8f68f6cc5f01007b03f1c511c0f00e2518c7cc4c4982fd010d2e330898147d088eaa94d38de0fdc40f38f5d6b92662420e70ece948c9c9c503d01f882c3098a5c2001b83c1905dc6244d02059821fdc6a8250004a62ab78e5429e0918a960c2018b5205841401612d2c0275 +narrow8 Niblack 7 ba86000407a40004e3000000400c00008e400000014005e04ac0a00380800ff08301d00fe0403ff82623fe0ff001bfba5807ee1ff880fbf83207ff1ff903febc000fff3ff003fbdca80fddfff807fcdc081fff5ff801f868c80fff5ff841ee38a81faf1ff000e9607e0f7f0fe0004ff03493ff07e1000ff01801fe00004019387e013c000020fb7ca22458000900ffde930000000081fff7732201404f03fbfc050008495561f7dc172108238d807ff8237b00f1532817f842c128132cc013f029db0a282b700060885078e62c0c0080e5dcdb000a1400008bc49d2f8e9d2002146434d64da206027d02438331b2a271062fd28dc6212508005d1a75ee3e126381b7f818105d9fdf86c15c1866708a4aa49257dd238c3e0f8ee095009038b1298287d25714020913aa022a08c409cec031f745133722f80007995cc14c6e100163c5c0f89cfe3000a4410adb72a2200071c02d3a9ae80000e312761a709001c012028c2a7d0007f8834a574534000ffe10b09cb110001ffd3180110900181fb948390641c06c1f418a088006007e1dd5617a0001c0fe8f6cc5f00001a1ff1e550c0f01e251ff7cc4c0980ff010fef73c898d4ff801ffefcd39de1ffc00ff8f7f6b920ffc01ff0ffeb48c1ebc007d01f892c21ffe042401b83c180ffc0000002059801ffc220000004a600bfc2402080018a941e001835001841403f02d2c0201 +narrow8 Phansalkar 3 bb86c26d17ad6284f726cb20484d4330ce49018489581df16ac1b88789971ffd8379ff0fe2417ff92623fe7ff825ffbb7c47fe3fffa2fffd331ffffff923fffc01effffff9d3fffeaf6fdffffdeffffd099fffdffcc5fffec95fffdffccfeffca8dfef9ff14bfff87f0fff5ff20cfff93597ff97e987fff51903fea54c745ffc7e1dfe150429fffeb22c79dd2d6bffdf932601d082dbfff7f3e2497bff13fffd25425d49d571fffe776d6b3b8db4fffee37b92f9d3aa1ffd42c728132cf857f46fdb8e6c2f79c3e38873f9e62c0d6ac8e5dcdb400a141d108be49dafeedfec9a94f474f74db386637d024387b1b3bef11e2fd29dc621a508c27d1afdee3e1a63d1b7f818145d9fdf87e15e186670ca4aec9a77df63cebe4f8ef29586983af9f9aabfd2771c820993aa832a5cc409cff831ff45133f22fe518799dcc94c6fb0e573c5e0f9bcfe37a4ac490adb72e2304871c0adba9ae9c81ee31a761a71dca5cc1602cc2a7d480ffc8b4a57c53533bfffd0b09df5583bdfff31a05b4d329e3ffb4a3f9f61c4ef3f7f8a0c9dc6037f7fffe97a2993d8ffffffe5fa1057b1ffffff5c0f01f351ffffffc49a6ff01bfefffdcd8d4ffecbffefdf39de1ffc70ffffff6b92fffe2dff8fffb48c9fbe70ff83fd92c37ffe7cbe03f93c195fff6274d4aa59823ffe7a8272084e66affce7429e1b38ad7fea81cb52058c1507f52d2c02f5 +narrow8 Phansalkar 7 ba86400c17a52004f7224920480c0350ce41008481581df14ac0b007e9961ff98309fe0fe0417ff82623fe1ff801fffa5c47fe3ffda3fffc321ffffff903fffc002ffffffc83fffeec1ffffffc67fffc091ffffffc07fffcc91fffdff847fff8a85fff9ff10bfff87f0fff1ff0047ff83497ff87e1017ff01803fe0540405ffc7e0dfe000029fffcb22c58480903ffffd306018000cbfffff3e2415a7f03fffd25400c495561fffe7765083b8d90fffea37b92f9d3a81ffc42c728132cf017f46fdb0e2c2f7883e1897379e62c0d42c8e5dcdb410a1400108be49dafeedfe08a95f474f74db386227d024397b1b2b2f10e2fd28dc635a508c07d1afdfe3e1a63c1b7f818147d9fdf86e15e9866708a4ae49a77df63cebe4f8ef29586983ab939a3bfd2571c820913aa832a48c409cfc033ff45133f22fc018799dcc94c6f900573c5e0f9befe3000ac490adb7ae2204071c0edba9ae8c058e312761a719007ec1602cc2a7d4007fc8b4a57c534110fff90b09db558181fff31a0190d321c1ffb4a7f1e41c0ff3fff8a2c9046037f7fffe97a2883d8ffffffe5fa1047a1ffffff5c0f01f251ffffffc49a2ff013ffffff8d8d4ffc0bffffdf39de1ffc41ffffff6b927ffe21ff8fffbc8c9ffe10ff03fd92c33ffe44be03f83c181fff227400a85d803ffe6a0010004e60affce4428a0938a97ffa018b50018c1407f42d2c0245 +narrow8 Sauvola 3 ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff +narrow8 Sauvola 7 ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff