You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@sis.apache.org by de...@apache.org on 2019/03/09 22:59:21 UTC

[sis] 05/05: First draft of the use of linearizers in LinearTransformBuilder. https://issues.apache.org/jira/browse/SIS-446

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

commit f17de4bf788c413ef76cd8cb2474bb6b7ca4219c
Author: Martin Desruisseaux <ma...@geomatys.com>
AuthorDate: Sat Mar 9 23:52:51 2019 +0100

    First draft of the use of linearizers in LinearTransformBuilder.
    https://issues.apache.org/jira/browse/SIS-446
---
 .../operation/builder/LinearTransformBuilder.java  | 467 +++++++++++++++++----
 .../operation/builder/LocalizationGridBuilder.java |   5 +
 .../operation/builder/ProjectedTransformTry.java   | 324 ++++++++++++++
 .../operation/builder/TransformBuilder.java        |   6 +
 .../operation/transform/AbstractMathTransform.java |   2 +-
 .../builder/LinearTransformBuilderTest.java        |  37 +-
 .../operation/builder/NonLinearTransform.java      |  51 +++
 .../java/org/apache/sis/internal/util/Strings.java |  26 +-
 .../src/main/java/org/apache/sis/math/Plane.java   |   4 +
 .../main/java/org/apache/sis/util/ArraysExt.java   |   8 +-
 .../org/apache/sis/util/resources/Vocabulary.java  |   5 +
 .../sis/util/resources/Vocabulary.properties       |   1 +
 .../sis/util/resources/Vocabulary_fr.properties    |   1 +
 ide-project/NetBeans/nbproject/genfiles.properties |   2 +-
 ide-project/NetBeans/nbproject/project.xml         |   2 +
 15 files changed, 852 insertions(+), 89 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 4138d69..6aa38a4 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
@@ -17,8 +17,15 @@
 package org.apache.sis.referencing.operation.builder;
 
 import java.util.Map;
+import java.util.List;
+import java.util.Queue;
 import java.util.Arrays;
+import java.util.ArrayList;
+import java.util.ArrayDeque;
+import java.util.Collections;
 import java.util.NoSuchElementException;
+import java.util.Optional;
+import java.text.NumberFormat;
 import java.io.IOException;
 import java.io.UncheckedIOException;
 import org.opengis.util.FactoryException;
@@ -27,6 +34,7 @@ import org.opengis.geometry.DirectPosition;
 import org.opengis.geometry.MismatchedDimensionException;
 import org.opengis.geometry.coordinate.Position;
 import org.opengis.referencing.operation.Matrix;
+import org.opengis.referencing.operation.MathTransform;
 import org.opengis.referencing.operation.MathTransformFactory;
 import org.apache.sis.geometry.GeneralEnvelope;
 import org.apache.sis.io.TableAppender;
