This is an automated email from the ASF dual-hosted git repository.

asf-gitbox-commits pushed a commit to branch geoapi-4.0
in repository https://gitbox.apache.org/repos/asf/sis.git


The following commit(s) were added to refs/heads/geoapi-4.0 by this push:
     new c0abcda167 Make localization grid tolerant to coordinates rounded to 
nearest integers. https://github.com/apache/sis/pull/48
c0abcda167 is described below

commit c0abcda16726b5215e443b1cdfeb73290d6214cf
Author: Martin Desruisseaux <[email protected]>
AuthorDate: Tue Oct 6 20:40:55 2026 +0200

    Make localization grid tolerant to coordinates rounded to nearest integers.
    https://github.com/apache/sis/pull/48
    
    Co-authored-by: JF3Env <[email protected]>
---
 .../operation/builder/LocalizationGridBuilder.java | 140 +++++++----
 .../operation/builder/package-info.java            |   2 +-
 .../builder/LocalizationGridBuilderTest.java       |  42 +++-
 .../sis/storage/geotiff/reader/Localization.java   |  23 +-
 .../storage/geotiff/reader/LocalizationTest.java   | 270 +++++++++++++++++++++
 .../main/org/apache/sis/math/Vector.java           |  15 +-
 6 files changed, 429 insertions(+), 63 deletions(-)

diff --git 
a/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/operation/builder/LocalizationGridBuilder.java
 
b/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/operation/builder/LocalizationGridBuilder.java
index e635f75e4f..b775adee7e 100644
--- 
a/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/operation/builder/LocalizationGridBuilder.java
+++ 
b/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/operation/builder/LocalizationGridBuilder.java
@@ -87,7 +87,7 @@ import org.opengis.coordinate.MismatchedDimensionException;
  * See the <cite>Linearizers</cite> section in {@link LinearTransformBuilder} 
for more discussion.
  *
  * @author  Martin Desruisseaux (Geomatys)
- * @version 1.2
+ * @version 1.7
  *
  * @see InterpolatedTransform
  * @see LinearTransform
