sis-commits mailing list archives

Site index · List index
Message view « Date » · « Thread »
Top « Date » · « Thread »
From desruisse...@apache.org
Subject [sis] branch geoapi-4.0 updated: Add a LinearTransformBuilder.getControlPoints() method in complement to setControlPoints(Map). Use that new method for adding a LocalizationGridBuilder(LinearTransformBuilder) constructor.
Date Sat, 06 Oct 2018 00:40:18 GMT
This is an automated email from the ASF dual-hosted git repository.

desruisseaux 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 27b57b6  Add a LinearTransformBuilder.getControlPoints() method in complement to
setControlPoints(Map). Use that new method for adding a LocalizationGridBuilder(LinearTransformBuilder)
constructor.
27b57b6 is described below

commit 27b57b629a5b1d49eadc6d1d6622433d89f58255
Author: Martin Desruisseaux <martin.desruisseaux@geomatys.com>
AuthorDate: Fri Oct 5 20:39:06 2018 -0400

    Add a LinearTransformBuilder.getControlPoints() method in complement to setControlPoints(Map).
    Use that new method for adding a LocalizationGridBuilder(LinearTransformBuilder) constructor.
---
 .../operation/builder/LinearTransformBuilder.java  | 332 ++++++++++++++++++++-
 .../operation/builder/LocalizationGridBuilder.java |  54 +++-
 .../builder/LinearTransformBuilderTest.java        |  81 ++++-
 .../builder/LocalizationGridBuilderTest.java       |  39 ++-
 .../java/org/apache/sis/util/resources/Errors.java |   5 +
 .../apache/sis/util/resources/Errors.properties    |   1 +
 .../apache/sis/util/resources/Errors_fr.properties |   1 +
 7 files changed, 494 insertions(+), 19 deletions(-)

diff --git a/core/sis-referencing/src/main/java/org/apache/sis/referencing/operation/builder/LinearTransformBuilder.java
b/core/sis-referencing/src/main/java/org/apache/sis/referencing/operation/builder/LinearTransformBuilder.java
index 88123bb..e3bb860 100644
--- a/core/sis-referencing/src/main/java/org/apache/sis/referencing/operation/builder/LinearTransformBuilder.java
+++ b/core/sis-referencing/src/main/java/org/apache/sis/referencing/operation/builder/LinearTransformBuilder.java
@@ -18,6 +18,7 @@ package org.apache.sis.referencing.operation.builder;
 
 import java.util.Map;
 import java.util.Arrays;
+import java.util.NoSuchElementException;
 import java.io.IOException;
 import org.opengis.util.FactoryException;
 import org.opengis.geometry.Envelope;
@@ -31,12 +32,16 @@ import org.apache.sis.io.TableAppender;
 import org.apache.sis.math.Line;
 import org.apache.sis.math.Plane;
 import org.apache.sis.math.Vector;
+import org.apache.sis.geometry.DirectPosition1D;
+import org.apache.sis.geometry.DirectPosition2D;
+import org.apache.sis.geometry.GeneralDirectPosition;
 import org.apache.sis.referencing.operation.matrix.Matrices;
 import org.apache.sis.referencing.operation.matrix.MatrixSIS;
 import org.apache.sis.referencing.operation.transform.LinearTransform;
 import org.apache.sis.referencing.factory.InvalidGeodeticParameterException;
 import org.apache.sis.internal.referencing.ExtendedPrecisionMatrix;
 import org.apache.sis.internal.referencing.Resources;
+import org.apache.sis.internal.util.AbstractMap;
 import org.apache.sis.util.resources.Vocabulary;
 import org.apache.sis.util.resources.Errors;
 import org.apache.sis.util.ArgumentChecks;