@@ -60,9 +68,23 @@ import org.apache.sis.util.Classes;
  * Otherwise a builder created by the {@link #LinearTransformBuilder()} constructor will be able to handle
  * randomly distributed coordinates.
  *
- * <p>The transform coefficients are determined using a <cite>least squares</cite> estimation method,
+ * <p>Builders can be used only once;
+ * points can not be added or modified after {@link #create(MathTransformFactory)} has been invoked.
+ * The transform coefficients are determined using a <cite>least squares</cite> estimation method,
  * with the assumption that source positions are exact and all the uncertainty is in the target positions.</p>
  *
+ * <div class="section">Linearizers</div>
+ * Consider the following situation (commonly found with {@linkplain org.apache.sis.storage.netcdf netCDF files}):
+ * the <i>sources</i> coordinates are pixel indices and the <i>targets</i> are (longitude, latitude) coordinates,
+ * but we suspect that the <i>sources to targets</i> transform is some undetermined map projection, maybe Mercator.
+ * A linear approximation between those coordinates will give poor results; the results would be much better if all
+ * (longitude, latitude) coordinates were converted to the right projection first. However that map projection may
+ * not be known, but we can try to guess it by trials-and-errors using a set of plausible projections.
+ * That set can be specified by {@link #addLinearizers(Map, int...)}.
+ * If the {@link #create(MathTransformFactory)} method finds that one of the specified projections seems a good fit,
+ * it will automatically convert all target coordinates to that projection.
+ * That selected projection is given by {@link #linearizer()}.
+ *
  * @author  Martin Desruisseaux (Geomatys)
  * @version 1.0
  *
@@ -103,6 +125,12 @@ public class LinearTransformBuilder extends TransformBuilder {
      * This layout allows to create only a few (typically two) large arrays instead of a multitude of small arrays.
      * Example: {x[], y[], z[]}.
      * This is {@code null} if not yet specified.
+     *
+     * <div class="note"><b>Implementation note:</b>
+     * we could use a flat array with (x₀, y₀), (x₁, y₁), (x₂, y₂), <i>etc.</i> coordinate tuples instead.
+     * Such flat array would be more convenient for some coordinate conversions with {@link MathTransform}.
+     * But using array of arrays is more convenient for other calculations working on one dimension at time,
+     * make data more local for CPU, and also allows handling of more points.</div>
      */
     private double[][] targets;
 
@@ -122,8 +150,25 @@ public class LinearTransformBuilder extends TransformBuilder {
     private int numPoints;
 
     /**
+     * If the user suspects that the transform may be linear when the target is another space than the space of
+     * {@link #targets} coordinates, projections toward spaces to try. The {@link #create(MathTransformFactory)}
+     * method will try to apply those transforms on {@link #targets} and check if they produce better fits.
+     *
+     * @see #addLinearizers(Map, int[])
+     * @see #linearizer()
+     */
+    private List<ProjectedTransformTry> linearizers;
+
+    /**
+     * If one of the {@linkplain #linearizers} have been applied, that linearizer. Otherwise {@code null}.
+     *
+     * @see #linearizer()
+     */
+    private transient ProjectedTransformTry appliedLinearizer;
+
+    /**
      * The transform created by the last call to {@link #create(MathTransformFactory)}.
-     * This is reset to {@code null} when coordinates are modified.
+     * A non-null value means that this builder became unmodifiable.
      */
     private transient LinearTransform transform;
 
@@ -131,7 +176,24 @@ public class LinearTransformBuilder extends TransformBuilder {
      * An estimation of the Pearson correlation coefficient for each target dimension.
      * This is {@code null} if not yet computed.
      */
-    private transient double[] correlation;
+    private transient double[] correlations;
+
+    /**
+     * Creates a temporary builder with all source fields from the given builder and no target arrays.
+     * Calculated fields, namely {@link #correlations} and {@link #transform}, are left uninitialized.
+     * Arrays are copied by references and their content shall not be modified. The new builder should
+     * not be made accessible to users since changes in this builder would be reflected in the source
+     * values or original builder. This constructor is reserved to {@link #create(MathTransformFactory)}
+     * internal usage.
+     *
+     * @param original  the builder from which to take array references of source values.
+     */
+    private LinearTransformBuilder(final LinearTransformBuilder original) {
+        gridSize   = original.gridSize;
+        sources    = original.sources;
+        gridLength = original.gridLength;
+        numPoints  = original.numPoints;
+    }
 
     /**
      * Creates a new linear transform builder for randomly distributed positions.
@@ -326,6 +388,14 @@ search: for (int j=numPoints; --j >= 0;) {
     }
 
     /**
+     * Returns the error message to be given to {@link IllegalStateException}
+     * when this builder can not be modified anymore.
+     */
+    private static String unmodifiable() {
+        return Errors.format(Errors.Keys.UnmodifiableObject_1, LinearTransformBuilder.class);
+    }
+
+    /**
      * 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.
      *
@@ -391,12 +461,14 @@ search: for (int j=numPoints; --j >= 0;) {
             }
             return envelope;
         } else {
-            return envelope(sources);
+            return envelope(sources, numPoints);
         }
     }
 
     /**
      * Returns the envelope of target points. The lower and upper values are inclusive.
+     * If a {@linkplain #linearizer() linearizer has been applied}, then coordinates of
+     * the returned envelope are projected by that linearizer.
      *
      * @return the envelope of target points.
      * @throws IllegalStateException if the target points are not yet known.
@@ -404,22 +476,24 @@ search: for (int j=numPoints; --j >= 0;) {
      * @since 1.0
      */
     public Envelope getTargetEnvelope() {
-        return envelope(targets);
+        return envelope(targets, (gridLength != 0) ? gridLength : numPoints);
     }
 
     /**
      * Implementation of {@link #getSourceEnvelope()} and {@link #getTargetEnvelope()}.
      */
-    private static Envelope envelope(final double[][] points) {
+    private static Envelope envelope(final double[][] points, final int numPoints) {
         if (points == null) {
             throw new IllegalStateException(noData());
         }
         final int dim = points.length;
         final GeneralEnvelope envelope = new GeneralEnvelope(dim);
         for (int i=0; i<dim; i++) {
+            final double[] values = points[i];
             double lower = Double.POSITIVE_INFINITY;
             double upper = Double.NEGATIVE_INFINITY;
-            for (final double value : points[i]) {
+            for (int j=0; j<numPoints; j++) {
+                final double value = values[j];
                 if (value < lower) lower = value;
                 if (value > upper) upper = value;
             }
@@ -447,7 +521,7 @@ search: for (int j=numPoints; --j >= 0;) {
      *
      * <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>).
-     * However the source dimension does not need to be the same the target dimension.
+     * However the source dimension does not need to be the same than the target dimension.
      * Apache SIS currently supports only one- or two-dimensional source positions,
      * together with arbitrary target dimension.</p>
      *
@@ -459,6 +533,7 @@ search: for (int j=numPoints; --j >= 0;) {
      *
      * @param  sourceToTarget  a map of source positions to target positions.
      *         Source positions are assumed precise and target positions are assumed uncertain.
+     * @throws IllegalStateException if {@link #create(MathTransformFactory) create(…)} has already been invoked.
      * @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 coordinates are not indices in that grid.
@@ -469,14 +544,15 @@ search: for (int j=numPoints; --j >= 0;) {
     public void setControlPoints(final Map<? extends Position, ? extends Position> sourceToTarget)
             throws MismatchedDimensionException
     {
+        if (transform != null) {
+            throw new IllegalStateException(unmodifiable());
+        }
         ArgumentChecks.ensureNonNull("sourceToTarget", sourceToTarget);
-        transform   = null;
-        correlation = null;
-        sources     = null;
-        targets     = null;
-        numPoints   = 0;
-        int srcDim  = 0;
-        int tgtDim  = 0;
+        sources    = null;
+        targets    = null;
+        numPoints  = 0;
+        int srcDim = 0;
+        int tgtDim = 0;
         for (final Map.Entry<? extends Position, ? extends Position> entry : sourceToTarget.entrySet()) {
             final DirectPosition src = position(entry.getKey());   if (src == null) continue;
             final DirectPosition tgt = position(entry.getValue()); if (tgt == null) continue;
@@ -543,6 +619,10 @@ search: for (int j=numPoints; --j >= 0;) {
      * 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.
      *
+     * <p>If {@link #linearizer()} returns a non-empty value,
+     * then the values in the returned map are projected using that linearizer.
+     * This may happen only after {@link #create(MathTransformFactory) create(…)} has been invoked.</p>
+     *
      * @return all control points in this builder.
      *
      * @since 1.0
@@ -788,6 +868,7 @@ search:         for (int j=domain(); --j >= 0;) {
      *                 then for every index <var>i</var> the {@code source[i]} value shall be in the [0 … {@code gridSize[i]}-1] range inclusive.
      *                 If this builder has been created with the {@link #LinearTransformBuilder()} constructor, then no constraint apply.
      * @param  target  the target coordinates, assumed uncertain.
+     * @throws IllegalStateException if {@link #create(MathTransformFactory) create(…)} has already been invoked.
      * @throws IllegalArgumentException if this builder has been {@linkplain #LinearTransformBuilder(int...) created for a grid}
      *         but some source coordinates 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.
@@ -795,6 +876,9 @@ search:         for (int j=domain(); --j >= 0;) {
      * @since 0.8
      */
     public void setControlPoint(final int[] source, final double[] target) {
+        if (transform != null) {
+            throw new IllegalStateException(unmodifiable());
+        }
         ArgumentChecks.ensureNonNull("source", source);
         ArgumentChecks.ensureNonNull("target", target);
         verifySourceDimension(source.length);
@@ -809,6 +893,9 @@ search:         for (int j=domain(); --j >= 0;) {
             if (targets == null) {
                 allocate(tgtDim);
             }
+            if (Double.isNaN(targets[0][index])) {
+                numPoints++;
+            }
         } else {
             /*
              * Case of randomly distributed points. Algorithm used below is inefficient, but Javadoc
@@ -836,10 +923,11 @@ search:         for (int j=domain(); --j >= 0;) {
         for (int i=0; i<tgtDim; i++) {
             isValid &= Double.isFinite(targets[i][index] = target[i]);
         }
-        transform   = null;
-        correlation = null;
         if (!isValid) {
-            if (gridSize == null) numPoints--;
+            numPoints--;
+            for (int i=0; i<tgtDim; i++) {
+                targets[i][index] = Double.NaN;
+            }
             throw new IllegalArgumentException(Errors.format(Errors.Keys.IllegalMapping_2,
                                                source, new DirectPositionView.Double(target)));
         }
@@ -850,6 +938,9 @@ search:         for (int j=domain(); --j >= 0;) {
      * This method can be used for retrieving points set by previous calls to
      * {@link #setControlPoint(int[], double[])} or {@link #setControlPoints(Map)}.
      *
+     * <p>If {@link #linearizer()} returns a non-empty value, then the returned values are projected using that linearizer.
+     * This may happen only if this method is invoked after {@link #create(MathTransformFactory) create(…)}.</p>
+     *
      * <div class="note"><b>Performance note:</b>
      * current implementation is efficient for builders {@linkplain #LinearTransformBuilder(int...) created for a grid}
      * but inefficient for builders {@linkplain #LinearTransformBuilder() created for randomly distributed points}.</div>
@@ -900,9 +991,13 @@ search:         for (int j=domain(); --j >= 0;) {
      * <i>etc.</i>. Coordinates are stored in row-major order (column index varies faster, followed by row index).
      *
      * @param  coordinates coordinates in each target dimensions, stored in row-major order.
+     * @throws IllegalStateException if {@link #create(MathTransformFactory) create(…)} has already been invoked.
      */
-    final void setControlPoints(final Vector... coordinates) {
+    final void setControlPoints(final Vector[] coordinates) {
         assert gridSize != null;
+        if (transform != null) {
+            throw new IllegalStateException(unmodifiable());
+        }
         final int tgtDim = coordinates.length;
         final double[][] result = new double[tgtDim][];
         for (int i=0; i<tgtDim; i++) {
@@ -917,9 +1012,8 @@ search:         for (int j=domain(); --j >= 0;) {
             }
             throw new IllegalArgumentException(Errors.format(Errors.Keys.UnexpectedArrayLength_2, gridLength, size));
         }
-        targets     = result;
-        transform   = null;
-        correlation = null;
+        targets = result;
+        numPoints = gridLength;
     }
 
     /**
@@ -955,8 +1049,12 @@ search:         for (int j=domain(); --j >= 0;) {
      *                    Value can be from 0 inclusive to {@link #getSourceDimensions()} exclusive.
      *                    The recommended direction is the direction of most stable values, typically 1 (rows) for longitudes.
      * @param  period     that wraparound range (typically 360° for longitudes).
+     * @throws IllegalStateException if {@link #create(MathTransformFactory) create(…)} has already been invoked.
      */
     final void resolveWraparoundAxis(final int dimension, final int direction, final double period) {
+        if (transform != null) {
+            throw new IllegalStateException(unmodifiable());
+        }
         final double[] coordinates = targets[dimension];
         int stride = 1;
         for (int i=0; i<direction; i++) {
@@ -1014,8 +1112,56 @@ search:         for (int j=domain(); --j >= 0;) {
     }
 
     /**
+     * Adds transforms to potentially apply on target coordinates before to compute the linear transform.
+     * This method can be invoked if one suspects that the <cite>source to target</cite> transform may be
+     * more linear when the target is another space than the current space of {@linkplain #getTargetEnvelope()
+     * target coordinates}. If linearizers have been specified, then the {@link #create(MathTransformFactory)}
+     * method will try to apply each transform on target coordinates and check which one results in the best
+     * {@linkplain #correlation() correlation} coefficients. It may be none.
+     *
+     * <p>The linearizers are specified as {@link MathTransform}s from current target coordinates to other spaces
+     * where <cite>sources to new targets</cite> transforms may be more linear. The keys in the map are arbitrary
+     * identifiers used in {@link #toString()} for analysis or debugging purpose.
+     * The {@code dimensions} argument specifies which target dimensions to project and can be null or omitted
+     * if the projections shall be applied on all target coordinates. It is possible to invoke this method many
+     * times with different {@code dimensions} argument values.</p>
+     *
+     * @param  projections  projections from current target coordinates to other spaces which may result in more linear transforms.
+     * @param  dimensions   the target dimensions to project, or null or omitted for projecting all target dimensions.
+     *                      If non-null and non-empty, then all transforms in the {@code projections} map shall have a
+     *                      number of source and target dimensions equals to the length of this array.
+     * @throws IllegalStateException if {@link #create(MathTransformFactory) create(…)} has already been invoked.
+     *
+     * @see #linearizer()
+     * @see #correlation()
+     *
+     * @since 1.0
+     */
+    public void addLinearizers(final Map<String,MathTransform> projections, int... dimensions) {
+        if (transform != null) {
+            throw new IllegalStateException(unmodifiable());
+        }
+        final int tgtDim = getTargetDimensions();
+        if (dimensions == null || dimensions.length == 0) {
+            dimensions = ArraysExt.sequence(0, tgtDim);
+        }
+        if (linearizers == null) {
+            linearizers = new ArrayList<>();
+        }
+        for (final Map.Entry<String,MathTransform> entry : projections.entrySet()) {
+            ProjectedTransformTry t = new ProjectedTransformTry(entry.getKey(), entry.getValue(), dimensions, tgtDim);
+            if (!t.projection.isIdentity()) {
+                linearizers.add(t);
+            }
+        }
+    }
+
+    /**
      * 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.
+     * If {@linkplain #addLinearizers linearizers have been specified}, then this method may project all target
+     * coordinates using one of those linearizers in order to get a more linear transform.
+     * If such projection is applied, then {@link #linearizer()} will return a non-empty value after this method call.
      *
      * @param  factory  the factory to use for creating the transform, or {@code null} for the default factory.
      *                  The {@link MathTransformFactory#createAffineTransform(Matrix)} method of that factory
@@ -1027,68 +1173,129 @@ search:         for (int j=domain(); --j >= 0;) {
      * @since 0.8
      */
     @Override
-    @SuppressWarnings("serial")
     public LinearTransform create(final MathTransformFactory factory) throws FactoryException {
         if (transform == null) {
-            final double[][] sources = this.sources;                    // Protect from changes.
-            final double[][] targets = this.targets;
-            if (targets == null) {
-                throw new InvalidGeodeticParameterException(noData());
-            }
-            final int sourceDim = (sources != null) ? sources.length : gridSize.length;
-            final int targetDim = targets.length;
-            correlation = new double[targetDim];
-            final MatrixSIS matrix = Matrices.create(targetDim + 1, sourceDim + 1,  ExtendedPrecisionMatrix.ZERO);
-            matrix.setElement(targetDim, sourceDim, 1);
-            for (int j=0; j < targetDim; j++) {
-                final double c;
-                switch (sourceDim) {
-                    case 1: {
-                        final int row = j;
-                        final Line line = new Line() {
-                            @Override public void setEquation(final Number slope, final Number y0) {
-                                super.setEquation(slope, y0);
-                                matrix.setNumber(row, 0, slope);    // Preserve the extended precision (double-double).
-                                matrix.setNumber(row, 1, y0);
-                            }
-                        };
-                        if (sources != null) {
-                            c = line.fit(vector(sources[0]), vector(targets[j]));
+            MatrixSIS matrix = fit();
+            if (linearizers != null) {
+                /*
+                 * We are going to try to project target coordinates in an attempt to find a more linear transform.
+                 * If a projection allows better results than unprojected coordinates, the following variables will
+                 * be set to values to assign to this 'LinearTransformBuilder' after the loop. We do not assign new
+                 * values to this 'LinearTransformBuilder' directly (as we find them) in the loop because the checks
+                 * for a better transform require the original values.
+                 */
+                double     bestCorrelation   = average(correlations);
+                double[]   bestCorrelations  = null;
+                MatrixSIS  bestTransform     = null;
+                double[][] transformedArrays = null;
+                /*
+                 * Store the correlation when using no conversions, only for this.toString() purpose. We copy
+                 * 'ProjectedTransformTry' list in an array both for excluding the dummy entry, and also for
+                 * avoiding ConcurrentModificationException if a debugger invokes toString() during the loop.
+                 */
+                final ProjectedTransformTry[] alternatives = linearizers.toArray(new ProjectedTransformTry[linearizers.size()]);
+                linearizers.add(new ProjectedTransformTry((float) bestCorrelation));
+                /*
+                 * 'tmp' and 'pool' are temporary objects for this computation only. We use a pool because the
+                 * 'double[]' arrays may be large (e.g. megabytes) and we want to avoid creating new arrays of
+                 * such size for each projection to try.
+                 */
+                final Queue<double[]> pool = new ArrayDeque<>();
+                final int n = (gridLength != 0) ? gridLength : numPoints;
+                final LinearTransformBuilder tmp = new LinearTransformBuilder(this);
+                for (final ProjectedTransformTry alt : alternatives) {
+                    if ((tmp.targets = alt.transform(targets, n, pool)) != null) {
+                        final MatrixSIS altTransform    = tmp.fit();
+                        final double[]  altCorrelations = alt.replace(correlations, tmp.correlations);
+                        final double    altCorrelation  = average(altCorrelations);
+                        alt.correlation = (float) altCorrelation;
+                        if (altCorrelation > bestCorrelation) {
+                            ProjectedTransformTry.recycle(transformedArrays, pool);
+                            transformedArrays = tmp.targets;
+                            bestCorrelation   = altCorrelation;
+                            bestCorrelations  = altCorrelations;
+                            bestTransform     = alt.replace(matrix, altTransform);
+                            appliedLinearizer = alt;
                         } else {
-                            c = line.fit(Vector.createSequence(0, 1, gridSize[0]),
-                                         Vector.create(targets[j]));
+                            ProjectedTransformTry.recycle(tmp.targets, pool);
                         }
-                        break;
                     }
-                    case 2: {
-                        final int row = j;
-                        final Plane plan = new Plane() {
-                            @Override public void setEquation(final Number sx, final Number sy, final Number z0) {
-                                super.setEquation(sx, sy, z0);
-                                matrix.setNumber(row, 0, sx);       // Preserve the extended precision (double-double).
-                                matrix.setNumber(row, 1, sy);
-                                matrix.setNumber(row, 2, z0);
-                            }
-                        };
-                        if (sources != null) {
-                            c = plan.fit(vector(sources[0]), vector(sources[1]), vector(targets[j]));
-                        } else try {
-                            c = plan.fit(gridSize[0], gridSize[1], Vector.create(targets[j]));
-                        } catch (IllegalArgumentException e) {
-                            // This may happen if the z vector still contain some "NaN" values.
-                            throw new InvalidGeodeticParameterException(noData(), e);
+                }
+                if (bestTransform != null) {
+                    matrix       = bestTransform;
+                    targets      = transformedArrays;
+                    correlations = bestCorrelations;
+                }
+            }
+            transform = (LinearTransform) nonNull(factory).createAffineTransform(matrix);
+        }
+        return transform;
+    }
+
+    /**
+     * Computes the matrix of the linear approximation. This is the implementation of {@link #create(MathTransformFactory)}
+     * without the step creating the {@link LinearTransform} from a matrix. The {@link #correlations} field is set as a side
+     * effect of this method call.
+     */
+    @SuppressWarnings("serial")
+    private MatrixSIS fit() throws FactoryException {
+        final double[][] sources = this.sources;                    // Protect from changes.
+        final double[][] targets = this.targets;
+        if (targets == null) {
+            throw new InvalidGeodeticParameterException(noData());
+        }
+        final int sourceDim = (sources != null) ? sources.length : gridSize.length;
+        final int targetDim = targets.length;
+        correlations = new double[targetDim];
+        final MatrixSIS matrix = Matrices.create(targetDim + 1, sourceDim + 1,  ExtendedPrecisionMatrix.ZERO);
+        matrix.setElement(targetDim, sourceDim, 1);
+        for (int j=0; j < targetDim; j++) {
+            final double c;
+            switch (sourceDim) {
+                case 1: {
+                    final int row = j;
+                    final Line line = new Line() {
+                        @Override public void setEquation(final Number slope, final Number y0) {
+                            super.setEquation(slope, y0);
+                            matrix.setNumber(row, 0, slope);    // Preserve the extended precision (double-double).
+                            matrix.setNumber(row, 1, y0);
                         }
-                        break;
+                    };
+                    if (sources != null) {
+                        c = line.fit(vector(sources[0]), vector(targets[j]));
+                    } else {
+                        c = line.fit(Vector.createSequence(0, 1, gridSize[0]),
+                                     Vector.create(targets[j]));
                     }
-                    default: {
-                        throw new FactoryException(Errors.format(Errors.Keys.ExcessiveNumberOfDimensions_1, sourceDim));
+                    break;
+                }
+                case 2: {
+                    final int row = j;
+                    final Plane plan = new Plane() {
+                        @Override public void setEquation(final Number sx, final Number sy, final Number z0) {
+                            super.setEquation(sx, sy, z0);
+                            matrix.setNumber(row, 0, sx);       // Preserve the extended precision (double-double).
+                            matrix.setNumber(row, 1, sy);
+                            matrix.setNumber(row, 2, z0);
+                        }
+                    };
+                    if (sources != null) {
+                        c = plan.fit(vector(sources[0]), vector(sources[1]), vector(targets[j]));
+                    } else try {
+                        c = plan.fit(gridSize[0], gridSize[1], Vector.create(targets[j]));
+                    } catch (IllegalArgumentException e) {
+                        // This may happen if the z vector still contain some "NaN" values.
+                        throw new InvalidGeodeticParameterException(noData(), e);
                     }
+                    break;
+                }
+                default: {
+                    throw new FactoryException(Errors.format(Errors.Keys.ExcessiveNumberOfDimensions_1, sourceDim));
                 }
-                correlation[j] = c;
             }
-            transform = (LinearTransform) nonNull(factory).createAffineTransform(matrix);
+            correlations[j] = c;
         }
-        return transform;
+        return matrix;
     }
 
     /**
@@ -1102,43 +1309,137 @@ search:         for (int j=domain(); --j >= 0;) {
     }
 
     /**
-     * Returns the correlation coefficients of the last transform created by {@link #create create(…)},
-     * or {@code null} if none. If non-null, the array length is equals to the number of target
-     * dimensions.
+     * Returns a global estimation of correlation by computing the average of absolute values.
+     * We don't use {@link org.apache.sis.math.MathFunctions#magnitude(double...)} because it
+     * would result in values greater than 1.
+     */
+    private static double average(final double[] correlations) {
+        double sum = 0;
+        for (int i=0; i<correlations.length; i++) {
+            sum += Math.abs(correlations[i]);
+        }
+        return sum / correlations.length;
+    }
+
+    /**
+     * If all target coordinates have been projected to another space, returns the projection applied.
+     * This method returns a non-empty value only if all the following conditions are met:
      *
-     * @return estimation of correlation coefficients for each target dimension, or {@code null}.
+     * <ol>
+     *   <li>{@link #addLinearizers(Map, int...)} has been invoked.</li>
+     *   <li>{@link #create(MathTransformFactory)} has been invoked.</li>
+     *   <li>The {@code create(…)} method at step 2 found that projecting target coordinates using
+     *       one of the linearizers specified at step 1 results in a more linear transform.</li>
+     * </ol>
+     *
+     * If this method returns a non-empty value, then the envelope returned by {@link #getTargetEnvelope()}
+     * and all control points returned by {@link #getControlPoint(int[])} are projected by this transform.
+     *
+     * @return the projection applied on target coordinates before to compute a linear transform.
+     *
+     * @since 1.0
+     */
+    public Optional<MathTransform> linearizer() {
+        return (appliedLinearizer != null) ? Optional.of(appliedLinearizer.projection) : Optional.empty();
+    }
+
+    /**
+     * Returns the Pearson correlation coefficients of the transform created by {@link #create create(…)}.
+     * The closer those coefficients are to +1 or -1, the better the fit.
+     * This method returns {@code null} if {@code create(…)} has not yet been invoked.
+     * If non-null, the array length is equal to the number of target dimensions.
+     *
+     * @return estimation of Pearson correlation coefficients for each target dimension,
+     *         or {@code null} if {@code create(…)} has not been invoked yet.
      */
     public double[] correlation() {
-        return (correlation != null) ? correlation.clone() : null;
+        return (correlations != null) ? correlations.clone() : null;
     }
 
     /**
      * Returns a string representation of this builder for debugging purpose.
+     * Current implementation shows the following information:
+     *
+     * <ul>
+     *   <li>Number of points.</li>
+     *   <li>Linearizers and their correlation coefficients (if available).</li>
+     *   <li>The linear transform (if already computed).</li>
+     * </ul>
+     *
+     * The string representation may change in any future version.
      *
      * @return a string representation of this builder.
      */
     @Override
     public String toString() {
-        final StringBuilder buffer = new StringBuilder(Classes.getShortClassName(this)).append('[');
-        if (sources != null) {
-            buffer.append(sources[0].length).append(" points");
+        final StringBuilder buffer = new StringBuilder(Classes.getShortClassName(this))
+                .append('[').append(numPoints).append(" points");
+        if (gridSize != null) {
+            String separator = " on ";
+            for (final int size : gridSize) {
+                buffer.append(separator).append(size);
+                separator = " × ";
+            }
+            buffer.append(" grid");
         }
         buffer.append(']');
+        final String lineSeparator = System.lineSeparator();
+        /*
+         * Example (from LinearTransformBuilderTest):
+         * ┌────────────┬─────────────┐
+         * │ Conversion │ Correlation │
+         * ├────────────┼─────────────┤
+         * │ x³ y²      │ 1.000000    │
+         * │ x² y³      │ 0.997437    │
+         * │ Identité   │ 0.995969    │
+         * └────────────┴─────────────┘
+         */
+        if (linearizers != null) {
+            buffer.append(':').append(lineSeparator);
+            Collections.sort(linearizers);
+            NumberFormat nf = null;
+            final TableAppender table = new TableAppender(buffer, " │ ");
+            table.appendHorizontalSeparator();
+            table.append(Vocabulary.format(Vocabulary.Keys.Conversion)).nextColumn();
+            table.append(Vocabulary.format(Vocabulary.Keys.Correlation)).nextLine();
+            table.appendHorizontalSeparator();
+            for (final ProjectedTransformTry alt : linearizers) {
+                nf = alt.summarize(table, nf);
+            }
+            table.appendHorizontalSeparator();
+            try {
+                table.flush();
+            } catch (IOException e) {
+                throw new UncheckedIOException(e);      // Should never happen since we wrote into a StringBuilder.
+            }
+        }
+        /*
+         * Example:
+         * Result:
+         * ┌               ┐                 ┌        ┐
+         * │ 2.0  0    3.0 │   Correlation = │ 0.9967 │
+         * │ 0    1.0  1.0 │                 │ 0.9950 │
+         * │ 0    0    1   │                 └        ┘
+         * └               ┘
+         */
         if (transform != null) {
-            final String lineSeparator = System.lineSeparator();
+            if (linearizers != null) {
+                buffer.append(Vocabulary.format(Vocabulary.Keys.Result));
+            }
             buffer.append(':').append(lineSeparator);
             final TableAppender table = new TableAppender(buffer, " ");
             table.setMultiLinesCells(true);
             table.append(Matrices.toString(transform.getMatrix())).nextColumn();
             table.append(lineSeparator).append("  ")
                  .append(Vocabulary.format(Vocabulary.Keys.Correlation)).append(" =").nextColumn();
-            table.append(Matrices.create(correlation.length, 1, correlation).toString());
+            table.append(Matrices.create(correlations.length, 1, correlations).toString());
             try {
                 table.flush();
             } catch (IOException e) {
                 throw new UncheckedIOException(e);      // Should never happen since we wrote into a StringBuilder.
             }
         }
+        Strings.insertLineInLeftMargin(buffer, lineSeparator);
         return buffer.toString();
     }
 }
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 b3fb15a..a035ec9 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
@@ -61,6 +61,9 @@ import static org.apache.sis.referencing.operation.builder.ResidualGrid.SOURCE_D
  *   <li>Create a {@link InterpolatedTransform} with the above shift grid.</li>
  * </ol>
  *
+ * Builders can be used only once;
+ * points can not be added or modified after {@link #create(MathTransformFactory)} has been invoked.
+ *
  * @author  Martin Desruisseaux (Geomatys)
  * @version 1.0
  *
@@ -351,6 +354,7 @@ public class LocalizationGridBuilder extends TransformBuilder {
      * <i>etc.</i>. Coordinates are stored in row-major order (column index varies faster, followed by row index).
      *
      * @param  coordinates coordinates in each target dimensions, stored in row-major order.
+     * @throws IllegalStateException if {@link #create(MathTransformFactory) create(…)} has already been invoked.
      *
      * @since 1.0
      */
@@ -369,6 +373,7 @@ public class LocalizationGridBuilder extends TransformBuilder {
      * @param  gridX   the column index in the grid where to store the given target position.
      * @param  gridY   the row index in the grid where to store the given target position.
      * @param  target  the target coordinates, assumed uncertain.
+     * @throws IllegalStateException if {@link #create(MathTransformFactory) create(…)} has already been invoked.
      * @throws IllegalArgumentException if the {@code x} or {@code y} coordinate value is out of grid range.
      * @throws MismatchedDimensionException if the target position does not have the expected number of dimensions.
      */
diff --git a/core/sis-referencing/src/main/java/org/apache/sis/referencing/operation/builder/ProjectedTransformTry.java b/core/sis-referencing/src/main/java/org/apache/sis/referencing/operation/builder/ProjectedTransformTry.java
new file mode 100644
index 0000000..5065b8f
--- /dev/null
+++ b/core/sis-referencing/src/main/java/org/apache/sis/referencing/operation/builder/ProjectedTransformTry.java
@@ -0,0 +1,324 @@
+/*
+ * 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.referencing.operation.builder;
+
+import java.util.Queue;
+import java.util.Arrays;
+import java.text.NumberFormat;
+import org.opengis.geometry.MismatchedDimensionException;
+import org.opengis.referencing.operation.MathTransform;
+import org.opengis.referencing.operation.TransformException;
+import org.apache.sis.referencing.operation.matrix.MatrixSIS;
+import org.apache.sis.internal.referencing.Resources;
+import org.apache.sis.io.TableAppender;
+import org.apache.sis.util.ArraysExt;
+import org.apache.sis.util.resources.Vocabulary;
+
+
+/**
+ * Information about an attempt to transform coordinates to some projection before to compute a linear approximation.
+ * This class contains only the projection to be attempted and a summary of the result. We do not keep new coordinates
+ * in order to avoid consuming too much memory when many attempts are made; {@link LinearTransformBuilder} needs only
+ * to keep the best attempt.
+ *
+ * <div class="note">
+ * <p><b>Purpose:</b> localization grids in netCDF files contain (<var>longitude</var>, <var>latitude</var>) values for all pixels.
+ * {@link LocalizationGridBuilder} first computes a linear (affine) approximation of a localization grid, then stores the residuals.
+ * This approach works well when the residuals are small. However if the localization grid is non-linear, then the affine transform
+ * is a poor approximation of that grid and the residuals are high. High residuals make inverse transforms hard to compute, which
+ * sometime cause a {@link TransformException} with <cite>"no convergence"</cite> error message.</p>
+ *
+ * <p>In practice, localization grids in netCDF files are often used for storing the results of a map projection, e.g. Mercator.
+ * This class allows {@link LocalizationGridBuilder} to try to transform the grid using a given list of map projections and see
+ * if one of those projections results in a grid closer to an affine transform. In other words, we use this class for trying to
+ * guess what the projection may be. It is okay if the guess is not a perfect match; if the residuals become smalls enough,
+ * it will resolve the "no convergence" errors.</p>
+ * </div>
+ *
+ * @author  Martin Desruisseaux (Geomatys)
+ * @version 1.0
+ * @since   1.0
+ * @module
+ */
+final class ProjectedTransformTry implements Comparable<ProjectedTransformTry> {
+    /**
+     * Number of points in the temporary buffer used for transforming data.
+     * The buffer length will be this capacity multiplied by the number of dimensions.
+     *
+     * @see org.apache.sis.referencing.operation.transform.AbstractMathTransform#MAXIMUM_BUFFER_SIZE
+     */
+    private static final int BUFFER_CAPACITY = 512;
+
+    /**
+     * A name by witch this projection attempt is identified.
+     */
+    private String name;
+
+    /**
+     * A conversion from a non-linear grid (typically with longitude and latitude values) to
+     * something that may be more linear (typically, but not necessarily, a map projection).
+     */
+    final MathTransform projection;
+
+    /**
+     * Maps {@link #projection} dimensions to {@link LinearTransformBuilder} target dimensions.
+     * For example if this array is {@code {2,1}}, then dimensions 0 and 1 of {@code projection}
+     * map dimensions 2 and 1 of {@link LinearTransformBuilder#targets} respectively. The length
+     * of this array shall be equal to the number of {@link #projection} source dimensions.
+     */
+    private final int[] dimensions;
+
+    /**
+     * A global correlation factor, stored for information purpose only.
+     */
+    float correlation;
+
+    /**
+     * If an error occurred during coordinate operations, the error. Otherwise {@code null}.
+     */
+    private TransformException error;
+
+    /**
+     * Creates a new instance with only the given correlation coefficient. This instance can not be used for
+     * computation purpose. Its sole purpose is to hold the given coefficient when no projection is applied.
+     */
+    ProjectedTransformTry(final float corr) {
+        projection  = null;
+        dimensions  = null;
+        correlation = corr;
+    }
+
+    /**
+     * Prepares a new attempt to project a localization grid.
+     * All arguments are stored as-is (arrays are not cloned).
+     *
+     * @param name               a name by witch this projection attempt is identified, or {@code null}.
+     * @param projection         conversion from non-linear grid to something that may be more linear.
+     * @param dimensions         maps {@code projection} dimensions to {@link LinearTransformBuilder} target dimensions.
+     * @param expectedDimension  number of {@link LinearTransformBuilder} target dimensions.
+     */
+    ProjectedTransformTry(final String name, final MathTransform projection, final int[] dimensions, int expectedDimension) {
+        this.name       = name;
+        this.projection = projection;
+        this.dimensions = dimensions;
+        int side = 0;                           // 0 = problem with source dimensions, 1 = problem with target dimensions.
+        int actual = projection.getSourceDimensions();
+        if (actual <= expectedDimension) {
+            expectedDimension = dimensions.length;
+            if (actual == expectedDimension) {
+                actual = projection.getTargetDimensions();
+                if (actual == expectedDimension) {
+                    return;
+                }
+                side = 1;
+            }
+        }
+        throw new MismatchedDimensionException(Resources.format(
+                Resources.Keys.MismatchedTransformDimension_3, side, expectedDimension, actual));
+    }
+
+    /**
+     * Transforms target coordinates of a localization grid. The {@code coordinates} argument is the value
+     * of {@link LinearTransformBuilder#targets}, without clone (this method will only read those arrays).
+     * Only arrays at indices given by {@link #dimensions} will be read; the other arrays will be ignored.
+     * The coordinate operation result will be stored in arrays of size {@code [numDimensions][numPoints]}
+     * where {@code numDimensions} is the length of the {@link #dimensions} array. Indices are as below,
+     * with 0 ≦ <var>d</var> ≦ {@code numDimensions}:
+     *
+     * <ol>
+     *   <li>{@code results[d]}    contains the coordinates in dimension <var>d</var>.</li>
+     *   <li>{@code results[d][i]} is a coordinate of the point at index <var>i</var>.</li>
+     * </ol>
+     *
+     * The {@code pool} queue is initially empty. Arrays created by this method and later discarded will be added to
+     * that queue, for recycling if this method is invoked again for another {@code ProjectedTransformTry} instance.
+     *
+     * @param  coordinates  the {@link LinearTransformBuilder#targets} arrays of coordinates to transform.
+     * @param  numPoints    number of points to transform: {@code numPoints} ≦ {@code coordinates[i].length}.
+     * @param  pool         pre-allocated arrays of length {@code numPoints} that can be recycled.
+     * @return results of coordinate operations (see method javadoc), or {@code null} if an error occurred.
+     */
+    final double[][] transform(final double[][] coordinates, final int numPoints, final Queue<double[]> pool) {
+        final int numDimensions = dimensions.length;
+        final double[][] results = new double[numDimensions][];
+        for (int i=0; i<numDimensions; i++) {
+            if ((results[i] = pool.poll()) == null) {
+                results[i] = new double[numPoints];
+            }
+        }
+        /*
+         * Allocate the destination arrays for coordinates to transform as (x₀,y₀), (x₁,y₁), (x₂,y₂)… tuples.
+         * In the particular case of one-dimensional transforms (not necessarily one-dimensional coordinates)
+         * we can transform arrays directly without the need for a temporary buffer.
+         */
+        try {
+            if (numDimensions == 1) {
+                projection.transform(coordinates[dimensions[0]], 0, results[0], 0, numPoints);
+            } else {
+                final int bufferCapacity = Math.min(numPoints, BUFFER_CAPACITY);                 // In number of points.
+                final double[] buffer = new double[bufferCapacity * numDimensions];
+                int dataOffset = 0;
+                while (dataOffset < numPoints) {
+                    final int start = dataOffset;
+                    final int stop = Math.min(start + bufferCapacity, numPoints);
+                    /*
+                     * Copies coordinates in a single interleaved array before to transform them.
+                     * Coordinates start at index 0 and the number of valid points is stop - start.
+                     */
+                    for (int d=0; d<numDimensions; d++) {
+                        final double[] data = coordinates[dimensions[d]];
+                        dataOffset = start;
+                        int dst = d;
+                        do {
+                            buffer[dst] = data[dataOffset];
+                            dst += numDimensions;
+                        } while (++dataOffset < stop);
+                    }
+                    /*
+                     * Transform coordinates and save the result.
+                     */
+                    projection.transform(buffer, 0, buffer, 0, stop - start);
+                    for (int d=0; d<numDimensions; d++) {
+                        @SuppressWarnings("MismatchedReadAndWriteOfArray")
+                        final double[] data = results[d];
+                        dataOffset = start;
+                        int dst = d;
+                        do {
+                            data[dataOffset] = buffer[dst];
+                            dst += numDimensions;
+                        } while (++dataOffset < stop);
+                    }
+                }
+            }
+        } catch (TransformException e) {
+            error = e;
+            recycle(results, pool);         // Make arrays available for other transforms.
+            return null;
+        }
+        return results;
+    }
+
+    /**
+     * Makes the given arrays available for reuse by other transforms.
+     */
+    static void recycle(final double[][] arrays, final Queue<double[]> pool) {
+        if (arrays != null) {
+            pool.addAll(Arrays.asList(arrays));
+        }
+    }
+
+    /**
+     * Replaces old correlation values by new values in a copy of the given array.
+     * May return {@code newValues} directly if suitable.
+     *
+     * @param  correlations  the original correlation values. This array will not be modified.
+     * @param  newValues     correlations computed by {@link LinearTransformBuilder} for the dimensions specified at construction time.
+     * @return a copy of the given {@code correlation} array with new values overwriting the old values.
+     */
+    final double[] replace(double[] correlations, final double[] newValues) {
+        if (newValues.length == correlations.length && ArraysExt.isSequence(0, dimensions)) {
+            return newValues;
+        }
+        correlations = correlations.clone();
+        for (int j=0; j<dimensions.length; j++) {
+            correlations[dimensions[j]] = newValues[j];
+        }
+        return correlations;
+    }
+
+    /**
+     * Replaces old transform coefficients by new values in a copy of the given matrix.
+     * May return {@code newValues} directly if suitable.
+     *
+     * @param  transform  the original affine transform. This matrix will not be modified.
+     * @param  newValues  coefficients computed by {@link LinearTransformBuilder} for the dimensions specified at construction time.
+     * @return a copy of the given {@code transform} matrix with new coefficients overwriting the old values.
+     */
+    final MatrixSIS replace(MatrixSIS transform, final MatrixSIS newValues) {
+        /*
+         * The two matrices shall have the same number of columns because they were computed with
+         * LinearTransformBuilder instances having the same sources. However the two matrices may
+         * have a different number of rows since the number of target dimensions may differ.
+         */
+        assert newValues.getNumCol() == transform.getNumCol();
+        if (newValues.getNumRow() == transform.getNumRow() && ArraysExt.isSequence(0, dimensions)) {
+            return newValues;
+        }
+        transform = transform.clone();
+        for (int j=0; j<dimensions.length; j++) {
+            final int d = dimensions[j];
+            for (int i=transform.getNumRow(); --i >= 0;) {
+                transform.setNumber(d, i, newValues.getNumber(j, i));
+            }
+        }
+        return transform;
+    }
+
+    /**
+     * Order by the inverse of correlation coefficients. Highest coefficients (best correlations)
+     * are first, lower coefficients are next, {@link Float#NaN} values are last.
+     */
+    @Override
+    public int compareTo(final ProjectedTransformTry other) {
+        return Float.compare(-correlation, -other.correlation);
+    }
+
+    /**
+     * Formats a summary of this projection attempt. This method formats the following columns:
+     *
+     * <ol>
+     *   <li>The projection name.</li>
+     *   <li>The corelation coefficient, or the error message if an error occurred.</li>
+     * </ol>
+     *
+     * @param  table  the table where to write a row.
+     * @param  nf     format to use for writing coefficients, or {@code null} if not yet created.
+     * @return format used for writing coefficients, or {@code null}.
+     */
+    final NumberFormat summarize(final TableAppender table, NumberFormat nf) {
+        if (name == null) {
+            name = Vocabulary.format(projection == null ? Vocabulary.Keys.Identity : Vocabulary.Keys.Unnamed);
+        }
+        table.append(name).nextColumn();
+        String message = "";
+        if (error != null) {
+            message = error.getMessage();
+            if (message == null) {
+                message = error.getClass().getSimpleName();
+            }
+        } else if (correlation > 0) {
+            if (nf == null) {
+                nf = NumberFormat.getInstance();
+                nf.setMinimumFractionDigits(6);         // Math.ulp(1f) ≈ 1.2E-7
+                nf.setMaximumFractionDigits(6);
+            }
+            message = nf.format(correlation);
+        }
+        table.append(message).nextLine();
+        return nf;
+    }
+
+    /**
+     * Returns a string representation of this projection attempt for debugging purpose.
+     */
+    @Override
+    public String toString() {
+        final TableAppender buffer = new TableAppender("  ");
+        summarize(buffer, null);
+        return buffer.toString();
+    }
+}
diff --git a/core/sis-referencing/src/main/java/org/apache/sis/referencing/operation/builder/TransformBuilder.java b/core/sis-referencing/src/main/java/org/apache/sis/referencing/operation/builder/TransformBuilder.java
index ef621d7..3a60ed1 100644
--- a/core/sis-referencing/src/main/java/org/apache/sis/referencing/operation/builder/TransformBuilder.java
+++ b/core/sis-referencing/src/main/java/org/apache/sis/referencing/operation/builder/TransformBuilder.java
@@ -28,6 +28,9 @@ import org.apache.sis.referencing.operation.transform.DefaultMathTransformFactor
  * The transform may be a linear approximation the minimize the errors in a <cite>least square</cite> sense,
  * or a more accurate transform using a localization grid.
  *
+ * <p>Builders can be used only once;
+ * points can not be added or modified after {@link #create(MathTransformFactory)} has been invoked.</p>
+ *
  * @author  Martin Desruisseaux (Geomatys)
  * @version 0.8
  * @since   0.8
@@ -42,6 +45,9 @@ public abstract class TransformBuilder {
 
     /**
      * Creates a transform from the source points to the target points.
+     * Invoking this method puts the builder in an unmodifiable state.
+     * Invoking this method more than once returns the same transform
+     * (the transform is not recomputed).
      *
      * @param  factory  the factory to use for creating the transform, or {@code null} for the default factory.
      * @return the transform from source to target points.
diff --git a/core/sis-referencing/src/main/java/org/apache/sis/referencing/operation/transform/AbstractMathTransform.java b/core/sis-referencing/src/main/java/org/apache/sis/referencing/operation/transform/AbstractMathTransform.java
index 3ac113d..5664883 100644
--- a/core/sis-referencing/src/main/java/org/apache/sis/referencing/operation/transform/AbstractMathTransform.java
+++ b/core/sis-referencing/src/main/java/org/apache/sis/referencing/operation/transform/AbstractMathTransform.java
@@ -581,7 +581,7 @@ public abstract class AbstractMathTransform extends FormattableObject
             } catch (TransformException exception) {
                 /*
                  * If an exception occurred but the transform nevertheless declares having been
-                 * able to process all coordinate points (setting to NaN those that can't be
+                 * able to process all coordinate points (setting to NaN those that can not be
                  * transformed), we will keep the first exception (to be propagated at the end
                  * of this method) and continue. Otherwise we will stop immediately.
                  */
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 1deb313..582eaa1 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
@@ -19,9 +19,12 @@ package org.apache.sis.referencing.operation.builder;
 import java.util.Map;
 import java.util.HashMap;
 import java.util.Random;
+import java.util.Collections;
 import java.awt.geom.AffineTransform;
 import org.opengis.util.FactoryException;
+import org.opengis.geometry.DirectPosition;
 import org.opengis.referencing.operation.Matrix;
+import org.apache.sis.referencing.operation.matrix.Matrix3;
 import org.apache.sis.geometry.DirectPosition1D;
 import org.apache.sis.geometry.DirectPosition2D;
 import org.apache.sis.test.DependsOnMethod;
@@ -30,7 +33,6 @@ import org.apache.sis.test.TestCase;
 import org.junit.Test;
 
 import static org.apache.sis.test.Assert.*;
-import org.opengis.geometry.DirectPosition;
 
 
 /**
@@ -406,4 +408,37 @@ public final strictfp class LinearTransformBuilderTest extends TestCase {
         assertTrue (actual.containsValue(t00));
         assertMapEquals(expected, actual);
     }
+
+    /**
+     * Tests the effect of {@link LinearTransformBuilder#addLinearizers(Map, int...)}.
+     *
+     * @throws FactoryException if the transform can not be created.
+     */
+    @Test
+    public void testLinearizers() throws FactoryException {
+        final int width  = 3;
+        final int height = 4;
+        final LinearTransformBuilder builder = new LinearTransformBuilder(width, height);
+        for (int y=0; y<height; y++) {
+            final int[]    source = new int[2];
+            final double[] target = new double[2];
+            for (int x=0; x<width; x++) {
+                source[0] = x;
+                source[1] = y;
+                target[0] = StrictMath.cbrt(3 + x*2);
+                target[1] = StrictMath.sqrt(1 + y);
+                builder.setControlPoint(source, target);
+            }
+        }
+        final NonLinearTransform x2y3 = new NonLinearTransform();
+        final NonLinearTransform x3y2 = new NonLinearTransform();
+        builder.addLinearizers(Collections.singletonMap("x² y³", x2y3));
+        builder.addLinearizers(Collections.singletonMap("x³ y²", x3y2), 1, 0);
+        final Matrix m = builder.create(null).getMatrix();
+        assertSame("linearizer", x3y2, builder.linearizer().get());
+        assertMatrixEquals("linear",
+                new Matrix3(2, 0, 3,
+                            0, 1, 1,
+                            0, 0, 1), m, 1E-15);
+    }
 }
diff --git a/core/sis-referencing/src/test/java/org/apache/sis/referencing/operation/builder/NonLinearTransform.java b/core/sis-referencing/src/test/java/org/apache/sis/referencing/operation/builder/NonLinearTransform.java
new file mode 100644
index 0000000..4dea19b
--- /dev/null
+++ b/core/sis-referencing/src/test/java/org/apache/sis/referencing/operation/builder/NonLinearTransform.java
@@ -0,0 +1,51 @@
+/*
+ * 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.referencing.operation.builder;
+
+import org.opengis.referencing.operation.Matrix;
+import org.apache.sis.referencing.operation.transform.AbstractMathTransform2D;
+
+
+/**
+ * A two-dimensional non-linear transform for {@link LinearTransformBuilderTest} purpose.
+ *
+ * @author  Martin Desruisseaux (Geomatys)
+ * @version 1.0
+ * @since   1.0
+ * @module
+ */
+final strictfp class NonLinearTransform extends AbstractMathTransform2D {
+    /**
+     * Creates a new instance of this class.
+     */
+    NonLinearTransform() {
+    }
+
+    /**
+     * Applies an arbitrary non-linear transform.
+     */
+    @Override
+    public Matrix transform(final double[] srcPts, int srcOff,
+                            final double[] dstPts, int dstOff, boolean derivate)
+    {
+        final double x = srcPts[srcOff++];
+        final double y = srcPts[srcOff  ];
+        dstPts[dstOff++] = x * x;
+        dstPts[dstOff  ] = y * y * y;
+        return null;
+    }
+}
diff --git a/core/sis-utility/src/main/java/org/apache/sis/internal/util/Strings.java b/core/sis-utility/src/main/java/org/apache/sis/internal/util/Strings.java
index 117dcb0..19b4cef 100644
--- a/core/sis-utility/src/main/java/org/apache/sis/internal/util/Strings.java
+++ b/core/sis-utility/src/main/java/org/apache/sis/internal/util/Strings.java
@@ -184,10 +184,31 @@ public final class Strings extends Static {
     }
 
     /**
+     * Inserts a continuation character after each line separator except the last one.
+     * The intent is to show that a block of lines are part of the same element.
+     * The characters are the same than {@link org.apache.sis.util.logging.MonolineFormatter}.
+     *
+     * @param buffer         the buffer where to insert a continuation character in the left margin.
+     * @param lineSeparator  the line separator.
+     */
+    public static void insertLineInLeftMargin(final StringBuilder buffer, final String lineSeparator) {
+        char c = '╹';
+        int i = CharSequences.skipTrailingWhitespaces(buffer, 0, buffer.length());
+        while ((i = buffer.lastIndexOf(lineSeparator, i - 1)) >= 0) {
+            buffer.insert(i + lineSeparator.length(), c);
+            c = '┃';
+        }
+    }
+
+    /**
      * Returns a string representation of an instance of the given class having the given properties.
      * This is a convenience method for implementation of {@link Object#toString()} methods that are
      * used mostly for debugging purpose.
      *
+     * <p>The content is specified by (<var>key</var>=<var>value</var>) pairs. If a value is {@code null},
+     * the whole entry is omitted. If a key is {@code null}, the value is written without the {@code "key="}
+     * part. The later happens typically when the first value is the object name.</p>
+     *
      * @param  classe      the class to format.
      * @param  properties  the (<var>key</var>=<var>value</var>) pairs.
      * @return a string representation of an instance of the given class having the given properties.
@@ -201,7 +222,10 @@ public final class Strings extends Static {
                 if (isNext) {
                     buffer.append(", ");
                 }
-                buffer.append(properties[i-1]).append('=');
+                final Object name = properties[i-1];
+                if (name != null) {
+                    buffer.append(name).append('=');
+                }
                 final boolean isText = (value instanceof CharSequence);
                 if (isText) buffer.append('“');
                 buffer.append(value);
diff --git a/core/sis-utility/src/main/java/org/apache/sis/math/Plane.java b/core/sis-utility/src/main/java/org/apache/sis/math/Plane.java
index 1dd05ae..e8e6b2e 100644
--- a/core/sis-utility/src/main/java/org/apache/sis/math/Plane.java
+++ b/core/sis-utility/src/main/java/org/apache/sis/math/Plane.java
@@ -242,6 +242,7 @@ public class Plane implements Cloneable, Serializable {
      * @param  y  vector of <var>y</var> coordinates.
      * @param  z  vector of <var>z</var> values.
      * @return an estimation of the Pearson correlation coefficient.
+     *         The closer this coefficient is to +1 or -1, the better the fit.
      * @throws IllegalArgumentException if <var>x</var>, <var>y</var> and <var>z</var> do not have the same length.
      */
     public double fit(final double[] x, final double[] y, final double[] z) {
@@ -263,6 +264,7 @@ public class Plane implements Cloneable, Serializable {
      * @param  y  vector of <var>y</var> coordinates.
      * @param  z  vector of <var>z</var> values.
      * @return an estimation of the Pearson correlation coefficient.
+     *         The closer this coefficient is to +1 or -1, the better the fit.
      * @throws IllegalArgumentException if <var>x</var>, <var>y</var> and <var>z</var> do not have the same length.
      *
      * @since 0.8
@@ -311,6 +313,7 @@ public class Plane implements Cloneable, Serializable {
      * @param  ny  number of rows.
      * @param  z   values of a matrix of {@code nx} columns by {@code ny} rows organized in a row-major fashion.
      * @return an estimation of the Pearson correlation coefficient.
+     *         The closer this coefficient is to +1 or -1, the better the fit.
      * @throws IllegalArgumentException if <var>z</var> does not have the expected length or if a <var>z</var>
      *         value is {@link Double#NaN}.
      *
@@ -340,6 +343,7 @@ public class Plane implements Cloneable, Serializable {
      *
      * @param  points  the three-dimensional points.
      * @return an estimation of the Pearson correlation coefficient.
+     *         The closer this coefficient is to +1 or -1, the better the fit.
      * @throws MismatchedDimensionException if a point is not three-dimensional.
      */
     public double fit(final Iterable<? extends DirectPosition> points) {
diff --git a/core/sis-utility/src/main/java/org/apache/sis/util/ArraysExt.java b/core/sis-utility/src/main/java/org/apache/sis/util/ArraysExt.java
index a6dd7a7..26079ae 100644
--- a/core/sis-utility/src/main/java/org/apache/sis/util/ArraysExt.java
+++ b/core/sis-utility/src/main/java/org/apache/sis/util/ArraysExt.java
@@ -1259,7 +1259,7 @@ public final class ArraysExt extends Static {
     }
 
     /**
-     * Returns a finite arithmetic progression of the given length. Each value is increased by 1.
+     * Returns a finite arithmetic progression of the given length and common difference of 1.
      * For example {@code sequence(-1, 4)} returns {@code {-1, 0, 1, 2}}.
      *
      * <div class="note"><b>Purpose:</b>
@@ -1302,7 +1302,8 @@ public final class ArraysExt extends Static {
     }
 
     /**
-     * Returns {@code true} if the given array is a finite arithmetic progression starting at the given value.
+     * Returns {@code true} if the given array is a finite arithmetic progression starting at the given value
+     * and having a common difference of 1.
      * More specifically:
      *
      * <ul>
@@ -1318,6 +1319,9 @@ public final class ArraysExt extends Static {
      * {@code isSequence(1, array)} returns {@code true} if the given array is {@code {1, 2, 3, 4}}
      * but {@code false} if the array is {@code {1, 2, 4}} (missing 3).</div>
      *
+     * This method is useful when {@code array} is an argument specified to another method, and determining that the
+     * argument values are {@code start}, {@code start}+1, {@code start}+2, <i>etc.</i> allows some optimizations.
+     *
      * @param  start  first value expected in the given {@code array}.
      * @param  array  the array to test, or {@code null}.
      * @return {@code true} if the given array is non-null and equal to
diff --git a/core/sis-utility/src/main/java/org/apache/sis/util/resources/Vocabulary.java b/core/sis-utility/src/main/java/org/apache/sis/util/resources/Vocabulary.java
index 5f20a54..c3f14cf 100644
--- a/core/sis-utility/src/main/java/org/apache/sis/util/resources/Vocabulary.java
+++ b/core/sis-utility/src/main/java/org/apache/sis/util/resources/Vocabulary.java
@@ -682,6 +682,11 @@ public final class Vocabulary extends IndexedResourceBundle {
         public static final short Resolution = 153;
 
         /**
+         * Result
+         */
+        public static final short Result = 164;
+
+        /**
          * Root
          */
         public static final short Root = 90;
diff --git a/core/sis-utility/src/main/java/org/apache/sis/util/resources/Vocabulary.properties b/core/sis-utility/src/main/java/org/apache/sis/util/resources/Vocabulary.properties
index b3ff71a..e5c62c0 100644
--- a/core/sis-utility/src/main/java/org/apache/sis/util/resources/Vocabulary.properties
+++ b/core/sis-utility/src/main/java/org/apache/sis/util/resources/Vocabulary.properties
@@ -139,6 +139,7 @@ Remarks                 = Remarks
 RemoteConfiguration     = Remote configuration
 RepresentativeValue     = Representative value
 Resolution              = Resolution
+Result                  = Result
 Root                    = Root
 RootMeanSquare          = Root Mean Square
 SampleDimensions        = Sample dimensions
diff --git a/core/sis-utility/src/main/java/org/apache/sis/util/resources/Vocabulary_fr.properties b/core/sis-utility/src/main/java/org/apache/sis/util/resources/Vocabulary_fr.properties
index 5c4f7d5..30d2755 100644
--- a/core/sis-utility/src/main/java/org/apache/sis/util/resources/Vocabulary_fr.properties
+++ b/core/sis-utility/src/main/java/org/apache/sis/util/resources/Vocabulary_fr.properties
@@ -146,6 +146,7 @@ Remarks                 = Remarques
 RemoteConfiguration     = Configuration distante
 RepresentativeValue     = Valeur repr\u00e9sentative
 Resolution              = R\u00e9solution
+Result                  = R\u00e9sultat
 Root                    = Racine
 RootMeanSquare          = Moyenne quadratique
 SampleDimensions        = Dimensions d\u2019\u00e9chantillonnage
diff --git a/ide-project/NetBeans/nbproject/genfiles.properties b/ide-project/NetBeans/nbproject/genfiles.properties
index ec9b777..35088b0 100644
--- a/ide-project/NetBeans/nbproject/genfiles.properties
+++ b/ide-project/NetBeans/nbproject/genfiles.properties
@@ -3,6 +3,6 @@
 build.xml.data.CRC32=58e6b21c
 build.xml.script.CRC32=462eaba0
 build.xml.stylesheet.CRC32=28e38971@1.53.1.46
-nbproject/build-impl.xml.data.CRC32=80e7865b
+nbproject/build-impl.xml.data.CRC32=6673fb19
 nbproject/build-impl.xml.script.CRC32=a7689f96
 nbproject/build-impl.xml.stylesheet.CRC32=3a2fa800@1.89.1.48
diff --git a/ide-project/NetBeans/nbproject/project.xml b/ide-project/NetBeans/nbproject/project.xml
index 3d5b601..e24aedb 100644
--- a/ide-project/NetBeans/nbproject/project.xml
+++ b/ide-project/NetBeans/nbproject/project.xml
@@ -99,6 +99,8 @@
             <word>initially</word>
             <word>javadoc</word>
             <word>kilometre</word>
+            <word>linearizer</word>
+            <word>linearizers</word>
             <word>loggings</word>
             <word>maintainance</word>
             <word>marshallable</word>