@@ -96,11 +96,6 @@ import org.opengis.coordinate.MismatchedDimensionException;
  * @since 0.8
  */
 public class LocalizationGridBuilder extends TransformBuilder {
-    /**
-     * Tolerance threshold for comparing pixel coordinates relative to integer 
values.
-     */
-    private static final double EPS = Numerics.COMPARISON_THRESHOLD;
-
     /**
      * The transform for the linear part.
      * Always created with a grid size specified to the constructor.
@@ -116,6 +111,8 @@ public class LocalizationGridBuilder extends 
TransformBuilder {
     /**
      * Conversions from source real-world coordinates to grid indices before 
interpolation.
      * If there is no such conversion to apply, then this is the identity 
transform.
+     *
+     * @see #getSourceToGrid()
      */
     private LinearTransform sourceToGrid;
 
@@ -180,9 +177,9 @@ public class LocalizationGridBuilder extends 
TransformBuilder {
      * @throws ArithmeticException if this constructor cannot infer a 
reasonable grid size from the given vectors.
      */
     public LocalizationGridBuilder(final Vector sourceX, final Vector sourceY) 
{
-        final Matrix fromGrid = new Matrix3();
-        final int width  = infer(sourceX, fromGrid, 0);
-        final int height = infer(sourceY, fromGrid, 1);
+        final var fromGrid = new Matrix3();
+        final int width    = infer(sourceX, fromGrid, 0);
+        final int height   = infer(sourceY, fromGrid, 1);
         linearBuilder = new LinearTransformBuilder(width, height);
         try {
             sourceToGrid = MathTransforms.linear(fromGrid).inverse();
@@ -227,7 +224,7 @@ public class LocalizationGridBuilder extends 
TransformBuilder {
                 final Vector[] sources = localizations.sources();
                 n = sources.length;
                 if (n == SOURCE_DIMENSION) {
-                    final Matrix fromGrid = new Matrix3();
+                    final var fromGrid = new Matrix3();
                     final int width  = infer(sources[0], fromGrid, 0);
                     final int height = infer(sources[1], fromGrid, 1);
                     linearBuilder = new LinearTransformBuilder(width, height);
@@ -247,35 +244,83 @@ public class LocalizationGridBuilder extends 
TransformBuilder {
     }
 
     /**
-     * Infers a grid size by searching for the greatest common divisor (GCD) 
for values in the given vector.
+     * Infers a grid size by searching for a constant increment between all 
values in the given vector.
      * The vector values should be integers, but this method is tolerant to 
constant offsets (typically 0.5).
-     * The GCD is taken as a "grid to source" scale factor and the minimal 
value as the translation term.
+     * The increment is taken as a "grid to source" scale factor and the 
minimal value as the translation term.
      * Those two values are stored in the {@code dim} row of the given matrix.
      *
-     * @param  source    the vector of values for which to get the GCD and 
minimum value.
-     * @param  fromGrid  matrix where to store the minimum value and the GCD.
+     * @param  source    the vector of values for which to get the increment 
and minimum value.
+     * @param  fromGrid  matrix where to store the minimum value and the 
increment.
      * @param  dim       index of the matrix row to update.
      * @return grid size.
+     * @throws ArithmeticException if the grid size cannot be computed.
      */
     private static int infer(final Vector source, final Matrix fromGrid, final 
int dim) {
         final NumberRange<?> range = source.range();
-        final double min  = range.getMinDouble(true);
-        final double span = range.getSpan();
-        final Number increment = source.increment(EPS * span);
-        double inc;
-        if (increment != null) {
-            inc = increment.doubleValue();
+        final double min   = range.getMinDouble(true);
+        final double span  = range.getSpan();
+        final int    size  = source.size();
+        final Number scale = source.increment(span / (size - 1) * 
DEFAULT_PRECISION);
+        double increment;
+        if (scale != null) {
+            increment = Math.abs(scale.doubleValue());
         } else {
-            inc = span;
-            final int size = source.size();
+            /*
+             * Initialize the increment to the difference between the two 
values closest to zero.
+             * They are likely to be the two most accurate values, thus 
reducing rounding errors.
+             * The block is for keeping variables in local scope.
+             */
+            {
+                // Find the value closest to zero.
+                double zero = Double.POSITIVE_INFINITY;
+                double abs0 = Double.POSITIVE_INFINITY;
+                for (int i=0; i<size; i++) {
+                    final double value = source.doubleValue(i);
+                    final double abs   = Math.abs(value);
+                    if (abs < abs0) {
+                        abs0 = abs;
+                        zero = value;
+                        if (zero == 0) break;       // Optimization for a 
common case.
+                    }
+                }
+                // Find the second value closest to zero.
+                double tolerance = abs0 + span / (Math.sqrt(size) - 1) * 
DEFAULT_PRECISION;   // Assuming a square grid.
+                double closeZero = Double.POSITIVE_INFINITY;
+                abs0 = Double.POSITIVE_INFINITY;
+                for (int i=0; i<size; i++) {
+                    final double value = source.doubleValue(i);
+                    final double abs   = Math.abs(value);
+                    if (abs < abs0 && abs > tolerance) {
+                        abs0 = abs;
+                        closeZero = value;
+                    }
+                }
+                increment = Math.abs(closeZero - zero);
+                /*
+                 * Use the increment for computing the expected number of 
distinct values.
+                 * Then, re-compute the increment as the average delta over 
all the span.
+                 * It will be the same increment if the delta between values 
is constant,
+                 * but may differ if the delta is not constant.
+                 */
+                final double average = span / Math.rint(span / increment);
+                if (Math.abs(average - increment) > span * DEFAULT_PRECISION 
|| Numerics.isInteger(average)) {
+                    increment = average;
+                }
+            }
+            /*
+             * If the vector contains only integer values, it may be because 
all coordinates have been rounded
+             * toward nearest integer. In such case, tolerate a difference 
corresponding to the rounding errors.
+             * Then verifies that all values in the vector are multiples of 
the increment (ignoring `min` offset).
+             */
+            double tolerance = increment * DEFAULT_PRECISION;
+            if (tolerance < 0.5 && source.isInteger()) {
+                tolerance = 0.5;
+            }
             for (int i=0; i<size; i++) {
-                double v = source.doubleValue(i) - min;
-                if (Math.abs(v % inc) > EPS) {
-                    do {
-                        final double r = (inc % v);     // Both `inc` and `v` 
are positive, so `r` will be positive too.
-                        inc = v;
-                        v = r;
-                    } while (Math.abs(v) > EPS);
+                final double remainder = (source.doubleValue(i) - min) % 
increment;
+                if (remainder > tolerance && (increment - remainder) > 
tolerance) {
+                    increment = 0;  // Will cause an exception to be thrown 
below.
+                    break;
                 }
             }
         }
@@ -285,15 +330,22 @@ public class LocalizationGridBuilder extends 
TransformBuilder {
          * would fail anyway, because it would contain holes where no value is 
defined. A limit is important
          * for preventing useless allocation of large arrays - 
https://issues.apache.org/jira/browse/SIS-407
          */
-        fromGrid.setElement(dim, dim, inc);
+        fromGrid.setElement(dim, dim, increment);
         fromGrid.setElement(dim, SOURCE_DIMENSION, min);
-        final double n = span / inc;
+        final double n = span / increment;
         if (n >= 0.5 && n < source.size() - 0.5) {          // Compare as 
`double` in case the value is large.
             return ((int) Math.round(n)) + 1;
         }
         throw new 
ArithmeticException(Resources.format(Resources.Keys.CanNotInferGridSizeFromValues_1,
 range));
     }
 
+    /**
+     * Returns the grid size for the given dimension.
+     */
+    final int gridSize(final int srcDim) {
+        return linearBuilder.gridSize(srcDim);
+    }
+
     /**
      * Throws {@link IllegalStateException} if this builder cannot be modified 
anymore.
      */
@@ -657,11 +709,11 @@ public class LocalizationGridBuilder extends 
TransformBuilder {
             if (isExact) {
                 step = MathTransforms.concatenate(sourceToGrid, gridToCoord);
             } else {
-                final int      width    = linearBuilder.gridSize(0);
-                final int      height   = linearBuilder.gridSize(1);
-                final float[]  residual = new float [SOURCE_DIMENSION * 
linearBuilder.gridLength];
-                final double[] grid     = new double[SOURCE_DIMENSION * width];
-                double gridPrecision    = precision;
+                final int width    = gridSize(0);
+                final int height   = gridSize(1);
+                final var residual = new float [SOURCE_DIMENSION * 
linearBuilder.gridLength];
+                final var grid     = new double[SOURCE_DIMENSION * width];
+                double gridPrecision = precision;
                 try {
                     /*
                      * If the user specified a precision, we need to convert 
it from source units to grid units.
@@ -761,7 +813,7 @@ public class LocalizationGridBuilder extends 
TransformBuilder {
      *
      * @since 1.1
      */
-    public Optional<Map.Entry<String,MathTransform>> linearizer(final boolean 
ifNotCompensated) {
+    public Optional<Map.Entry<String, MathTransform>> linearizer(final boolean 
ifNotCompensated) {
         ProjectedTransformTry linearizer = linearBuilder.appliedLinearizer();
         if (ifNotCompensated && linearizer != null && 
linearizer.reverseAfterLinearization) {
             linearizer = null;
@@ -803,10 +855,10 @@ public class LocalizationGridBuilder extends 
TransformBuilder {
         if (mt == null) {
             throw new 
IllegalStateException(Errors.format(Errors.Keys.Uninitialized_1, 
getClass().getSimpleName()));
         }
-        final int           tgtDim = mt.getTargetDimensions();
-        final double[]      point  = new double[Math.max(tgtDim, 
SOURCE_DIMENSION)];
-        final Statistics[]  stats  = new Statistics[tgtDim + SOURCE_DIMENSION];
-        final StringBuilder buffer = new StringBuilder();
+        final int tgtDim = mt.getTargetDimensions();
+        final var point  = new double[Math.max(tgtDim, SOURCE_DIMENSION)];
+        final var stats  = new Statistics[tgtDim + SOURCE_DIMENSION];
+        final var buffer = new StringBuilder();
         for (int i=0; i<stats.length; i++) {
             buffer.setLength(0);
             buffer.append('Δ');
@@ -835,8 +887,8 @@ public class LocalizationGridBuilder extends 
TransformBuilder {
         } catch (NoninvertibleTransformException e) {
             throw new IllegalStateException(e);
         }
-        final int width  = linearBuilder.gridSize(0);
-        final int height = linearBuilder.gridSize(1);
+        final int width  = gridSize(0);
+        final int height = gridSize(1);
         for (int y=0; y<height; y++) {
             for (int x=0; x<width; x++) {
                 point[0] = gridCoordinates[0] = x;
@@ -893,7 +945,7 @@ public class LocalizationGridBuilder extends 
TransformBuilder {
      * @since 1.1
      */
     public String toString(final boolean linear, final Locale locale) {
-        final StringBuilder buffer = new StringBuilder(400);
+        final var buffer = new StringBuilder(400);
         String lineSeparator = null;
         try {
             lineSeparator = linearBuilder.appendTo(buffer, getClass(), locale, 
Vocabulary.Keys.LinearTransformation);
diff --git 
a/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/operation/builder/package-info.java
 
b/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/operation/builder/package-info.java
index a21d184cd3..39994212b8 100644
--- 
a/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/operation/builder/package-info.java
+++ 
b/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/operation/builder/package-info.java
@@ -30,7 +30,7 @@
  * convenience.</p>
  *
  * @author  Martin Desruisseaux (Geomatys)
- * @version 1.2
+ * @version 1.7
  * @since   0.5
  */
 package org.apache.sis.referencing.operation.builder;
diff --git 
a/endorsed/src/org.apache.sis.referencing/test/org/apache/sis/referencing/operation/builder/LocalizationGridBuilderTest.java
 
b/endorsed/src/org.apache.sis.referencing/test/org/apache/sis/referencing/operation/builder/LocalizationGridBuilderTest.java
index 9ffdeddf1a..3b05075caa 100644
--- 
a/endorsed/src/org.apache.sis.referencing/test/org/apache/sis/referencing/operation/builder/LocalizationGridBuilderTest.java
+++ 
b/endorsed/src/org.apache.sis.referencing/test/org/apache/sis/referencing/operation/builder/LocalizationGridBuilderTest.java
@@ -16,11 +16,14 @@
  */
 package org.apache.sis.referencing.operation.builder;
 
+import java.util.Arrays;
 import java.awt.geom.Point2D;
 import java.awt.geom.AffineTransform;
 import org.opengis.util.FactoryException;
+import org.opengis.referencing.operation.Matrix;
 import org.opengis.referencing.operation.TransformException;
 import org.apache.sis.geometry.Envelope2D;
+import org.apache.sis.math.Vector;
 
 // Test dependencies
 import org.junit.jupiter.api.Test;
@@ -39,6 +42,12 @@ import static 
org.apache.sis.referencing.Assertions.assertEnvelopeEquals;
 @SuppressWarnings("exports")
 @ExtendWith(FailureDetailsReporter.class)
 public final class LocalizationGridBuilderTest extends TransformTestCase {
+    /**
+     * Whether to generate assertion codes.
+     * If enabled, the code is sent to the standard output stream.
+     */
+    private static final boolean GENERATE_TEST_CODE = false;
+
     /**
      * Creates a new test case.
      */
@@ -56,7 +65,7 @@ public final class LocalizationGridBuilderTest extends 
TransformTestCase {
      */
     @SuppressWarnings("UseOfSystemOutOrSystemErr")
     private static LocalizationGridBuilder builder(final AffineTransform 
reference, final int width, final int height) {
-        final LocalizationGridBuilder builder = new 
LocalizationGridBuilder(width, height);
+        final var builder = new LocalizationGridBuilder(width, height);
         Point2D pt = new Point2D.Double();
         for (int gridY=0; gridY < height; gridY++) {
             for (int gridX=0; gridX < width; gridX++) {
@@ -67,7 +76,7 @@ public final class LocalizationGridBuilderTest extends 
TransformTestCase {
                 final double x = pt.getX() + 0.4*gx2 + 0.7*gy2;
                 final double y = pt.getY() + 0.3*gx2 - 0.5*gy2;
                 builder.setControlPoint(gridX, gridY, x, y);
-                if (false) {
+                if (GENERATE_TEST_CODE) {
                     // For generating verification code.
                     System.out.printf("verifyTransform(new double[] {%d, %d}, 
new double[] {%f, %f});%n", gridX, gridY, x, y);
                 }
@@ -84,7 +93,7 @@ public final class LocalizationGridBuilderTest extends 
TransformTestCase {
      */
     @Test
     public void testQuadratic() throws FactoryException, TransformException {
-        final AffineTransform reference = new AffineTransform(20, -30, 5, -4, 
-20, 8);
+        final var reference = new AffineTransform(20, -30, 5, -4, -20, 8);
         final LocalizationGridBuilder builder = builder(reference, 5, 4);
         builder.setDesiredPrecision(1E-6);
         transform = builder.create(null);
@@ -122,14 +131,14 @@ public final class LocalizationGridBuilderTest extends 
TransformTestCase {
      */
     @Test
     public void testCreateFromLocalizations() throws TransformException {
-        final LinearTransformBuilder localizations = new 
LinearTransformBuilder();
+        final var localizations = new LinearTransformBuilder();
         localizations.setControlPoint(new int[] {0, 0}, new double[] {-20.0,   
 8.0});
         localizations.setControlPoint(new int[] {1, 0}, new double[] {  0.4,  
-21.7});
         localizations.setControlPoint(new int[] {0, 1}, new double[] {-14.3,   
 3.5});
         localizations.setControlPoint(new int[] {1, 1}, new double[] {  6.1,  
-26.2});
         localizations.setControlPoint(new int[] {0, 2}, new double[] {  1.3,   
-8.5});
         localizations.setControlPoint(new int[] {1, 2}, new double[] { 87.7, 
-123.7});
-        LocalizationGridBuilder builder = new 
LocalizationGridBuilder(localizations);
+        final var builder = new LocalizationGridBuilder(localizations);
         /*
          * Verifies the grid size by checking the source envelope.
          * Minimum and maximum values are inclusive.
@@ -148,4 +157,27 @@ public final class LocalizationGridBuilderTest extends 
TransformTestCase {
         assertArrayEquals(new double[] {-8.5, -123.7}, builder.getRow(1, 
2).doubleValues());
         assertArrayEquals(new double[] {-21.7, -26.2, -123.7}, 
builder.getColumn(1, 1).doubleValues());
     }
+
+    /**
+     * Tests inferring the grid size from the vectors of <var>x</var> and 
<var>y</var> values.
+     * This test uses a non-integer delta between grid coordinates in order to 
test robustness
+     * against rounding errors.
+     */
+    @Test
+    public void testInferGridSize() {
+        final var x = new double[39];
+        final var y = new double[40];
+        final double sx = 19249d/38d;
+        final double sy =  19509/39d;
+        Arrays.setAll(x, (i) -> -1d/3d + (i % 10) * sx + 1E-12 * 
StrictMath.random());
+        Arrays.setAll(y, (i) -> -1d/6d + (i / 10) * sy + 1E-12 * 
StrictMath.random());
+        final var builder = new LocalizationGridBuilder(Vector.create(x), 
Vector.create(y));
+        assertEquals(10, builder.gridSize(0), "width");
+        assertEquals( 4, builder.gridSize(1), "height");
+        final Matrix m = builder.getSourceToGrid().getMatrix();
+        assertEquals(3, m.getNumCol());
+        assertEquals(3, m.getNumRow());
+        assertEquals(1/sx, m.getElement(0, 0), 1E-16);
+        assertEquals(1/sy, m.getElement(1, 1), 1E-16);
+    }
 }
diff --git 
a/endorsed/src/org.apache.sis.storage.geotiff/main/org/apache/sis/storage/geotiff/reader/Localization.java
 
b/endorsed/src/org.apache.sis.storage.geotiff/main/org/apache/sis/storage/geotiff/reader/Localization.java
index 78cbc7e196..731c24b302 100644
--- 
a/endorsed/src/org.apache.sis.storage.geotiff/main/org/apache/sis/storage/geotiff/reader/Localization.java
+++ 
b/endorsed/src/org.apache.sis.storage.geotiff/main/org/apache/sis/storage/geotiff/reader/Localization.java
@@ -40,6 +40,7 @@ import org.apache.sis.math.Vector;
  * if another data store needs similar functionality in the future.
  *
  * @author  Martin Desruisseaux (Geomatys)
+ * @author  Jonatas Fischer
  */
 final class Localization {
     /**
@@ -80,7 +81,7 @@ final class Localization {
      * @param  addTo           if non-null, add the transform result to this 
map.
      * @return the "grid to CRS" transform backed by the localization grid.
      */
-    private static MathTransform localizationGrid(final Vector modelTiePoints, 
final Map<Envelope,MathTransform> addTo)
+    private static MathTransform localizationGrid(final Vector modelTiePoints, 
final Map<Envelope, MathTransform> addTo)
             throws FactoryException, TransformException
     {
         final int size = modelTiePoints.size();
@@ -89,7 +90,7 @@ final class Localization {
         final Vector x = modelTiePoints.subSampling(0, RECORD_LENGTH, n);
         final Vector y = modelTiePoints.subSampling(1, RECORD_LENGTH, n);
         try {
-            final LocalizationGridBuilder grid = new 
LocalizationGridBuilder(x, y);
+            final var grid = new LocalizationGridBuilder(x, y);
             final LinearTransform sourceToGrid = grid.getSourceToGrid();
             final double[] coordinates = new double[2];
             for (int i=0; i<size; i += RECORD_LENGTH) {
@@ -124,8 +125,12 @@ final class Localization {
              *    │         2        │ 3 │
              *    └──────────────────┴───┘
              *                    splitX
+             *
+             * If the irregular spacing is on a single axis, then the 
threshold of the other axis is NaN,
+             * the comparisons against it are always false and only two of the 
four parts receive points.
+             * The empty parts are skipped.
              */
-            final Set<Double> uniques = new HashSet<>(100);
+            final var uniques = new HashSet<Double>(100);
             final double splitX = threshold(x, uniques);
             final double splitY = threshold(y, uniques);
             if (Double.isNaN(splitX) && Double.isNaN(splitY)) {
@@ -180,13 +185,15 @@ final class Localization {
              * valid only in a sub-area. Put those information in a map for 
MathTransforms.specialize(…).
              */
             MathTransform global = null;
-            final Map<Envelope,MathTransform> specialization = new 
LinkedHashMap<>(4);
+            final var specialization = new LinkedHashMap<Envelope, 
MathTransform>(4);
             for (int i=0; i<indices.length; i++) {
                 final Vector sub = modelTiePoints.pick(indices[i]);
-                if (i == largestPart) {
-                    global = localizationGrid(sub, null);
-                } else {
-                    localizationGrid(sub, specialization);
+                if (!sub.isEmpty()) {
+                    if (i == largestPart) {
+                        global = localizationGrid(sub, null);
+                    } else {
+                        localizationGrid(sub, specialization);
+                    }
                 }
             }
             return MathTransforms.specialize(global, specialization);
diff --git 
a/endorsed/src/org.apache.sis.storage.geotiff/test/org/apache/sis/storage/geotiff/reader/LocalizationTest.java
 
b/endorsed/src/org.apache.sis.storage.geotiff/test/org/apache/sis/storage/geotiff/reader/LocalizationTest.java
new file mode 100644
index 0000000000..111ea6a9a9
--- /dev/null
+++ 
b/endorsed/src/org.apache.sis.storage.geotiff/test/org/apache/sis/storage/geotiff/reader/LocalizationTest.java
@@ -0,0 +1,270 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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.
+ */
+package org.apache.sis.storage.geotiff.reader;
+
+import org.opengis.referencing.operation.MathTransform;
+import org.apache.sis.math.Vector;
+
+// Test dependencies
+import org.junit.jupiter.api.Test;
+import static org.junit.jupiter.api.Assertions.*;
+import org.apache.sis.test.TestCase;
+
+
+/**
+ * Tests the construction of a localization grid from GeoTIFF tie points.
+ * The tie point spacings tested here are the spacings observed in real 
products:
+ * Sentinel 1 images, and ICEYE images in Ground Range Detected, Single Look 
Complex
+ * and ScanSAR flavors.
+ *
+ * <p>All tests build the tie points from a slightly non-linear model, then 
verify that the
+ * resulting transform maps each tie point to the model coordinates declared 
in the same record.
+ * The model has to be non-linear, otherwise every candidate transform would 
reproduce the tie
+ * points exactly and the tests would only verify the absence of exception.</p>
+ *
+ * @author  Jonatas Fischer
+ */
+public final class LocalizationTest extends TestCase {
+    /**
+     * Tolerance threshold, in degrees, when comparing tie point coordinates.
+     * This is about one metre, while the tie points of the grids tested here
+     * are kilometres apart.
+     */
+    private static final double TOLERANCE = 1E-5;
+
+    /**
+     * Number of tie points on each axis of most grids tested here.
+     * This is the number of tie points in <abbr>ICEYE</abbr> products.
+     */
+    private static final int GRID_SIZE = 10;
+
+    /**
+     * Creates a new test case.
+     */
+    public LocalizationTest() {
+    }
+
+    /**
+     * Returns the pixel coordinates of {@code count} evenly spaced tie points.
+     * The first coordinate is zero and the last coordinate is the given value.
+     * The coordinates are not necessarily integers.
+     *
+     * @param  count  number of tie points.
+     * @param  last   pixel coordinate of the last tie point, usually the 
image size minus 1.
+     * @return pixel coordinates of the tie points.
+     */
+    private static double[] evenSpacing(final int count, final double last) {
+        final double[] coordinates = new double[count];
+        final double step = last / (count - 1.0);
+        for (int i=0; i<count; i++) {
+            coordinates[i] = i * step;
+        }
+        return coordinates;
+    }
+
+    /**
+     * Returns the pixel coordinates of {@value #GRID_SIZE} evenly spaced tie 
points,
+     * rounded to integers as in the GeoTIFF files written by 
<abbr>ICEYE</abbr>.
+     * Consequently the steps may differ from each other by one pixel.
+     *
+     * @param  last  pixel coordinate of the last tie point, usually the image 
size minus 1.
+     * @return pixel coordinates of the tie points.
+     */
+    private static double[] roundedSpacing(final int last) {
+        final double[] coordinates = evenSpacing(GRID_SIZE, last);
+        for (int i=0; i<coordinates.length; i++) {
+            coordinates[i] = Math.round(coordinates[i]);
+        }
+        return coordinates;
+    }
+
+    /**
+     * Returns the pixel coordinates of {@value #GRID_SIZE} tie points spaced 
by the given step,
+     * except the last point which is closer to its predecessor. This is the 
spacing of Sentinel 1
+     * images, and the reason why {@link Localization} splits irregular grids 
in four parts.
+     *
+     * @param  step  step between two consecutive tie points, except the last 
two.
+     * @param  last  step between the two last tie points.
+     * @return pixel coordinates of the tie points.
+     */
+    private static double[] shorterLastStep(final int step, final int last) {
+        final double[] coordinates = new double[GRID_SIZE];
+        for (int i=0; i<GRID_SIZE; i++) {
+            coordinates[i] = i * step;
+        }
+        coordinates[GRID_SIZE-1] =last;
+        return coordinates;
+    }
+
+    /**
+     * Creates tie points on the grid formed by the given pixel coordinates. 
Model coordinates are computed
+     * by an arbitrary non-linear function of the pixel coordinates, in order 
to give the localization grid
+     * something to interpolate. The magnitude of the non-linear terms is 
about 10 metres,
+     * which is the order of magnitude of the terrain-induced distortion in a 
radar image.
+     *
+     * @param  columns  pixel coordinates of the tie points along the 
<var>x</var> axis.
+     * @param  rows     pixel coordinates of the tie points along the 
<var>y</var> axis.
+     * @return the (I,J,K,X,Y,Z) records of the tie points.
+     */
+    private static Vector tiePoints(final double[] columns, final double[] 
rows) {
+        final double[] records = new double[columns.length * rows.length * 
Localization.RECORD_LENGTH];
+        int p = 0;
+        for (final double y : rows) {
+            for (final double x : columns) {
+                records[p++] = x;
+                records[p++] = y;
+                records[p++] = 0;
+                records[p++] = -66 + x*1E-6 + y*3E-8 + (x*y)*2E-13;
+                records[p++] =  45 - y*1E-6 + x*5E-8 - (x*x)*1E-13;
+                records[p++] = 0;
+            }
+        }
+        return Vector.create(records, false);
+    }
+
+    /**
+     * Builds the localization grid for the given tie points, then verifies 
that the resulting
+     * transform maps the pixel coordinates of each tie point to the model 
coordinates declared
+     * in the same record.
+     *
+     * @param  columns  pixel coordinates of the tie points along the 
<var>x</var> axis.
+     * @param  rows     pixel coordinates of the tie points along the 
<var>y</var> axis.
+     * @throws Exception if the transform cannot be created or used.
+     */
+    private static void verify(final double[] columns, final double[] rows) 
throws Exception {
+        final Vector tiePoints = tiePoints(columns, rows);
+        final MathTransform gridToCRS = Localization.nonLinear(tiePoints);
+        assertNotNull(gridToCRS);
+        final double[] source = new double[2];
+        final double[] target = new double[2];
+        for (int i=0; i<tiePoints.size(); i += Localization.RECORD_LENGTH) {
+            source[0] = tiePoints.doubleValue(i);
+            source[1] = tiePoints.doubleValue(i+1);
+            gridToCRS.transform(source, 0, target, 0, 1);
+            assertEquals(tiePoints.doubleValue(i+3), target[0], TOLERANCE, 
"Longitude of tie point");
+            assertEquals(tiePoints.doubleValue(i+4), target[1], TOLERANCE, 
"Latitude of tie point");
+        }
+    }
+
+    /**
+     * Tests a grid where all points are evenly spaced by an integer amount of 
pixels. This is the case
+     * handled directly by {@link 
org.apache.sis.referencing.operation.builder.LocalizationGridBuilder},
+     * without any of the fallbacks tested by the other methods.
+     *
+     * @throws Exception if the transform cannot be created or used.
+     */
+    @Test
+    public void testRegularGrid() throws Exception {
+        final double[] coordinates = new double[GRID_SIZE];
+        for (int i=1; i<GRID_SIZE; i++) {
+            coordinates[i] = coordinates[i-1] + 2222;
+        }
+        verify(coordinates, coordinates);
+    }
+
+    /**
+     * Tests a grid where all points are evenly spaced, but by a fractional 
amount of pixels.
+     * The greatest common divisor of those coordinates is much smaller than 
the actual step,
+     * so the grid size cannot be inferred from it.
+     *
+     * <p>This is the case of <abbr>ICEYE</abbr> ScanSAR images of 19250 × 
19510 pixels,
+     * which have 39 × 40 tie points spaced by 506.55 and 500.23 pixels 
respectively.</p>
+     *
+     * @throws Exception if the transform cannot be created or used.
+     */
+    @Test
+    public void testGridWithFractionalSpacing() throws Exception {
+        verify(evenSpacing(39, 19249),
+               evenSpacing(40, 19509));
+    }
+
+    /**
+     * Tests a grid where the steps differ by one pixel on a single axis. Only 
two of the four parts
+     * in which {@code Localization} would split such a grid receive points; 
the empty parts shall
+     * not cause an {@link IndexOutOfBoundsException}.
+     *
+     * <p>This is the case of ICEYE Single Look Complex images of 114644 × 
16714 pixels:
+     * the tie points are spaced by 12738 pixels along <var>x</var> except one 
step of 12739 pixels,
+     * and evenly spaced by 1857 pixels along <var>y</var>.</p>
+     *
+     * @throws Exception if the transform cannot be created or used.
+     */
+    @Test
+    public void testGridWithIrregularStepOnOneAxis() throws Exception {
+        verify(roundedSpacing(114643),
+               roundedSpacing(16713));
+    }
+
+    /**
+     * Tests a grid where the steps differ by one pixel on both axes.
+     * This is the case of ICEYE Ground Range Detected images of 20000 × 20000 
pixels:
+     * the tie points are spaced by 2222 pixels except one step of 2223 pixels 
on each axis.
+     *
+     * @throws Exception if the transform cannot be created or used.
+     */
+    @Test
+    public void testGridWithIrregularStepOnBothAxes() throws Exception {
+        verify(roundedSpacing(19999),
+               roundedSpacing(19999));
+    }
+
+    /**
+     * Tests a grid where the steps alternate between two values differing by 
one pixel.
+     * This is the case of ICEYE Single Look Complex images of 34484 × 15342 
pixels:
+     * the tie points are spaced by 3831 and 3832 pixels alternately along 
<var>x</var>,
+     * and by 1705 and 1704 pixels alternately along <var>y</var>. Splitting 
such a grid
+     * gives parts that are still irregular, so the split has to recurse.
+     *
+     * @throws Exception if the transform cannot be created or used.
+     */
+    @Test
+    public void testGridWithAlternatingSteps() throws Exception {
+        verify(roundedSpacing(34483),
+               roundedSpacing(15341));
+    }
+
+    /**
+     * Tests a grid where the last step is genuinely shorter than the other 
steps,
+     * as in Sentinel 1 images where the tie points are spaced by 1320 pixels 
except
+     * the last two which are 1302 pixels apart. Contrarily to the ICEYE 
grids, the
+     * spacing of this grid is not uniform up to a rounding to integers, so it 
has to
+     * be handled by splitting the grid in parts.
+     *
+     * @throws Exception if the transform cannot be created or used.
+     */
+    @Test
+    public void testGridWithShorterLastStep() throws Exception {
+        final double[] coordinates = shorterLastStep(1320, 1302);
+        verify(coordinates, coordinates);
+    }
+
+    /**
+     * Tests a grid where the last step is genuinely shorter on a single axis.
+     * This combines the Sentinel 1 spacing with the empty parts of
+     * {@link #testGridWithIrregularStepOnOneAxis()}.
+     *
+     * @throws Exception if the transform cannot be created or used.
+     */
+    @Test
+    public void testGridWithShorterLastStepOnOneAxis() throws Exception {
+        final double[] rows = new double[GRID_SIZE];
+        for (int i=1; i<GRID_SIZE; i++) {
+            rows[i] = rows[i-1] + 1320;
+        }
+        verify(shorterLastStep(1320, 1302), rows);
+    }
+}
diff --git 
a/endorsed/src/org.apache.sis.util/main/org/apache/sis/math/Vector.java 
b/endorsed/src/org.apache.sis.util/main/org/apache/sis/math/Vector.java
index 71af8410fb..517d867515 100644
--- a/endorsed/src/org.apache.sis.util/main/org/apache/sis/math/Vector.java
+++ b/endorsed/src/org.apache.sis.util/main/org/apache/sis/math/Vector.java
@@ -90,7 +90,7 @@ import org.apache.sis.system.Loggers;
  * method and by accepting buffer in the {@link #create(Object, boolean)} 
method.
  *
  * @author  Martin Desruisseaux (MPO, Geomatys)
- * @version 1.6
+ * @version 1.7
  *
  * @see org.apache.sis.util.collection.IntegerList
  *
@@ -912,12 +912,17 @@ search:     for (;;) {
      */
     @SuppressWarnings("ReturnOfCollectionOrArrayField")
     public Vector subSampling(final int first, final int step, final int 
length) {
-        final int size = size();
-        if (step == 1 && first == 0 && length == size) {
+        int limit = size();
+        if (step == 1 && first == 0 && length == limit) {
             return this;
         }
-        final long last = first + step * (length - 1L);
-        if (first < 0 || first >= size || last < 0 || last >= size || length < 
0) {
+        ArgumentChecks.ensurePositive("length", length);
+        long last = first;
+        if (length != 0) {
+            last += step * (length - 1L);
+            limit--;
+        }
+        if ((first | last) < 0 || Math.max(first, last) > limit) {
             final short key;
             final Object arg1, arg2;
             if (step == 1) {

Reply via email to