From 2493e9cd11d8bdd8eda90a9c04c7e9d0bfefeda7 Mon Sep 17 00:00:00 2001 From: lleplat Date: Wed, 18 Feb 2026 17:23:03 +0000 Subject: [PATCH 1/3] Create 2d linear interpolators without precision issues --- .../qupath/ext/imglib2/AccessibleScaler.java | 9 +-- .../interpolators/ArgbLinearInterpolator.java | 65 +++++++++++++++++++ .../LinearInterpolationFactory.java | 49 ++++++++++++++ .../interpolators/RealLinearInterpolator.java | 43 ++++++++++++ 4 files changed, 162 insertions(+), 4 deletions(-) create mode 100644 src/main/java/qupath/ext/imglib2/interpolators/ArgbLinearInterpolator.java create mode 100644 src/main/java/qupath/ext/imglib2/interpolators/LinearInterpolationFactory.java create mode 100644 src/main/java/qupath/ext/imglib2/interpolators/RealLinearInterpolator.java diff --git a/src/main/java/qupath/ext/imglib2/AccessibleScaler.java b/src/main/java/qupath/ext/imglib2/AccessibleScaler.java index db56c40..de6dc16 100644 --- a/src/main/java/qupath/ext/imglib2/AccessibleScaler.java +++ b/src/main/java/qupath/ext/imglib2/AccessibleScaler.java @@ -3,7 +3,6 @@ import net.imglib2.RandomAccessible; import net.imglib2.RandomAccessibleInterval; import net.imglib2.interpolation.InterpolatorFactory; -import net.imglib2.interpolation.randomaccess.NLinearInterpolatorFactory; import net.imglib2.interpolation.randomaccess.NearestNeighborInterpolatorFactory; import net.imglib2.realtransform.AffineGet; import net.imglib2.realtransform.RealViews; @@ -11,6 +10,7 @@ import net.imglib2.realtransform.Translation2D; import net.imglib2.type.numeric.NumericType; import net.imglib2.view.Views; +import qupath.ext.imglib2.interpolators.LinearInterpolationFactory; import java.util.Arrays; import java.util.stream.LongStream; @@ -35,15 +35,16 @@ private AccessibleScaler() { * @param scale the scale to apply to the first two dimensions of the input {@link RandomAccessibleInterval}. Shouldn't be * less than or equal to 0 * @return the input if the provided scale is 1, or a new scaled {@link RandomAccessibleInterval} otherwise - * @param the type of elements of the random accessible interval + * @param the type of elements of the random accessible interval. It must be {@link net.imglib2.type.numeric.ARGBType} + * or an instance of {@link net.imglib2.type.numeric.RealType} * @throws IllegalArgumentException if the input interval has at least one minimum different from 0, if the provided scale is less - * than or equal to 0, or if the input interval has less than two dimensions + * than or equal to 0, if the input interval has less than two dimensions, or if the type T is invalid (see above) */ public static > RandomAccessibleInterval scaleWithLinearInterpolation( RandomAccessibleInterval input, double scale ) { - return scale(input, scale, new NLinearInterpolatorFactory<>()); + return scale(input, scale, new LinearInterpolationFactory<>()); } /** diff --git a/src/main/java/qupath/ext/imglib2/interpolators/ArgbLinearInterpolator.java b/src/main/java/qupath/ext/imglib2/interpolators/ArgbLinearInterpolator.java new file mode 100644 index 0000000..7b63471 --- /dev/null +++ b/src/main/java/qupath/ext/imglib2/interpolators/ArgbLinearInterpolator.java @@ -0,0 +1,65 @@ +package qupath.ext.imglib2.interpolators; + +import net.imglib2.RandomAccessible; +import net.imglib2.interpolation.randomaccess.NLinearInterpolator2D; +import net.imglib2.type.numeric.ARGBType; + +/** + * A {@link NLinearInterpolator2D} that performs intermediary operations with doubles, to avoid precision errors. + */ +class ArgbLinearInterpolator extends NLinearInterpolator2D { + + /** + * Create an instance of this class. + * + * @param randomAccessible the accessible to interpolate + */ + public ArgbLinearInterpolator(RandomAccessible randomAccessible) { + super(randomAccessible); + } + + @Override + public ARGBType get() + { + fillWeights(); + + int pixelValue; + double red = 0; + double green = 0; + double blue = 0; + double alpha = 0; + + pixelValue = target.get().get(); + red += ARGBType.red(pixelValue) * weights[0]; + green += ARGBType.green(pixelValue) * weights[0]; + blue += ARGBType.blue(pixelValue) * weights[0]; + alpha += ARGBType.alpha(pixelValue) * weights[0]; + + target.fwd(0); + pixelValue = target.get().get(); + red += ARGBType.red(pixelValue) * weights[1]; + green += ARGBType.green(pixelValue) * weights[1]; + blue += ARGBType.blue(pixelValue) * weights[1]; + alpha += ARGBType.alpha(pixelValue) * weights[1]; + + target.fwd(1); + pixelValue = target.get().get(); + red += ARGBType.red(pixelValue) * weights[3]; + green += ARGBType.green(pixelValue) * weights[3]; + blue += ARGBType.blue(pixelValue) * weights[3]; + alpha += ARGBType.alpha(pixelValue) * weights[3]; + + target.bck(0); + pixelValue = target.get().get(); + red += ARGBType.red(pixelValue) * weights[2]; + green += ARGBType.green(pixelValue) * weights[2]; + blue += ARGBType.blue(pixelValue) * weights[2]; + alpha += ARGBType.alpha(pixelValue) * weights[2]; + + target.bck(1); + + accumulator.set(ARGBType.rgba(red, green, blue, alpha)); + + return accumulator; + } +} diff --git a/src/main/java/qupath/ext/imglib2/interpolators/LinearInterpolationFactory.java b/src/main/java/qupath/ext/imglib2/interpolators/LinearInterpolationFactory.java new file mode 100644 index 0000000..bc08eff --- /dev/null +++ b/src/main/java/qupath/ext/imglib2/interpolators/LinearInterpolationFactory.java @@ -0,0 +1,49 @@ +package qupath.ext.imglib2.interpolators; + +import net.imglib2.RandomAccessible; +import net.imglib2.RealInterval; +import net.imglib2.RealRandomAccess; +import net.imglib2.interpolation.InterpolatorFactory; +import net.imglib2.type.numeric.ARGBType; +import net.imglib2.type.numeric.NumericType; +import net.imglib2.type.numeric.RealType; + +/** + * An {@link InterpolatorFactory} that implements a 2D linear interpolation. + *

+ * This factory can only work with {@link RandomAccessible} that have 2 dimensions and the {@link ARGBType} or {@link RealType} + * type. If {@link #create(RandomAccessible)} is called with an invalid {@link RandomAccessible}, an {@link IllegalArgumentException} + * is thrown. + * + * @param the type of {@link RandomAccessible} to interpolate + */ +public class LinearInterpolationFactory> implements InterpolatorFactory> { + + @Override + @SuppressWarnings({"unchecked", "rawtypes"}) + public RealRandomAccess create(RandomAccessible randomAccessible) { + if (randomAccessible.numDimensions() != 2) { + throw new IllegalArgumentException(String.format( + "The provided accessible has not 2 dimensions (found %d)", + randomAccessible.numDimensions() + )); + } + + Object type = randomAccessible.randomAccess().get(); + if (type instanceof ARGBType) { + return (RealRandomAccess) new ArgbLinearInterpolator(randomAccessible); + } else if (type instanceof RealType) { + return (RealRandomAccess) new RealLinearInterpolator<>(randomAccessible); + } else { + throw new IllegalArgumentException(String.format( + "The type of the provided accessible %s is unexpected", + type + )); + } + } + + @Override + public RealRandomAccess create(RandomAccessible randomAccessible, RealInterval interval) { + return create(randomAccessible); + } +} diff --git a/src/main/java/qupath/ext/imglib2/interpolators/RealLinearInterpolator.java b/src/main/java/qupath/ext/imglib2/interpolators/RealLinearInterpolator.java new file mode 100644 index 0000000..a9bfad4 --- /dev/null +++ b/src/main/java/qupath/ext/imglib2/interpolators/RealLinearInterpolator.java @@ -0,0 +1,43 @@ +package qupath.ext.imglib2.interpolators; + +import net.imglib2.RandomAccessible; +import net.imglib2.interpolation.randomaccess.NLinearInterpolator2D; +import net.imglib2.type.numeric.RealType; + +/** + * A {@link NLinearInterpolator2D} that performs intermediary operations with doubles, to avoid precision errors. + */ +class RealLinearInterpolator> extends NLinearInterpolator2D { + + /** + * Create an instance of this class. + * + * @param randomAccessible the accessible to interpolate + */ + public RealLinearInterpolator(RandomAccessible randomAccessible) { + super(randomAccessible); + } + + @Override + public T get() + { + fillWeights(); + + double value = target.get().getRealDouble() * weights[0]; + + target.fwd(0); + value += target.get().getRealDouble() * weights[1]; + + target.fwd(1); + value += target.get().getRealDouble() * weights[3]; + + target.bck(0); + value += target.get().getRealDouble() * weights[2]; + + target.bck(1); + + accumulator.setReal(value); + + return accumulator; + } +} From 17b46a6ad0a067e6517ed2c883bfe16e280a7346 Mon Sep 17 00:00:00 2001 From: Leo Leplat <60394504+Rylern@users.noreply.github.com> Date: Thu, 19 Feb 2026 15:43:45 +0000 Subject: [PATCH 2/3] Add tests --- .../qupath/ext/imglib2/AccessibleScaler.java | 5 +- .../ext/imglib2/TestAccessibleScaler.java | 130 ++++++++++++++---- src/test/java/qupath/ext/imglib2/Utils.java | 21 +++ 3 files changed, 129 insertions(+), 27 deletions(-) diff --git a/src/main/java/qupath/ext/imglib2/AccessibleScaler.java b/src/main/java/qupath/ext/imglib2/AccessibleScaler.java index de6dc16..6606856 100644 --- a/src/main/java/qupath/ext/imglib2/AccessibleScaler.java +++ b/src/main/java/qupath/ext/imglib2/AccessibleScaler.java @@ -36,9 +36,10 @@ private AccessibleScaler() { * less than or equal to 0 * @return the input if the provided scale is 1, or a new scaled {@link RandomAccessibleInterval} otherwise * @param the type of elements of the random accessible interval. It must be {@link net.imglib2.type.numeric.ARGBType} - * or an instance of {@link net.imglib2.type.numeric.RealType} + * or an instance of {@link net.imglib2.type.numeric.RealType}, otherwise a {@link IllegalArgumentException} + * will be thrown when accessing the output * @throws IllegalArgumentException if the input interval has at least one minimum different from 0, if the provided scale is less - * than or equal to 0, if the input interval has less than two dimensions, or if the type T is invalid (see above) + * than or equal to 0, or if the input interval has less than two dimensions */ public static > RandomAccessibleInterval scaleWithLinearInterpolation( RandomAccessibleInterval input, diff --git a/src/test/java/qupath/ext/imglib2/TestAccessibleScaler.java b/src/test/java/qupath/ext/imglib2/TestAccessibleScaler.java index 631f649..b620b8e 100644 --- a/src/test/java/qupath/ext/imglib2/TestAccessibleScaler.java +++ b/src/test/java/qupath/ext/imglib2/TestAccessibleScaler.java @@ -4,6 +4,7 @@ import net.imglib2.Point; import net.imglib2.RandomAccess; import net.imglib2.RandomAccessibleInterval; +import net.imglib2.type.numeric.ARGBType; import net.imglib2.type.numeric.real.DoubleType; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; @@ -13,7 +14,7 @@ public class TestAccessibleScaler { @Test void Check_Min_Different_From_Zero() { double scale = 2; - RandomAccessibleInterval accessible = new SampleAccessible(new long[] {0, 3, 0}); + RandomAccessibleInterval accessible = new SampleDoubleAccessible(new long[] {0, 3, 0}); Assertions.assertThrows(IllegalArgumentException.class, () -> AccessibleScaler.scaleWithLinearInterpolation(accessible, scale)); } @@ -21,7 +22,7 @@ void Check_Min_Different_From_Zero() { @Test void Check_Negative_Scale() { double scale = -2; - RandomAccessibleInterval accessible = new SampleAccessible(); + RandomAccessibleInterval accessible = new SampleDoubleAccessible(); Assertions.assertThrows(IllegalArgumentException.class, () -> AccessibleScaler.scaleWithLinearInterpolation(accessible, scale)); } @@ -29,20 +30,20 @@ void Check_Negative_Scale() { @Test void Check_Less_Than_Two_Dimensions() { double scale = 2; - RandomAccessibleInterval accessible = new SampleAccessible(new long[] {0}); + RandomAccessibleInterval accessible = new SampleDoubleAccessible(new long[] {0}); Assertions.assertThrows(IllegalArgumentException.class, () -> AccessibleScaler.scaleWithLinearInterpolation(accessible, scale)); } @Test - void Check_Linear_Interpolation_Scale_With_2_by_4_Array() { - RandomAccessibleInterval accessible = new SampleAccessible(new double[][] { + void Check_Linear_Interpolation_Scale_With_2_by_4_Double_Array() { + RandomAccessibleInterval accessible = new SampleDoubleAccessible(new double[][] { new double[] {0.80, 0.97, 0.40, 0.99}, new double[] {0.95, 0.22, 0.63, 0.25} }); double scale = 0.5; double[][] expectedPixels = new double[][] { - new double[] {0.735, 0.5675} + new double[] {(0.80 + 0.97 + 0.95 + 0.22) / 4, (0.40 + 0.99 + 0.63 + 0.25) / 4} }; RandomAccessibleInterval scaledAccessible = AccessibleScaler.scaleWithLinearInterpolation(accessible, scale); @@ -51,13 +52,13 @@ void Check_Linear_Interpolation_Scale_With_2_by_4_Array() { } @Test - void Check_Linear_Interpolation_Scale_With_1_by_3_Array() { - RandomAccessibleInterval accessible = new SampleAccessible(new double[][] { + void Check_Linear_Interpolation_Scale_With_1_by_3_Double_Array() { + RandomAccessibleInterval accessible = new SampleDoubleAccessible(new double[][] { new double[] {0.26, 0.66, 0.34} }); double scale = 0.5; double[][] expectedPixels = new double[][] { - new double[] {0.46} + new double[] {(0.26 + 0.66) / 2} }; RandomAccessibleInterval scaledAccessible = AccessibleScaler.scaleWithLinearInterpolation(accessible, scale); @@ -66,8 +67,8 @@ void Check_Linear_Interpolation_Scale_With_1_by_3_Array() { } @Test - void Check_Linear_Interpolation_Interpolation_Scale_With_5_by_5_Array() { - RandomAccessibleInterval accessible = new SampleAccessible(new double[][] { + void Check_Linear_Interpolation_Interpolation_Scale_With_5_by_5_Double_Array() { + RandomAccessibleInterval accessible = new SampleDoubleAccessible(new double[][] { new double[] {0.80, 0.72, 0.48, 0.27, 0.68}, new double[] {0.41, 0.21, 0.60, 0.47, 0.86}, new double[] {0.94, 0.32, 0.55, 0.22, 0.46}, @@ -76,8 +77,8 @@ void Check_Linear_Interpolation_Interpolation_Scale_With_5_by_5_Array() { }); double scale = 0.5; double[][] expectedPixels = new double[][] { - new double[] {0.535, 0.455}, - new double[] {0.63, 0.4825} + new double[] {(0.80 + 0.72 + 0.41 + 0.21) / 4, (0.48 + 0.27 + 0.60 + 0.47) / 4}, + new double[] {(0.94 + 0.32 + 0.43 + 0.83) / 4, (0.55 + 0.22 + 0.49 + 0.67) / 4} }; RandomAccessibleInterval scaledAccessible = AccessibleScaler.scaleWithLinearInterpolation(accessible, scale); @@ -85,9 +86,28 @@ void Check_Linear_Interpolation_Interpolation_Scale_With_5_by_5_Array() { Utils.assertRandomAccessibleEquals(scaledAccessible, expectedPixels); } + @Test + void Check_Linear_Interpolation_Scale_With_2_by_4_Argb_Array() { + RandomAccessibleInterval accessible = new SampleArgbAccessible(new int[][] { + new int[] {ARGBType.rgba(82, 79, 61, 46), ARGBType.rgba(67, 94, 89, 62), ARGBType.rgba(13, 33, 3, 42), ARGBType.rgba(92, 41, 35, 54)}, + new int[] {ARGBType.rgba(32, 64, 90, 99), ARGBType.rgba(58, 55, 73, 84), ARGBType.rgba(81, 63, 45, 11), ARGBType.rgba(22, 24, 37, 34)} + }); + double scale = 0.5; + int[][] expectedPixels = new int[][] { + new int[] { + ARGBType.rgba(Math.round((82 + 67 + 32 + 58) / 4.), Math.round((79 + 94 + 64 + 55) / 4.), Math.round((61 + 89 + 90 + 73) / 4.), Math.round((46 + 62 + 99 + 84) / 4.)), + ARGBType.rgba(Math.round((13 + 92 + 22 + 81) / 4.), Math.round((33 + 41 + 24 + 63) / 4.), Math.round((3 + 35 + 37 + 45) / 4.), Math.round((42 + 54 + 34 + 11) / 4.)) + } + }; + + RandomAccessibleInterval scaledAccessible = AccessibleScaler.scaleWithLinearInterpolation(accessible, scale); + + Utils.assertArgbRandomAccessibleEquals(scaledAccessible, expectedPixels); + } + @Test void Check_Nearest_Neighbor_Interpolation_Scale_With_2_by_4_Array() { - RandomAccessibleInterval accessible = new SampleAccessible(new double[][] { + RandomAccessibleInterval accessible = new SampleDoubleAccessible(new double[][] { new double[] {0.80, 0.97, 0.40, 0.99}, new double[] {0.95, 0.22, 0.63, 0.25} }); @@ -103,7 +123,7 @@ void Check_Nearest_Neighbor_Interpolation_Scale_With_2_by_4_Array() { @Test void Check_Nearest_Neighbor_Interpolation_Scale_With_1_by_3_Array() { - RandomAccessibleInterval accessible = new SampleAccessible(new double[][] { + RandomAccessibleInterval accessible = new SampleDoubleAccessible(new double[][] { new double[] {0.26, 0.66, 0.34} }); double scale = 0.5; @@ -118,7 +138,7 @@ void Check_Nearest_Neighbor_Interpolation_Scale_With_1_by_3_Array() { @Test void Check_Nearest_Neighbor_Interpolation_Scale_With_5_by_5_Array() { - RandomAccessibleInterval accessible = new SampleAccessible(new double[][] { + RandomAccessibleInterval accessible = new SampleDoubleAccessible(new double[][] { new double[] {0.80, 0.72, 0.48, 0.27, 0.68}, new double[] {0.41, 0.21, 0.60, 0.47, 0.86}, new double[] {0.94, 0.32, 0.55, 0.22, 0.46}, @@ -136,27 +156,27 @@ void Check_Nearest_Neighbor_Interpolation_Scale_With_5_by_5_Array() { Utils.assertRandomAccessibleEquals(scaledAccessible, expectedPixels); } - private static class SampleAccessible implements RandomAccessibleInterval { + private static class SampleDoubleAccessible implements RandomAccessibleInterval { private final long[] min; private final long[] max; private final double[][] values; - public SampleAccessible() { + public SampleDoubleAccessible() { this(new long[] {0, 0}); } - public SampleAccessible(double[][] values) { + public SampleDoubleAccessible(double[][] values) { this(new long[] {0, 0}, values); } - public SampleAccessible(long[] min) { + public SampleDoubleAccessible(long[] min) { this(min, new double[][] { new double[] {} }); } - private SampleAccessible(long[] min, double[][] values) { + private SampleDoubleAccessible(long[] min, double[][] values) { this.min = min; this.max = new long[] {values.length - 1, values[0].length - 1}; this.values = values; @@ -174,7 +194,7 @@ public long max(int d) { @Override public RandomAccess randomAccess() { - return new SampleAccess(values); + return new SampleDoubleAccess(values); } @Override @@ -188,12 +208,12 @@ public int numDimensions() { } } - private static class SampleAccess extends Point implements RandomAccess { + private static class SampleDoubleAccess extends Point implements RandomAccess { private final DoubleType value = new DoubleType(); private final double[][] values; - public SampleAccess(double[][] values) { + public SampleDoubleAccess(double[][] values) { super(2); this.values = values; @@ -207,7 +227,67 @@ public DoubleType get() { @Override public RandomAccess copy() { - return new SampleAccess(values); + return new SampleDoubleAccess(values); + } + } + + private static class SampleArgbAccessible implements RandomAccessibleInterval { + + private final long[] min = new long[] {0, 0}; + private final long[] max; + private final int[][] values; + + public SampleArgbAccessible(int[][] values) { + this.max = new long[] {values.length - 1, values[0].length - 1}; + this.values = values; + } + + @Override + public long min(int d) { + return min[d]; + } + + @Override + public long max(int d) { + return max[d]; + } + + @Override + public RandomAccess randomAccess() { + return new SampleArgbAccess(values); + } + + @Override + public RandomAccess randomAccess(Interval interval) { + return randomAccess(); + } + + @Override + public int numDimensions() { + return min.length; + } + } + + private static class SampleArgbAccess extends Point implements RandomAccess { + + private final ARGBType value = new ARGBType(); + private final int[][] values; + + public SampleArgbAccess(int[][] values) { + super(2); + + this.values = values; + } + + @Override + public ARGBType get() { + value.set(values[(int) position[0]][(int) position[1]]); + return value; + } + + @Override + public RandomAccess copy() { + return new SampleArgbAccess(values); } } } diff --git a/src/test/java/qupath/ext/imglib2/Utils.java b/src/test/java/qupath/ext/imglib2/Utils.java index 0811576..9548e2f 100644 --- a/src/test/java/qupath/ext/imglib2/Utils.java +++ b/src/test/java/qupath/ext/imglib2/Utils.java @@ -161,6 +161,27 @@ public static > void assertRandomAccessibleEquals(RandomAc } } + public static void assertArgbRandomAccessibleEquals(RandomAccessibleInterval accessible, int[][] expectedPixels) { + Assertions.assertEquals(expectedPixels.length, accessible.dimension(0)); + Assertions.assertEquals(expectedPixels[0].length, accessible.dimension(1)); + + int[] position = new int[accessible.numDimensions()]; + Cursor cursor = accessible.localizingCursor(); + + while (cursor.hasNext()) { + ARGBType pixel = cursor.next(); + cursor.localize(position); + int x = position[1]; + int y = position[0]; + + Assertions.assertEquals( + expectedPixels[y][x], + pixel.get(), + 0.0000000001 // to avoid rounding errors + ); + } + } + public static void assertBufferedImagesEqual(BufferedImage expectedImage, BufferedImage actualImage, double delta) { Assertions.assertEquals(expectedImage.getWidth(), actualImage.getWidth()); Assertions.assertEquals(expectedImage.getHeight(), actualImage.getHeight()); From 2549cc459563b6e12d96694bb40408791e8ec29e Mon Sep 17 00:00:00 2001 From: Leo Leplat <60394504+Rylern@users.noreply.github.com> Date: Thu, 19 Feb 2026 15:44:37 +0000 Subject: [PATCH 3/3] Bump version --- build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle.kts b/build.gradle.kts index 794704b..545a200 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -4,7 +4,7 @@ plugins { } group = "io.github.qupath" -version = "0.1.0" +version = "0.1.1" repositories { mavenCentral()