@@ -108,7 +113,8 @@ public class LinearTransformBuilder extends TransformBuilder {
      * Number of valid positions in the {@link #sources} or {@link #targets} arrays.
      * Note that the "valid" positions may contain {@link Double#NaN} ordinate values.
      * This field is only indicative if this {@code LinearTransformBuilder} instance
-     * has been created by {@link #LinearTransformBuilder(int...)}.
+     * has been created by {@link #LinearTransformBuilder(int...)} because we do not
+     * try to detect if user adds a new point or overwrites an existing one.
      */
     private int numPoints;
 
@@ -177,6 +183,8 @@ public class LinearTransformBuilder extends TransformBuilder {
     /**
      * Returns the grid size for the given dimension. It is caller's responsibility to ensure
that
      * this method is invoked only on instances created by {@link #LinearTransformBuilder(int...)}.
+     *
+     * @see #getGridDimensions()
      */
     final int gridSize(final int srcDim) {
         return gridSize[srcDim];
@@ -214,11 +222,13 @@ public class LinearTransformBuilder extends TransformBuilder {
     /**
      * Returns the offset of the given source grid coordinate, or -1 if none. The algorithm
implemented in this
      * method is inefficient, but should rarely be used. This is only a fallback when {@link
#flatIndex(int[])}
-     * can not be used.
+     * can not be used. Callers is responsible to ensure that the number of dimensions match.
+     *
+     * @see ControlPoints#search(double[][], double[])
      */
     private int search(final int[] source) {
         assert gridSize == null;         // This method should not be invoked for points
distributed on a grid.
-search: for (int j=0; j<numPoints; j++) {
+search: for (int j=numPoints; --j >= 0;) {
             for (int i=0; i<source.length; i++) {
                 if (source[i] != sources[i][j]) {
                     continue search;                            // Search another position
for the same source.
@@ -256,6 +266,8 @@ search: for (int j=0; j<numPoints; j++) {
      * of known size. Callers must have verified the position dimension before to invoke
this method.
      *
      * @throws IllegalArgumentException if an ordinate value is illegal.
+     *
+     * @see ControlPoints#flatIndex(DirectPosition)
      */
     private int flatIndex(final DirectPosition source) {
         assert sources == null;               // This method should not be invoked for randomly
distributed points.
@@ -295,7 +307,8 @@ search: for (int j=0; j<numPoints; j++) {
 
     /**
      * Builds the exception message for an unexpected position dimension. This method assumes
-     * that positions are stored in this builder as they are read from user-provided collection.
+     * that positions are stored in this builder as they are read from user-provided collection,
+     * with {@link #numPoints} the index of the next point that we failed to add.
      */
     private String mismatchedDimension(final String name, final int expected, final int actual)
{
         return Errors.format(Errors.Keys.MismatchedDimension_3, name + '[' + numPoints +
']', expected, actual);
@@ -309,6 +322,17 @@ search: for (int j=0; j<numPoints; j++) {
     }
 
     /**
+     * Returns the number of dimensions in the source grid, or -1 if this builder is not
backed by a grid.
+     * Contrarily to the other {@code get*Dimensions()} methods, this method does not throw
exception.
+     *
+     * @see #getSourceDimensions()
+     * @see #gridSize(int)
+     */
+    final int getGridDimensions() {
+        return (gridSize != null) ? gridSize.length : -1;
+    }
+
+    /**
      * Returns the number of dimensions in source positions.
      *
      * @return the dimension of source points.
@@ -388,11 +412,10 @@ search: for (int j=0; j<numPoints; j++) {
         }
         final int dim = points.length;
         final GeneralEnvelope envelope = new GeneralEnvelope(dim);
-        for (int i=0; i <dim; i++) {
-            final double[] data = points[i];
+        for (int i=0; i<dim; i++) {
             double lower = Double.POSITIVE_INFINITY;
             double upper = Double.NEGATIVE_INFINITY;
-            for (final double value : data) {
+            for (final double value : points[i]) {
                 if (value < lower) lower = value;
                 if (value > upper) upper = value;
             }
@@ -416,6 +439,7 @@ search: for (int j=0; j<numPoints; j++) {
      * the given map, and the target positions are the associated values in the map. The
map should not contain two
      * entries with the same source position. Coordinate reference systems are ignored.
      * Null positions are silently ignored.
+     * Positions with NaN or infinite coordinates cause an exception to be thrown.
      *
      * <p>All source positions shall have the same number of dimensions (the <cite>source
dimension</cite>),
      * and all target positions shall have the same number of dimensions (the <cite>target
dimension</cite>).
@@ -431,6 +455,7 @@ search: for (int j=0; j<numPoints; j++) {
      *
      * @param  sourceToTarget  a map of source positions to target positions.
      *         Source positions are assumed precise and target positions are assumed uncertain.
+     * @throws IllegalArgumentException if the given positions contain NaN or infinite coordinate
values.
      * @throws IllegalArgumentException if this builder has been {@linkplain #LinearTransformBuilder(int...)
      *         created for a grid} but some source ordinates are not indices in that grid.
      * @throws MismatchedDimensionException if some positions do not have the expected number
of dimensions.
@@ -481,19 +506,268 @@ search: for (int j=0; j<numPoints; j++) {
             int d;
             if ((d = src.getDimension()) != srcDim) throw new MismatchedDimensionException(mismatchedDimension("source",
srcDim, d));
             if ((d = tgt.getDimension()) != tgtDim) throw new MismatchedDimensionException(mismatchedDimension("target",
tgtDim, d));
+            boolean isValid = true;
             int index;
             if (gridSize != null) {
                 index = flatIndex(src);
             } else {
                 index = numPoints;
                 for (int i=0; i<srcDim; i++) {
-                    sources[i][index] = src.getOrdinate(i);
+                    isValid &= Double.isFinite(sources[i][index] = src.getOrdinate(i));
                 }
             }
             for (int i=0; i<tgtDim; i++) {
-                targets[i][index] = tgt.getOrdinate(i);
+                isValid &= Double.isFinite(targets[i][index] = tgt.getOrdinate(i));
+            }
+            /*
+             * If the point contains some NaN or infinite coordinate values, it is okay to
leave it as-is
+             * (without incrementing 'numPoints') provided that we ensure that at least one
value is NaN.
+             * For convenience, we set only the first coordinate to NaN. The ControlPoints
map will check
+             * for the first coordinate too, so we need to keep this policy consistent.
+             */
+            if (isValid) {
+                numPoints++;
+            } else {
+                targets[0][index] = Double.NaN;
+                throw new IllegalArgumentException(Errors.format(Errors.Keys.IllegalMapping_2,
src, tgt));
             }
-            numPoints++;
+        }
+    }
+
+    /**
+     * Returns all control points as a map. Values are source coordinates and keys are target
coordinates.
+     * The map is unmodifiable and is guaranteed to contain only non-null keys and values.
+     * The map is a view: changes in this builder are immediately reflected in the returned
map.
+     *
+     * @return all control points in this builder.
+     *
+     * @since 1.0
+     */
+    public Map<DirectPosition,DirectPosition> getControlPoints() {
+        return (gridSize != null) ? new ControlPoints() : new Ungridded();
+    }
+
+    /**
+     * Implementation of the map returned by {@link #getControlPoints()}. The default implementation
+     * is suitable for {@link LinearTransformBuilder} backed by a grid. For non-gridded sources,
the
+     * {@link Ungridded} subclass shall be used instead.
+     */
+    private class ControlPoints extends AbstractMap<DirectPosition,DirectPosition>
{
+        /**
+         * Creates a new map view of control points.
+         */
+        ControlPoints() {
+        }
+
+        /**
+         * Creates a point from the given data at the given offset. Before to invoke this
method,
+         * caller should verify index validity and that the coordinate does not contain NaN
values.
+         */
+        final DirectPosition position(final double[][] data, final int offset) {
+            switch (data.length) {
+                case 1: return new DirectPosition1D(data[0][offset]);
+                case 2: return new DirectPosition2D(data[0][offset], data[1][offset]);
+            }
+            final GeneralDirectPosition pos = new GeneralDirectPosition(data.length);
+            for (int i=0; i<data.length; i++) pos.setOrdinate(i, data[i][offset]);
+            return pos;
+        }
+
+        /**
+         * Returns the number of points to consider when searching in {@link #sources} or
{@link #targets} arrays.
+         * For gridded data we can not rely on {@link #numPoints} because the coordinate
values may be at any index,
+         * not necessarily at consecutive indices.
+         */
+        int domain() {
+            return gridLength;
+        }
+
+        /**
+         * Returns the index of the given coordinates in the given data array (source or
target coordinates).
+         * This method is a copy of {@link LinearTransformBuilder#search(int[])}, but working
on real values
+         * instead than integers and capable to work on {@link #targets} as well as {@link
#sources}.
+         *
+         * <p>If the given coordinates contain NaN values, then this method will always
return -1 even if the
+         * given data contains the same NaN values. We want this behavior because NaN mean
that the point has
+         * not been set. There is no confusion with NaN values that users could have set
explicitly because
+         * {@code setControlPoint} methods do not allow NaN values.</p>
+         *
+         * @see LinearTransformBuilder#search(int[])
+         */
+        final int search(final double[][] data, final double[] coord) {
+            if (data != null && coord.length == data.length) {
+search:         for (int j=domain(); --j >= 0;) {
+                    for (int i=0; i<coord.length; i++) {
+                        if (coord[i] != data[i][j]) {           // Intentionally want 'false'
for NaN values.
+                            continue search;
+                        }
+                    }
+                    return j;
+                }
+            }
+            return -1;
+        }
+
+        /**
+         * Returns {@code true} if the given value is one of the target coordinates.
+         * This method requires a linear scan of the data.
+         */
+        @Override
+        public final boolean containsValue(final Object value) {
+            return (value instanceof Position) && search(targets, ((Position) value).getDirectPosition().getCoordinate())
>= 0;
+        }
+
+        /**
+         * Returns {@code true} if the given value is one of the source coordinates.
+         * This method is fast on gridded data, but requires linear scan on non-gridded data.
+         */
+        @Override
+        public final boolean containsKey(final Object key) {
+            return (key instanceof Position) && flatIndex(((Position) key).getDirectPosition())
>= 0;
+        }
+
+        /**
+         * Returns the target point for the given source point.
+         * This method is fast on gridded data, but requires linear scan on non-gridded data.
+         */
+        @Override
+        public final DirectPosition get(final Object key) {
+            if (key instanceof Position) {
+                final int index = flatIndex(((Position) key).getDirectPosition());
+                if (index >= 0) return position(targets, index);
+            }
+            return null;
+        }
+
+        /**
+         * Returns the index where to fetch a target position for the given source position
in the flattened array.
+         * This is the same work as {@link LinearTransformBuilder#flatIndex(DirectPosition)},
but without throwing
+         * exception if the position is invalid. Instead, -1 is returned as a sentinel value
for invalid source
+         * (including mismatched number of dimensions).
+         *
+         * <p>The default implementation assumes a grid. This method must be overridden
by {@link Ungridded}.</p>
+         *
+         * @see LinearTransformBuilder#flatIndex(DirectPosition)
+         */
+        int flatIndex(final DirectPosition source) {
+            final double[][] targets = LinearTransformBuilder.this.targets;
+            if (targets != null) {
+                final int[] gridSize = LinearTransformBuilder.this.gridSize;
+                int i = gridSize.length;
+                if (i == source.getDimension()) {
+                    int offset = 0;
+                    while (i != 0) {
+                        final int size = gridSize[--i];
+                        final double ordinate = source.getOrdinate(i);
+                        final int index = (int) ordinate;
+                        if (index < 0 || index >= size || index != ordinate) {
+                            return -1;
+                        }
+                        offset = offset * size + index;
+                    }
+                    if (!Double.isNaN(targets[0][offset])) return offset;
+                }
+            }
+            return -1;
+        }
+
+        /**
+         * Returns an iterator over the entries.
+         * {@code DirectPosition} instances are created on-the-fly during the iteration.
+         *
+         * <p>The default implementation assumes a grid. This method must be overridden
by {@link Ungridded}.</p>
+         */
+        @Override
+        protected EntryIterator<DirectPosition,DirectPosition> entryIterator() {
+            return new EntryIterator<DirectPosition,DirectPosition>() {
+                /**
+                 * Index in the flat arrays of the next entry to return.
+                 */
+                private int index = -1;
+
+                /**
+                 * Moves to the next entry and returns {@code true} if an entry has been
found.
+                 * This method skips coordinates having NaN value. Those NaN values may happen
+                 * on gridded data (they mean that the point has not yet been set), but should
+                 * not happen on non-gridded data.
+                 */
+                @Override protected boolean next() {
+                    final double[][] targets = LinearTransformBuilder.this.targets;
+                    if (targets != null) {
+                        final double[] x = targets[0];
+                        final int gridLength = LinearTransformBuilder.this.gridLength;
+                        while (++index < gridLength) {
+                            if (!Double.isNaN(x[index])) {
+                                return true;
+                            }
+                        }
+                    }
+                    return false;
+                }
+
+                /**
+                 * Reconstructs the source coordinates for the current index.
+                 * This method is the converse of {@code ControlPoints.flatIndex(DirectPosition)}.
+                 * It assumes gridded data; {@link Ungridded} will have to do a different
work.
+                 */
+                @Override protected DirectPosition getKey() {
+                    final int[] gridSize = LinearTransformBuilder.this.gridSize;
+                    final int dim = gridSize.length;
+                    final GeneralDirectPosition pos = new GeneralDirectPosition(dim);
+                    int offset = index;
+                    for (int i=0; i<dim; i++) {
+                        final int size = gridSize[i];
+                        pos.setOrdinate(i, offset % size);
+                        offset /= size;
+                    }
+                    if (offset == 0) {
+                        return pos;
+                    } else {
+                        throw new NoSuchElementException();
+                    }
+                }
+
+                /**
+                 * Returns the target coordinates at current index.
+                 */
+                @Override protected DirectPosition getValue() {
+                    return position(targets, index);
+                }
+            };
+        }
+    }
+
+    /**
+     * Implementation of the map returned by {@link #getControlPoints()} when no grid is
used.
+     * This implementation is simpler than the gridded case, but less efficient as some methods
+     * require a linear scan.
+     */
+    private final class Ungridded extends ControlPoints {
+        /** Overrides default method with more efficient implementation. */
+        @Override public boolean isEmpty() {return numPoints == 0;}
+        @Override public int     size()    {return numPoints;}
+        @Override        int     domain()  {return numPoints;}
+
+        /**
+         * Returns the index where to fetch a target position for the given source position
+         * in the flattened array. In non-gridded case, this operation requires linear scan.
+         */
+        @Override int flatIndex(final DirectPosition source) {
+            return search(sources, source.getCoordinate());
+        }
+
+        /**
+         * Returns an iterator over the entries.
+         * {@code DirectPosition} instances are created on-the-fly during the iteration.
+         */
+        @Override protected EntryIterator<DirectPosition,DirectPosition> entryIterator()
{
+            return new EntryIterator<DirectPosition,DirectPosition>() {
+                private int index = -1;
+
+                @Override protected boolean        next()     {return ++index < numPoints;}
+                @Override protected DirectPosition getKey()   {return position(sources, index);}
+                @Override protected DirectPosition getValue() {return position(targets, index);}
+            };
         }
     }
 
@@ -511,7 +785,7 @@ search: for (int j=0; j<numPoints; j++) {
      *                 If this builder has been created with the {@link #LinearTransformBuilder()}
constructor, then no constraint apply.
      * @param  target  the target coordinates, assumed uncertain.
      * @throws IllegalArgumentException if this builder has been {@linkplain #LinearTransformBuilder(int...)
created for a grid}
-     *         but some source ordinates are out of index range.
+     *         but some source ordinates are out of index range, or if {@code target} contains
NaN of infinite numbers.
      * @throws MismatchedDimensionException if the source or target position does not have
the expected number of dimensions.
      *
      * @since 0.8
@@ -554,8 +828,15 @@ search: for (int j=0; j<numPoints; j++) {
                 sources[i][index] = source[i];
             }
         }
+        boolean isValid = true;
         for (int i=0; i<tgtDim; i++) {
-            targets[i][index] = target[i];
+            isValid &= Double.isFinite(targets[i][index] = target[i]);
+        }
+        transform   = null;
+        correlation = null;
+        if (!isValid) {
+            if (gridSize == null) numPoints--;
+            throw new IllegalArgumentException(Errors.format(Errors.Keys.IllegalMapping_2,
source, target));
         }
     }
 
@@ -593,12 +874,18 @@ search: for (int j=0; j<numPoints; j++) {
                 return null;
             }
         }
-        boolean isNaN = true;
+        /*
+         * A coordinate with NaN value means that the point has not been set.
+         * Not that the coordinate may have only one NaN value, not necessarily
+         * all of them, if the point has been deleted after insertion attempt.
+         */
         final double[] target = new double[targets.length];
         for (int i=0; i<target.length; i++) {
-            isNaN &= Double.isNaN(target[i] = targets[i][index]);
+            if (Double.isNaN(target[i] = targets[i][index])) {
+                return null;
+            }
         }
-        return isNaN ? null : target;
+        return target;
     }
 
     /**
@@ -616,6 +903,21 @@ search: for (int j=0; j<numPoints; j++) {
     }
 
     /**
+     * Returns the vector of source ordinate names.
+     * It is caller responsibility to ensure that this builder is not backed by a grid.
+     */
+    final Vector[] sources() {
+        if (sources != null) {
+            final Vector[] v = new Vector[sources.length];
+            for (int i=0; i<v.length; i++) {
+                v[i] = vector(sources[i]);
+            }
+            return v;
+        }
+        throw new IllegalStateException(noData());
+    }
+
+    /**
      * Creates a linear transform approximation from the source positions to the target positions.
      * This method assumes that source positions are precise and that all uncertainty is
in the target positions.
      *
diff --git a/core/sis-referencing/src/main/java/org/apache/sis/referencing/operation/builder/LocalizationGridBuilder.java
b/core/sis-referencing/src/main/java/org/apache/sis/referencing/operation/builder/LocalizationGridBuilder.java
index f0abca3..7fc6e80 100644
--- a/core/sis-referencing/src/main/java/org/apache/sis/referencing/operation/builder/LocalizationGridBuilder.java
+++ b/core/sis-referencing/src/main/java/org/apache/sis/referencing/operation/builder/LocalizationGridBuilder.java
@@ -134,7 +134,9 @@ public class LocalizationGridBuilder extends TransformBuilder {
      */
     public LocalizationGridBuilder(final Vector sourceX, final Vector sourceY) {
         final Matrix fromGrid = new Matrix3();
-        linear = new LinearTransformBuilder(infer(sourceX, fromGrid, 0), infer(sourceY, fromGrid,
1));
+        final int width  = infer(sourceX, fromGrid, 0);
+        final int height = infer(sourceY, fromGrid, 1);
+        linear = new LinearTransformBuilder(width, height);
         try {
             sourceToGrid = MathTransforms.linear(fromGrid).inverse();
         } catch (NoninvertibleTransformException e) {
@@ -144,6 +146,56 @@ public class LocalizationGridBuilder extends TransformBuilder {
     }
 
     /**
+     * Creates a new builder for a localization grid inferred from the given provider of
control points.
+     * The {@linkplain LinearTransformBuilder#getSourceDimensions() number of source dimensions}
in the
+     * given {@code localizations} argument shall be 2. The {@code localization} can be used
in two ways:
+     *
+     * <ul class="verbose">
+     *   <li>If the {@code localizations} instance has been
+     *     {@linkplain LinearTransformBuilder#LinearTransformBuilder(int...) created with
a fixed grid size},
+     *     then that instance is used as-is — it is not copied. It is okay to specify an
empty instance and
+     *     to provide control points later by calls to {@link #setControlPoint(int, int,
double...)}.</li>
+     *   <li>If the {@code localizations} instance has been
+     *     {@linkplain LinearTransformBuilder#LinearTransformBuilder() created for a grid
of unknown size},
+     *     then this constructor tries to infer a grid size by inspection of the control
points present in
+     *     {@code localizations} at the time this constructor is invoked. Changes in {@code
localizations}
+     *     after construction will not be reflected in this new builder.</li>
+     * </ul>
+     *
+     * @param  localizations  the provider of control points for which to create a localization
grid.
+     * @throws ArithmeticException if this constructor can not infer a reasonable grid size
from the given localizations.
+     *
+     * @since 1.0
+     */
+    public LocalizationGridBuilder(final LinearTransformBuilder localizations) {
+        ArgumentChecks.ensureNonNull("localizations", localizations);
+        int n = localizations.getGridDimensions();
+        if (n == 2) {
+            linear = localizations;
+            sourceToGrid = MathTransforms.identity(2);
+        } else {
+            if (n < 0) {
+                final Vector[] sources = localizations.sources();
+                n = sources.length;
+                if (n == 2) {
+                    final Matrix fromGrid = new Matrix3();
+                    final int width  = infer(sources[0], fromGrid, 0);
+                    final int height = infer(sources[1], fromGrid, 1);
+                    linear = new LinearTransformBuilder(width, height);
+                    linear.setControlPoints(localizations.getControlPoints());
+                    try {
+                        sourceToGrid = MathTransforms.linear(fromGrid).inverse();
+                    } catch (NoninvertibleTransformException e) {
+                        throw (ArithmeticException) new ArithmeticException(e.getLocalizedMessage()).initCause(e);
+                    }
+                    return;
+                }
+            }
+            throw new IllegalArgumentException(Resources.format(Resources.Keys.MismatchedTransformDimension_3,
0, 2, n));
+        }
+    }
+
+    /**
      * Infers a grid size by searching for the greatest common divisor (GCD) for 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.
diff --git a/core/sis-referencing/src/test/java/org/apache/sis/referencing/operation/builder/LinearTransformBuilderTest.java
b/core/sis-referencing/src/test/java/org/apache/sis/referencing/operation/builder/LinearTransformBuilderTest.java
index 9c79d50..a36608e 100644
--- a/core/sis-referencing/src/test/java/org/apache/sis/referencing/operation/builder/LinearTransformBuilderTest.java
+++ b/core/sis-referencing/src/test/java/org/apache/sis/referencing/operation/builder/LinearTransformBuilderTest.java
@@ -29,14 +29,15 @@ import org.apache.sis.test.TestUtilities;
 import org.apache.sis.test.TestCase;
 import org.junit.Test;
 
-import static org.junit.Assert.*;
+import static org.apache.sis.test.Assert.*;
+import org.opengis.geometry.DirectPosition;
 
 
 /**
  * Tests {@link LinearTransformBuilder}.
  *
  * @author  Martin Desruisseaux (Geomatys)
- * @version 0.8
+ * @version 1.0
  * @since   0.5
  * @module
  */
@@ -329,4 +330,80 @@ public final strictfp class LinearTransformBuilderTest extends TestCase
{
         assertEquals("m₁₂", ref.getTranslateY(), m.getElement(1, 2), translationTolerance);
         assertArrayEquals("correlation", new double[] {1, 1}, builder.correlation(), scaleTolerance);
     }
+
+    /**
+     * Tests {@link LinearTransformBuilder#getControlPoints()} with gridded source points.
+     */
+    @Test
+    public void testGetControlPoints() {
+        testGetControlPoints(new LinearTransformBuilder(3, 4));
+    }
+
+    /**
+     * Tests {@link LinearTransformBuilder#getControlPoints()} with non-gridded source points.
+     */
+    @Test
+    public void testGetUngriddedControlPoints() {
+        testGetControlPoints(new LinearTransformBuilder());
+    }
+
+    /**
+     * Tests {@link LinearTransformBuilder#getControlPoints()} with the given builder.
+     * If the builder is backed by a grid, then the grid size shall be at least 3×4.
+     */
+    private static void testGetControlPoints(final LinearTransformBuilder builder) {
+        final DirectPosition2D s12, s23, s00;
+        final DirectPosition2D t12, t23, t00;
+        s12 = new DirectPosition2D(1, 2);   t12 = new DirectPosition2D(3, 2);
+        s23 = new DirectPosition2D(2, 3);   t23 = new DirectPosition2D(4, 1);
+        s00 = new DirectPosition2D(0, 0);   t00 = new DirectPosition2D(7, 3);
+
+        final Map<DirectPosition2D,DirectPosition2D> expected = new HashMap<>();
+        final Map<DirectPosition,DirectPosition> actual = builder.getControlPoints();
+        assertEquals(0, actual.size());
+        assertTrue(actual.isEmpty());
+        assertFalse(actual.containsKey  (s12));
+        assertFalse(actual.containsKey  (s23));
+        assertFalse(actual.containsKey  (s00));
+        assertFalse(actual.containsValue(t12));
+        assertFalse(actual.containsValue(t23));
+        assertFalse(actual.containsValue(t00));
+        assertMapEquals(expected, actual);
+
+        builder.setControlPoint(new int[] {1, 2}, t12.getCoordinate());
+        assertNull(expected.put(s12, t12));
+        assertEquals(1, actual.size());
+        assertFalse(actual.isEmpty());
+        assertTrue (actual.containsKey  (s12));
+        assertFalse(actual.containsKey  (s23));
+        assertFalse(actual.containsKey  (s00));
+        assertTrue (actual.containsValue(t12));
+        assertFalse(actual.containsValue(t23));
+        assertFalse(actual.containsValue(t00));
+        assertMapEquals(expected, actual);
+
+        builder.setControlPoint(new int[] {2, 3}, t23.getCoordinate());
+        assertNull(expected.put(s23, t23));
+        assertEquals(2, actual.size());
+        assertFalse(actual.isEmpty());
+        assertTrue (actual.containsKey  (s12));
+        assertTrue (actual.containsKey  (s23));
+        assertFalse(actual.containsKey  (s00));
+        assertTrue (actual.containsValue(t12));
+        assertTrue (actual.containsValue(t23));
+        assertFalse(actual.containsValue(t00));
+        assertMapEquals(expected, actual);
+
+        builder.setControlPoint(new int[] {0, 0}, t00.getCoordinate());
+        assertNull(expected.put(s00, t00));
+        assertEquals(3, actual.size());
+        assertFalse(actual.isEmpty());
+        assertTrue (actual.containsKey  (s12));
+        assertTrue (actual.containsKey  (s23));
+        assertTrue (actual.containsKey  (s00));
+        assertTrue (actual.containsValue(t12));
+        assertTrue (actual.containsValue(t23));
+        assertTrue (actual.containsValue(t00));
+        assertMapEquals(expected, actual);
+    }
 }
diff --git a/core/sis-referencing/src/test/java/org/apache/sis/referencing/operation/builder/LocalizationGridBuilderTest.java
b/core/sis-referencing/src/test/java/org/apache/sis/referencing/operation/builder/LocalizationGridBuilderTest.java
index ecba94d..11a3201 100644
--- a/core/sis-referencing/src/test/java/org/apache/sis/referencing/operation/builder/LocalizationGridBuilderTest.java
+++ b/core/sis-referencing/src/test/java/org/apache/sis/referencing/operation/builder/LocalizationGridBuilderTest.java
@@ -21,21 +21,29 @@ import java.awt.geom.AffineTransform;
 import org.opengis.util.FactoryException;
 import org.opengis.referencing.operation.TransformException;
 import org.opengis.test.referencing.TransformTestCase;
+import org.apache.sis.geometry.Envelope2D;
 import org.apache.sis.test.DependsOn;
 import org.junit.Test;
 
+import static org.apache.sis.test.ReferencingAssert.*;
+
 
 /**
  * Tests {@link LocalizationGridBuilder}.
  *
  * @author  Martin Desruisseaux (Geomatys)
- * @version 0.8
+ * @version 1.0
  * @since   0.8
  * @module
  */
 @DependsOn({LinearTransformBuilderTest.class, ResidualGridTest.class})
 public final strictfp class LocalizationGridBuilderTest extends TransformTestCase {
     /**
+     * For floating-point comparisons.
+     */
+    private static final double STRICT = 0;
+
+    /**
      * Creates a builder initialized with control points computed from the given affine transform.
      * Some non-linear terms will be added to the coordinates computed by the given transform.
      *
@@ -102,4 +110,33 @@ public final strictfp class LocalizationGridBuilderTest extends TransformTestCas
         verifyTransform(new double[] {0, 3}, new double[] {  1.3,   -8.5});
         verifyTransform(new double[] {4, 3}, new double[] { 87.7, -123.7});
     }
+
+    /**
+     * Tests {@link LocalizationGridBuilder#LocalizationGridBuilder(LinearTransformBuilder)}.
+     *
+     * @throws TransformException if an error occurred while computing the envelope.
+     */
+    @Test
+    public void testCreateFromLocalizations() throws TransformException {
+        final LinearTransformBuilder 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);
+        /*
+         * Verifies the grid size by checking the source envelope.
+         * Minimum and maximum values are inclusive.
+         */
+        assertEnvelopeEquals(new Envelope2D(null, 0, 0, 1, 2), builder.getSourceEnvelope(false),
STRICT);
+        /*
+         * Verify a few random positions.
+         */
+        assertArrayEquals(new double[] {-20.0,    8.0}, builder.getControlPoint(0, 0), STRICT);
+        assertArrayEquals(new double[] {  0.4,  -21.7}, builder.getControlPoint(1, 0), STRICT);
+        assertArrayEquals(new double[] {  1.3,   -8.5}, builder.getControlPoint(0, 2), STRICT);
+        assertArrayEquals(new double[] { 87.7, -123.7}, builder.getControlPoint(1, 2), STRICT);
+    }
 }
diff --git a/core/sis-utility/src/main/java/org/apache/sis/util/resources/Errors.java b/core/sis-utility/src/main/java/org/apache/sis/util/resources/Errors.java
index 365cdee..67668b2 100644
--- a/core/sis-utility/src/main/java/org/apache/sis/util/resources/Errors.java
+++ b/core/sis-utility/src/main/java/org/apache/sis/util/resources/Errors.java
@@ -383,6 +383,11 @@ public final class Errors extends IndexedResourceBundle {
         public static final short IllegalLanguageCode_1 = 54;
 
         /**
+         * Illegal mapping: {0} → {1}.
+         */
+        public static final short IllegalMapping_2 = 185;
+
+        /**
          * Member “{0}” can not be associated to type “{1}”.
          */
         public static final short IllegalMemberType_2 = 55;
diff --git a/core/sis-utility/src/main/java/org/apache/sis/util/resources/Errors.properties
b/core/sis-utility/src/main/java/org/apache/sis/util/resources/Errors.properties
index d238722..926be8e 100644
--- a/core/sis-utility/src/main/java/org/apache/sis/util/resources/Errors.properties
+++ b/core/sis-utility/src/main/java/org/apache/sis/util/resources/Errors.properties
@@ -87,6 +87,7 @@ IllegalCRSType_1                  = Coordinate reference system can not
be of ty
 IllegalFormatPatternForClass_2    = The \u201c{1}\u201d pattern can not be applied to formatting
of objects of type \u2018{0}\u2019.
 IllegalIdentifierForCodespace_2   = \u201c{1}\u201d is not a valid identifier for the \u201c{0}\u201d
code space.
 IllegalLanguageCode_1             = The \u201c{0}\u201d language is not recognized.
+IllegalMapping_2                  = Illegal mapping: {0} \u2192 {1}.
 IllegalMemberType_2               = Member \u201c{0}\u201d can not be associated to type
\u201c{1}\u201d.
 IllegalOptionValue_2              = Option \u2018{0}\u2019 can not take the \u201c{1}\u201d
value.
 IllegalOrdinateRange_3            = The [{0} \u2026 {1}] range of ordinate values is not
valid for the \u201c{2}\u201d axis.
diff --git a/core/sis-utility/src/main/java/org/apache/sis/util/resources/Errors_fr.properties
b/core/sis-utility/src/main/java/org/apache/sis/util/resources/Errors_fr.properties
index 176b593..4beb847 100644
--- a/core/sis-utility/src/main/java/org/apache/sis/util/resources/Errors_fr.properties
+++ b/core/sis-utility/src/main/java/org/apache/sis/util/resources/Errors_fr.properties
@@ -84,6 +84,7 @@ IllegalCRSType_1                  = Le syst\u00e8me de r\u00e9f\u00e9rence
des c
 IllegalFormatPatternForClass_2    = Le mod\u00e8le \u00ab\u202f{1}\u202f\u00bb ne peut pas
\u00eatre appliqu\u00e9 au formatage d\u2019objets de type \u2018{0}\u2019.
 IllegalIdentifierForCodespace_2   = \u00ab\u202f{1}\u202f\u00bb n\u2019est pas un identifiant
valide pour l\u2019espace de codes \u00ab\u202f{0}\u202f\u00bb.
 IllegalLanguageCode_1             = Le code de langue \u00ab\u202f{0}\u202f\u00bb n\u2019est
pas reconnu.
+IllegalMapping_2                  = Correspondance ill\u00e9gale: {0} \u2192 {1}.
 IllegalMemberType_2               = Le membre \u00ab\u202f{0}\u202f\u00bb ne peut pas \u00eatre
associ\u00e9 au type \u00ab\u202f{1}\u202f\u00bb.
 IllegalOptionValue_2              = L\u2019option \u2018{0}\u2019 n\u2019accepte pas la valeur
\u00ab\u202f{1}\u202f\u00bb.
 IllegalOrdinateRange_3            = La plage de valeurs de coordonn\u00e9es [{0} \u2026 {1}]
n\u2019est pas valide pour l\u2019axe \u00ab\u202f{2}\u202f\u00bb.


Mime
View raw message