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: Port MosaicCalculator from Geotk (was named "RegionCalculator") for use by DatumShiftGridGroup, itself used by NTv2 reader when a NTv2 file contains more than one grid without common parent. The intent is to figure out how to layout the 114 sub-grids found in NTv2 file when the file provided limited information about the parent-child relationships, especially close to the root.
Date Fri, 14 Feb 2020 19:42:28 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 8a462de  Port MosaicCalculator from Geotk (was named "RegionCalculator") for use by DatumShiftGridGroup, itself used by NTv2 reader when a NTv2 file contains more than one grid without common parent. The intent is to figure out how to layout the 114 sub-grids found in NTv2 file when the file provided limited information about the parent-child relationships, especially close to the root.
8a462de is described below

commit 8a462de986a44ca7f584f5f116cef15f5c1e40f1
Author: Martin Desruisseaux <martin.desruisseaux@geomatys.com>
AuthorDate: Fri Feb 14 11:05:29 2020 +0100

    Port MosaicCalculator from Geotk (was named "RegionCalculator") for use by DatumShiftGridGroup, itself used by NTv2 reader when a NTv2 file contains more than one grid without common parent.
    The intent is to figure out how to layout the 114 sub-grids found in NTv2 file when the file provided limited information about the parent-child relationships, especially close to the root.
---
 .../apache/sis/internal/referencing/Resources.java |   5 +
 .../sis/internal/referencing/Resources.properties  |   1 +
 .../internal/referencing/Resources_fr.properties   |   1 +
 .../internal/referencing/j2d/MosaicCalculator.java | 518 ++++++++++++++
 .../apache/sis/internal/referencing/j2d/Tile.java  | 755 +++++++++++++++++++++
 .../referencing/j2d/TranslatedTransform.java       |  75 ++
 .../sis/internal/referencing/j2d/package-info.java |   2 +-
 .../provider/DatumShiftGridCompressed.java         |   3 +
 .../referencing/provider/DatumShiftGridFile.java   |  32 +-
 .../referencing/provider/DatumShiftGridGroup.java  | 205 ++++++
 .../sis/internal/referencing/provider/NTv2.java    |   9 +-
 .../operation/matrix/AffineTransforms2D.java       |  14 +-
 .../org/apache/sis/util/resources/Vocabulary.java  |   5 +
 .../sis/util/resources/Vocabulary.properties       |   1 +
 .../sis/util/resources/Vocabulary_fr.properties    |   1 +
 15 files changed, 1617 insertions(+), 10 deletions(-)

diff --git a/core/sis-referencing/src/main/java/org/apache/sis/internal/referencing/Resources.java b/core/sis-referencing/src/main/java/org/apache/sis/internal/referencing/Resources.java
index 5931cf9..0bc18cb 100644
--- a/core/sis-referencing/src/main/java/org/apache/sis/internal/referencing/Resources.java
+++ b/core/sis-referencing/src/main/java/org/apache/sis/internal/referencing/Resources.java
@@ -303,6 +303,11 @@ public final class Resources extends IndexedResourceBundle {
         public static final short LoadingDatumShiftFile_1 = 32;
 
         /**
+         * Misaligned datum shift grid in “{0}”.
+         */
+        public static final short MisalignedDatumShiftGrid_1 = 94;
+
+        /**
          * The “{1}” parameter could have been omitted. But it has been given a value of {2} which does
          * not match the definition of the “{0}” ellipsoid.
          */
diff --git a/core/sis-referencing/src/main/java/org/apache/sis/internal/referencing/Resources.properties b/core/sis-referencing/src/main/java/org/apache/sis/internal/referencing/Resources.properties
index 4943d84..d84d3b3 100644
--- a/core/sis-referencing/src/main/java/org/apache/sis/internal/referencing/Resources.properties
+++ b/core/sis-referencing/src/main/java/org/apache/sis/internal/referencing/Resources.properties
@@ -32,6 +32,7 @@ InverseOperationUsesSameSign      = Inverse operation uses the same parameter va
 InverseOperationUsesOppositeSign  = Inverse operation uses this parameter value with opposite sign.
 LoadingDatumShiftFile_1           = Loading datum shift file \u201c{0}\u201d.
 UsingDatumShiftGrid_4             = Using datum shift grid from \u201c{0}\u201d to \u201c{1}\u201d created on {2} (updated on {3}).
+MisalignedDatumShiftGrid_1        = Misaligned datum shift grid in \u201c{0}\u201d.
 MismatchedEllipsoidAxisLength_3   = The \u201c{1}\u201d parameter could have been omitted. But it has been given a value of {2} which does not match the definition of the \u201c{0}\u201d ellipsoid.
 MismatchedOperationFactories_2    = No coordinate operation from \u201c{0}\u201d to \u201c{1}\u201d because of mismatched factories.
 MisnamedParameter_1               = Despite its name, this parameter is effectively \u201c{0}\u201d.
diff --git a/core/sis-referencing/src/main/java/org/apache/sis/internal/referencing/Resources_fr.properties b/core/sis-referencing/src/main/java/org/apache/sis/internal/referencing/Resources_fr.properties
index 5c7d81e..0c64847 100644
--- a/core/sis-referencing/src/main/java/org/apache/sis/internal/referencing/Resources_fr.properties
+++ b/core/sis-referencing/src/main/java/org/apache/sis/internal/referencing/Resources_fr.properties
@@ -37,6 +37,7 @@ InverseOperationUsesSameSign      = L\u2019op\u00e9ration inverse utilise la m\u
 InverseOperationUsesOppositeSign  = L\u2019op\u00e9ration inverse utilise ce param\u00e8tre avec la valeur de signe oppos\u00e9.
 LoadingDatumShiftFile_1           = Chargement du fichier de changement de r\u00e9f\u00e9rentiel \u00ab\u202f{0}\u202f\u00bb.
 UsingDatumShiftGrid_4             = Utilise la grille de changement de r\u00e9f\u00e9rentiel de \u00ab\u202f{0}\u202f\u00bb vers \u00ab\u202f{1}\u202f\u00bb cr\u00e9\u00e9e le {2} (mise \u00e0 jour le {3}).
+MisalignedDatumShiftGrid_1        = Les grilles de changement de r\u00e9f\u00e9rentiel de \u00ab\u202f{0}\u202f\u00bb ne sont pas align\u00e9es.
 MismatchedEllipsoidAxisLength_3   = Le param\u00e8tre \u00ab\u202f{1}\u202f\u00bb aurait pu \u00eatre omis. Mais il lui a \u00e9t\u00e9 donn\u00e9 la valeur {2} qui ne correspond pas \u00e0 la d\u00e9finition de l\u2019ellipso\u00efde \u00ab\u202f{0}\u202f\u00bb.
 MismatchedOperationFactories_2    = Il n\u2019y a pas d\u2019op\u00e9rations allant de \u00ab\u202f{0}\u202f\u00bb vers \u00ab\u202f{1}\u202f\u00bb parce que ces derniers sont associ\u00e9s \u00e0 deux fabriques diff\u00e9rentes.
 MisnamedParameter_1               = Malgr\u00e9 son nom, ce param\u00e8tre produit en r\u00e9alit\u00e9 l\u2019effet d\u2019un \u00ab\u202f{0}\u202f\u00bb.
diff --git a/core/sis-referencing/src/main/java/org/apache/sis/internal/referencing/j2d/MosaicCalculator.java b/core/sis-referencing/src/main/java/org/apache/sis/internal/referencing/j2d/MosaicCalculator.java
new file mode 100644
index 0000000..b0654cd
--- /dev/null
+++ b/core/sis-referencing/src/main/java/org/apache/sis/internal/referencing/j2d/MosaicCalculator.java
@@ -0,0 +1,518 @@
+/*
+ * 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.internal.referencing.j2d;
+
+import java.util.List;
+import java.util.Arrays;
+import java.util.ArrayList;
+import java.util.Map;
+import java.util.HashMap;
+import java.util.IdentityHashMap;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.Iterator;
+import java.io.IOException;
+import java.awt.Point;
+import java.awt.Dimension;
+import java.awt.Rectangle;
+import java.awt.geom.AffineTransform;
+import java.awt.geom.NoninvertibleTransformException;
+import java.awt.geom.Rectangle2D;
+import org.apache.sis.referencing.operation.matrix.AffineTransforms2D;
+
+
+/**
+ * Creates a collection of {@link Tile}s from their <cite>grid to CRS</cite> affine transforms.
+ * When the {@link Rectangle} that describe the destination region is known for each tiles,
+ * the {@link Tile#Tile(Rectangle, Dimension)} constructor should be invoked directly.
+ * But in some cases the destination rectangle is not known directly. Instead we have a set of tiles,
+ * all of them with an upper-left corner located at (0,0), but different <cite>grid to CRS</cite>
+ * affine transforms read from <a href="https://en.wikipedia.org/wiki/World_file">World Files</a>.
+ * This {@code MosaicCalculator} class infers the destination regions automatically
+ * from the set of affine transforms.
+ *
+ * @author  Martin Desruisseaux (Geomatys)
+ * @version 1.1
+ * @since   1.1
+ * @module
+ */
+public class MosaicCalculator {
+    /**
+     * Small number for floating point comparisons.
+     */
+    private static final double EPS = 1E-10;
+
+    /**
+     * The location of the final bounding box (the one including every tiles).
+     * Tiles will be translated as needed in order to fit this location. This
+     * is usually zero, but not necessarily.
+     */
+    private final int xLocation, yLocation;
+
+    /**
+     * Tiles for which we should compute the bounding box after we have them all.
+     * Their bounding box (region) will need to be adjusted for the affine transform.
+     */
+    private final Map<AffineTransform,Tile> tiles;
+
+    /**
+     * Creates an initially empty tile collection with the given location.
+     *
+     * @param  location  the location, or {@code null} for (0,0).
+     */
+    public MosaicCalculator(final Point location) {
+        if (location != null) {
+            xLocation = location.x;
+            yLocation = location.y;
+        } else {
+            xLocation = yLocation = 0;
+        }
+        /*
+         * We really need an IdentityHashMap, not an ordinary HashMap, because we will
+         * put many AffineTransforms that are equal in the sense of Object.equals, but
+         * we still want to associate them to different Tile instances.
+         */
+        tiles = new IdentityHashMap<>();
+    }
+
+    /**
+     * Returns the location of the tile collections to be created. The location is often
+     * (0,0) as expected in {@link java.awt.image.BufferedImage}, but does not have to.
+     *
+     * @return origin of the tile collections to be created.
+     */
+    public Point getLocation() {
+        return new Point(xLocation, yLocation);
+    }
+
+    /**
+     * Adds a tile to the collection of tiles to process.
+     * Each tile can be added only once.
+     *
+     * @param  tile  the tile to add.
+     * @return {@code true} if the tile has been successfully added, or
+     *         {@code false} if the tile does not need to be processed by this class.
+     */
+    public boolean add(final Tile tile) {
+        final AffineTransform gridToCRS = tile.getPendingGridToCRS();
+        if (gridToCRS == null) {
+            return false;
+        }
+        if (tiles.putIfAbsent(gridToCRS, tile) != null) {
+            throw new IllegalStateException();              // Tile already present.
+        }
+        return true;
+    }
+
+    /**
+     * Returns the tiles. Keys are pyramid geometry (containing mosaic bounds and <cite>grid to CRS</cite>
+     * transforms) and values are the tiles in that pyramid. This method usually returns a singleton map,
+     * but more entries may be present if this method was not able to build a single pyramid using all
+     * provided tiles.
+     *
+     * <p><strong>Invoking this method clear the collection</strong>. On return, this instance is empty.
+     * This is because current implementation modify its workspace directly for efficiency.</p>
+     *
+     * @return all tiles added to this {@code MosaicCalculator}, grouped by pyramids.
+     * @throws IOException if a call to {@link Tile#getSize()} or {@link Tile#getRegion()} failed,
+     *         and {@link #unavailableSize(Tile, IOException)} did not consumed the exception.
+     */
+    public Map<Tile,Tile[]> tiles() throws IOException {
+        final Map<Tile,Tile[]> results = new HashMap<>(4);
+        for (final Map<AffineTransform,Dimension> tilesAT : computePyramidLevels(tiles.keySet())) {
+            /*
+             * Picks an affine transform to be used as the reference one. We need the finest one.
+             * If more than one have the finest resolution, we pickup the one that will lead to a
+             * (0,0) translation at the end of this method. This is because while the final result
+             * is expected to have integer translation terms, the intermediates results before the
+             * final translation may have fractional terms. Since those intermediate results are
+             * stored as integers in Tile fields, it can leads to errors.
+             */
+            AffineTransform reference = null;
+            double xMin  = Double.POSITIVE_INFINITY;
+            double xLead = Double.POSITIVE_INFINITY;            // Minimum on the first row only.
+            double yMin  = Double.POSITIVE_INFINITY;
+            double scale = Double.POSITIVE_INFINITY;
+            for (final AffineTransform tr : tilesAT.keySet()) {
+                final double s = AffineTransforms2D.getScale(tr);
+                double y = tr.getTranslateY(); if (tr.getScaleY() < 0 || tr.getShearY() < 0) y = -y;
+                double x = tr.getTranslateX(); if (tr.getScaleX() < 0 || tr.getShearX() < 0) x = -x;
+                if (!(Math.abs(s - scale) <= EPS)) {
+                    if (!(s < scale)) continue;                 // '!' is for catching NaN.
+                    scale = s;                                  // Found a smaller scale.
+                    yMin = y;
+                    xMin = x;
+                } else {                                        // Found a transform with the same scale.
+                    if (x < xMin) xMin = x;
+                    if (!(Math.abs(y - yMin) <= EPS)) {
+                        if (!(y < yMin)) continue;
+                        yMin = y;                               // Found a smaller y.
+                    } else if (!(x < xLead)) continue;
+                }
+                xLead = x;
+                reference = tr;
+            }
+            /*
+             * If there is missing tiles at the beginning of the first row, then the x location
+             * of the first tile is greater than the "true" minimum. We will need to adjust.
+             */
+            if (reference == null) {
+                continue;
+            }
+            xLead -= xMin;
+            if (xLead > EPS) {
+                final double[] matrix = new double[6];
+                reference.getMatrix(matrix);
+                matrix[4] = xMin;
+                reference = new AffineTransform(matrix);
+            } else {
+                reference = new AffineTransform(reference);     // Protects from upcoming changes.
+            }
+            /*
+             * Transform the image bounding box from its own space to the reference space.
+             * If `computePyramidLevels(…)` did its job correctly, the transform should contain only a
+             * scale and a translation - no shear (we do not put assertions because of rounding errors).
+             * In such particular case, transforming a Rectangle2D is accurate. We round (we do not clip
+             * as in the default Rectangle implementation) because we really expect integer results.
+             */
+            final AffineTransform toGrid;
+            try {
+                toGrid = reference.createInverse();
+            } catch (NoninvertibleTransformException e) {
+                throw new IllegalStateException(e);
+            }
+            int index = 0;
+            Rectangle groupBounds = null;
+            final Rectangle2D.Double envelope = new Rectangle2D.Double();
+            final Tile[] tilesArray = new Tile[tilesAT.size()];
+            for (final Map.Entry<AffineTransform,Dimension> entry : tilesAT.entrySet()) {
+                final AffineTransform tr = entry.getKey();
+                Tile tile = tiles.remove(tr);                   // Should never be null.
+                tr.preConcatenate(toGrid);
+                /*
+                 * Compute the transformed bounds. If we fail to obtain it, there is probably something wrong
+                 * with the tile but this is not fatal to this method. In such case we will transform only the
+                 * location instead of the full box, which sometime implies a lost of accuracy but not always.
+                 */
+                Rectangle bounds;
+                synchronized (tile) {
+                    tile.setSubsampling(entry.getValue());
+                    try {
+                        bounds = tile.getRegion();
+                    } catch (IOException exception) {
+                        if (!unavailableSize(tile, exception)) {
+                            throw exception;
+                        }
+                        bounds = null;
+                    }
+                    if (bounds != null) {
+                        AffineTransforms2D.transform(tr, bounds, envelope);
+                        bounds.x      = Math.toIntExact(Math.round(envelope.x));
+                        bounds.y      = Math.toIntExact(Math.round(envelope.y));
+                        bounds.width  = Math.toIntExact(Math.round(envelope.width));
+                        bounds.height = Math.toIntExact(Math.round(envelope.height));
+                    } else {
+                        final Point location = tile.getLocation();
+                        tr.transform(location, location);
+                        bounds = new Rectangle(location.x, location.y, 0, 0);
+                    }
+                    tile.setAbsoluteRegion(bounds);
+                }
+                if (groupBounds == null) {
+                    groupBounds = bounds;
+                } else {
+                    groupBounds.add(bounds);
+                }
+                tilesArray[index++] = tile;
+            }
+            tilesAT.clear();                                            // Lets GC do its work.
+            /*
+             * Translate the tiles in such a way that the upper-left corner has the coordinates
+             * specified by (xLocation, yLocation). Adjust the tile affine transform consequently.
+             * After this block, tiles having the same subsampling will share the same immutable
+             * affine transform instance.
+             */
+            if (groupBounds != null) {
+                final int dx = xLocation - groupBounds.x;
+                final int dy = yLocation - groupBounds.y;
+                if (dx != 0 || dy != 0) {
+                    reference.translate(-dx, -dy);
+                    groupBounds.translate(dx, dy);
+                }
+                reference = new AffineTransform2D(reference);               // Make immutable.
+                final Map<Dimension,TranslatedTransform> pool = new HashMap<>();
+                for (final Tile tile : tilesArray) {
+                    final Dimension subsampling = tile.getSubsampling();
+                    TranslatedTransform translated = pool.get(subsampling);
+                    if (translated == null) {
+                        translated = new TranslatedTransform(subsampling, reference, dx, dy);
+                        pool.put(subsampling, translated);
+                    }
+                    translated.applyTo(tile);
+                }
+                results.put(new Tile(reference, groupBounds), tilesArray);
+            }
+        }
+        return results;
+    }
+
+    /**
+     * Sorts affine transform by increasing X scales in absolute value.
+     * For {@link #computePyramidLevels(Collection)} internal working only.
+     */
+    private static final Comparator<AffineTransform> X_COMPARATOR = new Comparator<AffineTransform>() {
+        @Override public int compare(final AffineTransform tr1, final AffineTransform tr2) {
+            return Double.compare(AffineTransforms2D.getScaleX0(tr1), AffineTransforms2D.getScaleX0(tr2));
+        }
+    };
+
+    /**
+     * Sorts affine transform by increasing Y scales in absolute value.
+     * For {@link #computePyramidLevels(Collection)} internal working only.
+     */
+    private static final Comparator<AffineTransform> Y_COMPARATOR = new Comparator<AffineTransform>() {
+        @Override public int compare(final AffineTransform tr1, final AffineTransform tr2) {
+            return Double.compare(AffineTransforms2D.getScaleY0(tr1), AffineTransforms2D.getScaleY0(tr2));
+        }
+    };
+
+    /**
+     * From a set of arbitrary affine transforms, computes pyramid levels that can be given to
+     * {@link Tile} constructors. This method tries to locate the affine transform with finest resolution.
+     * This is typically (but not always, depending on rotation or axis flip) the transform with smallest
+     * {@linkplain AffineTransform#getScaleX scale X} and {@linkplain AffineTransform#getScaleY scale Y}
+     * coefficients in absolute value. That transform is given a "resolution" of (1,1) and stored in an
+     * {@link IdentityHashMap}. Other transforms are stored in the same map with their resolution relative
+     * to the first one, or discarded if the relative resolution is not an integer. In the later case, the
+     * transforms that were discarded from the first pass will be put in a new map to be added as the second
+     * element in the returned list. A new pass is run, discarded transforms from the second pass are put in
+     * the third element of the list, <i>etc</i>.
+     *
+     * @param  gridToCRS  the <cite>grid to CRS</cite> affine transforms computed from the image to use in a pyramid.
+     *         The collection and the transform elements are not modified by this method (they may be modified by the
+     *         caller however).
+     * @return a subset of the given transforms with their relative resolution. This method typically returns one map,
+     *         but more could be returned if the scale ratio is not an integer for every transforms.
+     */
+    private static List<Map<AffineTransform,Dimension>> computePyramidLevels(final Collection<AffineTransform> gridToCRS) {
+        final List<Map<AffineTransform,Dimension>> results = new ArrayList<>(2);
+        /*
+         * First, compute the pyramid levels along the X axis. Transforms that we were unable
+         * to classify will be discarded from the first run and put in a subsequent run.
+         */
+        AffineTransform[] transforms = gridToCRS.toArray(new AffineTransform[gridToCRS.size()]);
+        Arrays.sort(transforms, X_COMPARATOR);
+        int length = transforms.length;
+        while (length != 0) {
+            final Map<AffineTransform,Dimension> result = new IdentityHashMap<>();
+            if (length <= (length = computePyramidLevels(transforms, length, result, false))) {
+                throw new AssertionError(length);               // Should always be decreasing.
+            }
+            results.add(result);
+        }
+        /*
+         * Next, compute the pyramid levels along the Y axis. If we fail to compute the
+         * pyramid level for some AffineTransform, they will be removed from the map.
+         * If a map became empty because of that, the whole map will be removed.
+         */
+        final Iterator<Map<AffineTransform,Dimension>> iterator = results.iterator();
+        while (iterator.hasNext()) {
+            final Map<AffineTransform,Dimension> result = iterator.next();
+            length = result.size();
+            transforms = result.keySet().toArray(transforms);
+            Arrays.sort(transforms, 0, length, Y_COMPARATOR);
+            length = computePyramidLevels(transforms, length, result, true);
+            while (--length >= 0) {
+                if (result.remove(transforms[length]) == null) {
+                    throw new AssertionError(length);
+                }
+            }
+            if (result.isEmpty()) {
+                iterator.remove();
+            }
+        }
+        return results;
+    }
+
+    /**
+     * Computes the pyramid level for the given affine transforms along the X or Y axis,
+     * and stores the result in the given map.
+     *
+     * @param  gridToCRS  the AffineTransform to analyze. This array <strong>must</strong>
+     *                    be sorted along the dimension specified by {@code isY}.
+     * @param  length     the number of valid entries in the {@code gridToCRS} array.
+     * @param  result     an initially empty map in which to store the results.
+     * @param  isY        {@code false} for analyzing the X axis, or {@code true} for the Y axis.
+     * @return the number of entries remaining in {@code gridToCRS}.
+     */
+    private static int computePyramidLevels(final AffineTransform[] gridToCRS, final int length,
+            final Map<AffineTransform,Dimension> result, final boolean isY)
+    {
+        int processing = 0;             // Index of the AffineTransform under process.
+        int remaining  = 0;             // Count of AffineTransforms that this method did not processed.
+        AffineTransform base;
+        double scale, shear;
+        boolean scaleIsNull, shearIsNull;
+        for (;;) {
+            if (processing >= length) {
+                return remaining;
+            }
+            base = gridToCRS[processing++];
+            if (isY) {
+                scale = base.getScaleY();
+                shear = base.getShearY();
+            } else {
+                scale = base.getScaleX();
+                shear = base.getShearX();
+            }
+            scaleIsNull = Math.abs(scale) < EPS;
+            shearIsNull = Math.abs(shear) < EPS;
+            if (!(scaleIsNull & shearIsNull)) break;
+            result.remove(base);
+        }
+        if (isY) {
+            // If we get a NullPointerException here, it would be a bug in the algorithm.
+            result.get(base).height = 1;
+        } else {
+            assert result.isEmpty() : result;
+            result.put(base, new Dimension(1,0));
+        }
+        /*
+         * From this point, consider 'base', 'scale', 'shear', 'scaleIsNull', 'shearIsNull' as final.
+         * They describe the AffineTransform with finest resolution along one axis (X or Y).
+         */
+        while (processing < length) {
+            final AffineTransform candidate = gridToCRS[processing++];
+            final double scale2, shear2;
+            if (isY) {
+                scale2 = candidate.getScaleY();
+                shear2 = candidate.getShearY();
+            } else {
+                scale2 = candidate.getScaleX();
+                shear2 = candidate.getShearX();
+            }
+            final int level;
+            if (scaleIsNull) {
+                if (!(Math.abs(scale2) < EPS)) {
+                    // Expected a null scale but was not.
+                    gridToCRS[remaining++] = candidate;
+                    continue;
+                }
+                level = level(shear2 / shear);
+            } else {
+                level = level(scale2 / scale);
+                if (shearIsNull ? !(Math.abs(shear2) < EPS) : (level(shear2 / shear) != level)) {
+                    // Expected (a null shear) : (the same pyramid level), but was not.
+                    gridToCRS[remaining++] = candidate;
+                    continue;
+                }
+            }
+            if (level == 0) {
+                // Not a pyramid level (the ratio is not an integer).
+                gridToCRS[remaining++] = candidate;
+                continue;
+            }
+            /*
+             * Stores the pyramid level either as the width or as the height, depending on the `isY` value.
+             * The map is assumed initially empty for the X values, and is assumed containing every required
+             * entries for the Y values.
+             */
+            if (isY) {
+                // If we get a NullPointerException here, it would be a bug in the algorithm.
+                result.get(candidate).height = level;
+            } else {
+                if (result.put(candidate, new Dimension(level,0)) != null) {
+                    throw new AssertionError(candidate);                                // Should never happen.
+                }
+            }
+        }
+        Arrays.fill(gridToCRS, remaining, length, null);
+        return remaining;
+    }
+
+    /**
+     * Computes the pyramid level from the ratio between two affine transform coefficients.
+     * If the ratio has been computed from {@code entry2.scaleX / entry1.scaleX}, then a return value of:
+     *
+     * <ul>
+     *   <li>1 means that both entries are at the same level.</li>
+     *   <li>2 means that the second entry has pixels twice as large as first entry.</li>
+     *   <li>3 means that the second entry has pixels three time larger than first entry.</li>
+     *   <li><i>etc...</i></li>
+     *   <li>A negative number means that the second entry has pixels smaller than first entry.</li>
+     *   <li>0 means that the ratio between entries is not an integer number.</li>
+     * </ul>
+     *
+     * @param  ratio  the ratio between affine transform coefficients.
+     * @return the pixel size (actually subsampling) relative to the smallest pixel, or 0 if it can not be computed.
+     *         If the ratio is between 0 and 1, then this method returns a negative number.
+     */
+    private static int level(double ratio) {
+        if (ratio > 0 && ratio < Double.POSITIVE_INFINITY) {
+            /*
+             * The 0.75 threshold could be anything between 0.5 and 1.
+             * We take a middle value for being safe regarding rounding errors.
+             */
+            final boolean inverse = (ratio < 0.75);
+            if (inverse) {
+                ratio = 1 / ratio;
+            }
+            final double integer = Math.rint(ratio);
+            if (integer < Integer.MAX_VALUE && Math.abs(ratio - integer) < EPS) {
+                /*
+                 * Found an integer ratio.
+                 * Inverse the sign (just as a matter of convention) if smaller than 1.
+                 */
+                int level = (int) integer;
+                if (inverse) {
+                    level = -level;
+                }
+                return level;
+            }
+        }
+        return 0;
+    }
+
+    /**
+     * Invoked when an I/O error occurred in {@link Tile#getSize()} or {@link Tile#getRegion()}.
+     * This error is non-fatal since {@code MosaicCalculator} can fallback on calculation based
+     * on tile location only (without size).
+     *
+     * <p>The default implementation returns {@code false}, which instructs the caller to let
+     * the exception propagate.</p>
+     *
+     * @param  tile       the tile on which an error occurred.
+     * @param  exception  the error that occurred.
+     * @return {@code true} if the exception has been consumed, or {@code false} for re-throwing it.
+     */
+    protected boolean unavailableSize(final Tile tile, final IOException exception) {
+        return false;
+    }
+
+    /**
+     * Returns a string representation of the tiles contained in this object. Since this method is
+     * for debugging purpose, only the first tiles may be formatted in order to avoid consuming to
+     * much space in the debugger.
+     */
+    @Override
+    public String toString() {
+        final List<Tile> tiles = new ArrayList<>(this.tiles.values());
+        Collections.sort(tiles);
+        return Tile.toString(tiles, 400);
+    }
+}
diff --git a/core/sis-referencing/src/main/java/org/apache/sis/internal/referencing/j2d/Tile.java b/core/sis-referencing/src/main/java/org/apache/sis/internal/referencing/j2d/Tile.java
new file mode 100644
index 0000000..ec755eb
--- /dev/null
+++ b/core/sis-referencing/src/main/java/org/apache/sis/internal/referencing/j2d/Tile.java
@@ -0,0 +1,755 @@
+/*
+ * 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.internal.referencing.j2d;
+
+import java.util.Locale;
+import java.util.Collection;
+import java.util.Optional;
+import java.io.Writer;
+import java.io.StringWriter;
+import java.io.Serializable;
+import java.io.IOException;
+import java.awt.Point;
+import java.awt.Dimension;
+import java.awt.Rectangle;
+import java.awt.geom.AffineTransform;
+import javax.imageio.ImageReader;                           // For javadoc
+import org.opengis.metadata.spatial.PixelOrientation;
+import org.apache.sis.util.resources.Vocabulary;
+import org.apache.sis.util.resources.Errors;
+import org.apache.sis.util.ArgumentChecks;
+import org.apache.sis.util.Classes;
+import org.apache.sis.io.TableAppender;
+
+
+/**
+ * A tile identified by a location, a dimension and a subsampling.
+ * This class can be used for constructing a mosaic or a pyramid of images.
+ * While the Javadoc discusses image I/O operations, this {@code Tile} class is not restricted to imagery.
+ * This class is also used for managing tiles in a datum shift file encoded in NTv2 format.
+ *
+ * <p>Each tile contains the following:</p>
+ * <ul class="verbose">
+ *   <li><b>A format name or a provider of {@link ImageReader}</b> (optional).
+ *   The same format is typically used for every tiles, but this is not mandatory.
+ *   An {@linkplain ImageReader image reader} can be instantiated before a tile is read.</li>
+ *
+ *   <li><b>An image input</b> (optional), typically a {@link java.nio.file.Path} or {@link java.net.URL}.
+ *   The input is often different for every tile to be read, but this is not mandatory. For example tiles
+ *   could be stored at different {@linkplain #getImageIndex() image index} in the same file.</li>
+ *
+ *   <li><b>An image index</b> to be given to {@link ImageReader#read(int)} for reading the tile.
+ *   This index is often 0.</li>
+ *
+ *   <li><b>The upper-left corner</b> in the destination image as a {@link Point},
+ *   or the upper-left corner together with the image size as a {@link Rectangle}.
+ *   If the upper-left corner has been given as a point, then the
+ *   {@linkplain ImageReader#getWidth(int) width} and {@linkplain ImageReader#getHeight(int) height}
+ *   may be obtained from the image reader when first needed, which may have a slight performance cost.
+ *   If the upper-left corner has been given as a rectangle instead, then this performance cost is avoided
+ *   but the user is responsible for the accuracy of the information provided.
+ *
+ *     <div class="note"><b>NOTE:</b>
+ *     the upper-left corner is the {@linkplain #getLocation() location} of this tile in the
+ *     {@linkplain javax.imageio.ImageReadParam#setDestination destination image} when no
+ *     {@linkplain javax.imageio.ImageReadParam#setDestinationOffset destination offset} are specified.
+ *     If the user specified a destination offset, then the tile location will be translated accordingly
+ *     for the image being read.
+ *     </div></li>
+ *
+ *   <li><b>The subsampling relative to the tile having the best resolution.</b>
+ *   This is not the subsampling to apply when reading this tile, but rather the subsampling that we would
+ *   need to apply on the tile having the finest resolution in order to produce an image equivalent to this tile.
+ *   The subsampling is (1,1) for the tile having the finest resolution, (2,3) for an overview having
+ *   half the width and third of the height for the same geographic extent, <i>etc.</i>
+ *   (note that overviews are not required to have the same geographic extent - the above is just an example).
+ *
+ *     <div class="note"><b>NOTE 1:</b>
+ *     the semantic assumes that overviews are produced by subsampling, not by interpolation or pixel averaging.
+ *     The later are not prohibited, but doing so introduce some subsampling-dependent variations in images read,
+ *     which would not be what we would expect from a strictly compliant {@link ImageReader}.</div>
+ *
+ *     <div class="note"><b>NOTE 2:</b>
+ *     tile {@linkplain #getLocation() location} and {@linkplain #getRegion() region} coordinates should be
+ *     specified in the overview pixel units - they should <em>not</em> be pre-multiplied by subsampling.
+ *     This multiplication should be performed automatically by a {@code TileManager} when comparing regions
+ *     from tiles at different subsampling levels.
+ *     </div></li>
+ * </ul>
+ *
+ * The tiles are not required to be arranged on a regular grid, but performances may be better if they are.
+ * {@link MosaicCalculator} is responsible for analyzing the layout of a collection of tiles.
+ *
+ * <h2>Multi-threading</h2>
+ * This class is thread-safe. In addition {@code Tile} instances can be considered as immutable after construction.
+ * However some properties may be available only after the tiles have been processed by a {@link MosaicCalculator},
+ * or only after {@link #fetchSize()} has been invoked.
+ *
+ * @author  Martin Desruisseaux (Geomatys)
+ * @version 1.1
+ * @since   1.1
+ * @module
+ */
+public class Tile implements Comparable<Tile>, Serializable {
+    /**
+     * For cross-version compatibility during serialization.
+     */
+    private static final long serialVersionUID = 1638238437701248681L;
+
+    /**
+     * The upper-left corner in the mosaic (destination image). Should be considered as final,
+     * since this class is supposed to be mostly immutable. However the value can be changed
+     * by {@link #translate(int, int)} before the {@code Tile} instance is made public.
+     *
+     * @see #getLocation()
+     * @see #getRegion()
+     */
+    private int x, y;
+
+    /**
+     * The size of the tile, or 0 if not yet computed.
+     *
+     * @see #getSize()
+     * @see #getRegion()
+     */
+    private int width, height;
+
+    /**
+     * The subsampling relative to the tile having the finest resolution. If this tile is the one with
+     * finest resolution, then the value shall be 1. Should never be 0 or negative, except if the value
+     * has not yet been computed.
+     *
+     * <p>This field should be considered as final. It is not final only because
+     * {@link MosaicCalculator} may compute this value automatically.</p>
+     *
+     * @see #getSubsampling()
+     */
+    private int xSubsampling, ySubsampling;
+
+    /**
+     * The "grid to real world" transform, used by {@link MosaicCalculator} in order to compute
+     * the {@linkplain #getRegion() region} for this tile. This field is set to {@code null} when
+     * {@link MosaicCalculator}'s work is in progress, and set to a new value on completion.
+     *
+     * <p><b>Note:</b> {@link MosaicCalculator} really needs a new instance for each tile.
+     * No caching allowed before {@link MosaicCalculator} processing.
+     * Caching is allowed <em>after</em> {@link MosaicCalculator} processing is completed.</p>
+     */
+    private AffineTransform gridToCRS;
+
+    /**
+     * Creates a tile for the given tile location. This constructor can be used when the size of
+     * the tile is unknown. This tile size will be fetched automatically by {@link #fetchSize()}
+     * when {@link #getSize()} or {@link #getRegion()} is invoked for the first time.
+     *
+     * @param location     the upper-left corner in the mosaic (destination image).
+     * @param subsampling  the subsampling relative to the tile having the finest resolution,
+     *                     or {@code null} if none. If non-null, width and height shall be strictly positive.
+     *                     This argument can be understood as pixel size relative to finest resolution.
+     */
+    public Tile(final Point location, final Dimension subsampling) {
+        ArgumentChecks.ensureNonNull("location", location);
+        x = location.x;
+        y = location.y;
+        setSubsampling(subsampling);
+    }
+
+    /**
+     * Creates a tile for the given region. This constructor should be used when the size of the tile is known.
+     * This information avoid the cost of fetching the size when {@link #getSize()} or {@link #getRegion()} is
+     * first invoked.
+     *
+     * @param region       the region (location and size) in the mosaic (destination image).
+     * @param subsampling  the subsampling relative to the tile having the finest resolution,
+     *                     or {@code null} if none. If non-null, width and height shall be strictly positive.
+     *                     This argument can be understood as pixel size relative to finest resolution.
+     * @throws IllegalArgumentException if the given region {@linkplain Rectangle#isEmpty() is empty}.
+     */
+    public Tile(final Rectangle region, final Dimension subsampling) {
+        ArgumentChecks.ensureNonNull("location", region);
+        x      = region.x;
+        y      = region.y;
+        width  = region.width;
+        height = region.height;
+        if (width <= 0 || height <= 0) {
+            throw new IllegalArgumentException(Errors.format(Errors.Keys.EmptyArgument_1, "region"));
+        }
+        setSubsampling(subsampling);
+    }
+
+    /**
+     * Creates a tile for the given region and <cite>"grid to real world"</cite> transform.
+     * This constructor can be used when the {@linkplain #getLocation() location} of the tile is unknown.
+     * The location and subsampling will be computed automatically when this tile will be processed by a
+     * {@link MosaicCalculator}.
+     *
+     * <p>When using this constructor, the {@link #getLocation()}, {@link #getRegion()} and
+     * {@link #getSubsampling()} methods will throw an {@link IllegalStateException} until
+     * this tile has been processed by a {@link MosaicCalculator}, which will compute those
+     * values automatically.</p>
+     *
+     * @param region     the tile region, or {@code null} if unknown.
+     *                   The (<var>x</var>,<var>y</var> location of this region is typically (0,0).
+     *                   The final location will be computed when this tile will be given to a {@link MosaicCalculator}.
+     * @param gridToCRS  the <cite>"grid to real world"</cite> transform mapping pixel
+     *                   {@linkplain PixelOrientation#UPPER_LEFT upper left} corner.
+     */
+    public Tile(final Rectangle region, final AffineTransform gridToCRS) {
+        ArgumentChecks.ensureNonNull("gridToCRS", gridToCRS);
+        if (region != null) {
+            x      = region.x;
+            y      = region.y;
+            width  = Math.max(region.width,  0);                        // Empty region authorized.
+            height = Math.max(region.height, 0);
+        }
+        this.gridToCRS = new AffineTransform(gridToCRS);                // Really need a new instance - no cache
+    }
+
+    /**
+     * Creates a new tile for the given final transform.
+     * This is used for storing {@link MosaicCalculator} results.
+     */
+    Tile(final AffineTransform gridToCRS, final Rectangle region) {
+        this.x         = region.x;
+        this.y         = region.y;
+        this.width     = region.width;
+        this.height    = region.height;
+        this.gridToCRS = gridToCRS;                                     // Should be an AffineTransform2D instance.
+        setSubsampling(null);
+    }
+
+    /**
+     * Checks if the location, region, and subsampling can be returned. Throws an exception if this
+     * tile has been created without location and not yet processed by {@link MosaicCalculator}.
+     */
+    private void ensureDefined() throws IllegalStateException {
+        if (xSubsampling == 0 || ySubsampling == 0) {
+            throw new IllegalStateException();
+        }
+    }
+
+    /**
+     * Returns the tile upper-left corner coordinates in the mosaic.
+     *
+     * @return the tile upper-left corner.
+     * @throws IllegalStateException if this tile has been {@linkplain #Tile(Rectangle, AffineTransform)
+     *         created without location} and has not yet been processed by {@link MosaicCalculator}.
+     *
+     * @see javax.imageio.ImageReadParam#setDestinationOffset(Point)
+     */
+    public synchronized Point getLocation() throws IllegalStateException {
+        ensureDefined();
+        return new Point(x, y);
+    }
+
+    /**
+     * Returns the image size. If this tile has been created with the {@linkplain #Tile(Rectangle, Dimension)
+     * constructor expecting a rectangle}, then the dimension of that rectangle is returned.
+     * Otherwise {@link #fetchSize()} is invoked and the result is cached for future usage.
+     *
+     * <p>At the difference of {@link #getLocation()} and {@link #getRegion()}, this method never
+     * throw {@link IllegalStateException} because the tile size does not depend on the processing
+     * performed by {@link MosaicCalculator}.</p>
+     *
+     * @return the tile size.
+     * @throws IOException if an I/O operation was required for fetching the tile size and that operation failed.
+     * @throws IllegalStateException if this class does not have sufficient information for providing a tile size.
+     */
+    public synchronized Dimension getSize() throws IOException {
+        // No call to ensureDefined().
+        if ((width | height) == 0) {
+            final Dimension size = fetchSize();
+            width  = size.width;
+            height = size.height;
+        }
+        return new Dimension(width, height);
+    }
+
+    /**
+     * Invoked when the tile size need to be read or computed. The default implementation throws
+     * {@link IllegalStateException} since this base class has no information for computing a tile size.
+     * Subclasses can override and, for example, get the size with {@link ImageReader#getWidth(int)} and
+     * {@link ImageReader#getHeight(int)}.
+     *
+     * @return the tile size.
+     * @throws IOException if an I/O operation was required for fetching the tile size and that operation failed.
+     * @throws IllegalStateException if this class does not have sufficient information for providing a tile size.
+     */
+    protected Dimension fetchSize() throws IOException {
+        throw new IllegalStateException();
+    }
+
+    /**
+     * Returns the upper-left corner location in the mosaic together with the tile size.
+     * If this tile has been created with the {@linkplain #Tile(Rectangle, Dimension)
+     * constructor expecting a rectangle}, a copy of the specified rectangle is returned.
+     * Otherwise {@link #fetchSize()} is invoked and the result is cached for future usage.
+     *
+     * @return the region in the mosaic (destination image).
+     * @throws IOException if an I/O operation was required for fetching the tile size and that operation failed.
+     * @throws IllegalStateException if this tile has been {@linkplain #Tile(Rectangle, AffineTransform) created
+     *         without location} and has not yet been processed by {@link MosaicCalculator}, of if this tile does
+     *         not have enough information for providing a tile size.
+     *
+     * @see javax.imageio.ImageReadParam#setSourceRegion(Rectangle)
+     */
+    public synchronized Rectangle getRegion() throws IllegalStateException, IOException {
+        ensureDefined();
+        if ((width | height) == 0) {
+            final Dimension size = fetchSize();
+            width  = size.width;
+            height = size.height;
+        }
+        return new Rectangle(x, y, width, height);
+    }
+
+    /**
+     * Returns the {@linkplain #getRegion() region} multiplied by the subsampling.
+     * This is this tile coordinates in the units of the tile having the finest resolution,
+     * as opposed to other methods which are always in units relative to this tile.
+     *
+     * @return the region in units relative to the tile having the finest resolution.
+     * @throws IOException if an I/O operation was required for fetching the tile size and that operation failed.
+     * @throws IllegalStateException if this tile has been {@linkplain #Tile(Rectangle, AffineTransform) created
+     *         without location} and has not yet been processed by {@link MosaicCalculator}, of if this tile does
+     *         not have enough information for providing a tile size.
+     */
+    public synchronized Rectangle getAbsoluteRegion() throws IOException {
+        final Rectangle region = getRegion();
+        final int sx = xSubsampling;
+        final int sy = ySubsampling;
+        region.x      *= sx;
+        region.y      *= sy;
+        region.width  *= sx;
+        region.height *= sy;
+        return region;
+    }
+
+    /**
+     * Invoked by {@link MosaicCalculator} only. No other caller allowed.
+     * {@link #setSubsampling(Dimension)} must be invoked prior this method.
+     *
+     * <p>Note that invoking this method usually invalidate {@link #gridToCRS}.
+     * Calls to this method should be followed by {@link #translate(int, int)}
+     * for fixing the "gridToCRS" value.</p>
+     *
+     * @param  region  the region to assign to this tile in units of tile having finest resolution.
+     * @throws ArithmeticException if {@link #setSubsampling(Dimension)} method has not be invoked.
+     */
+    final void setAbsoluteRegion(final Rectangle region) throws ArithmeticException {
+        assert Thread.holdsLock(this);
+        final int sx = xSubsampling;
+        final int sy = ySubsampling;
+        assert (region.width % sx) == 0 && (region.height % sy) == 0 : region;
+        x      = region.x      / sx;
+        y      = region.y      / sy;
+        width  = region.width  / sx;
+        height = region.height / sy;
+    }
+
+    /**
+     * Returns the subsampling relative to the tile having the finest resolution.
+     * The return value can be interpreted as "pixel size" relative to tiles having the finest resolution.
+     * This method never return {@code null}, and the width and height shall never be smaller than 1.
+     *
+     * @return the subsampling along <var>x</var> and <var>y</var> axes.
+     * @throws IllegalStateException if this tile has been {@linkplain #Tile(Rectangle, AffineTransform)
+     *         created without location} and has not yet been processed by {@link MosaicCalculator}.
+     *
+     * @see javax.imageio.ImageReadParam#setSourceSubsampling(int, int, int, int)
+     */
+    public synchronized Dimension getSubsampling() throws IllegalStateException {
+        ensureDefined();
+        return new Dimension(xSubsampling, ySubsampling);
+    }
+
+    /**
+     * Sets the subsampling to the given dimension.
+     * Invoked by constructors and {@link MosaicCalculator} only.
+     */
+    final void setSubsampling(final Dimension subsampling) throws IllegalStateException {
+        // No assert Thread.holdsLock(this) because invoked from constructors.
+        if ((xSubsampling | ySubsampling) != 0) {
+            throw new IllegalStateException();                          // Should never happen.
+        }
+        if (subsampling != null) {
+            ArgumentChecks.ensureBetween("width",  0, 0xFFFF, subsampling.width);
+            ArgumentChecks.ensureBetween("height", 0, 0xFFFF, subsampling.height);
+            xSubsampling = subsampling.width;
+            ySubsampling = subsampling.height;
+        } else {
+            xSubsampling = ySubsampling = 1;
+        }
+    }
+
+    /**
+     * If the user-supplied transform is waiting for processing by {@link MosaicCalculator}, returns it.
+     * Otherwise returns {@code null}. This method is for internal usage by {@link MosaicCalculator} only.
+     *
+     * <p>This method clears the {@link #gridToCRS} field before to return. This is a way to tell that
+     * processing is in progress, and also a safety against transform usage while it may become invalid.</p>
+     *
+     * @return the transform, or {@code null} if none. This method does not clone the returned value -
+     *         {@link MosaicCalculator} will reference and modify directly that transform.
+     */
+    final synchronized AffineTransform getPendingGridToCRS() {
+        if ((xSubsampling | ySubsampling) != 0) {
+            // No transform waiting to be processed.
+            return null;
+        }
+        final AffineTransform at = gridToCRS;
+        gridToCRS = null;
+        return at;
+    }
+
+    /**
+     * Returns the <cite>"grid to real world"</cite> transform, or {@code null} if unknown.
+     * This transform is derived from the value given to the constructor, but may not be identical
+     * since it may have been {@linkplain AffineTransform#translate(double, double) translated}
+     * in order to get a uniform grid geometry for every tiles.
+     *
+     * <div class="note"><b>Tip:</b>
+     * the <a href="https://en.wikipedia.org/wiki/World_file">World File</a> coefficients of this tile
+     * (i.e. the <cite>grid to CRS</cite> transform that we would have if the pixel in the upper-left
+     * corner always had indices (0,0)) can be computed as below:
+     *
+     * {@preformat java
+     *     Point location = tile.getLocation();
+     *     AffineTransform gridToCRS = new AffineTransform(tile.getGridToCRS());
+     *     gridToCRS.translate(location.x, location.y);
+     * }
+     * </div>
+     *
+     * @return the <cite>"grid to real world"</cite> transform mapping pixel
+     *         {@linkplain PixelOrientation#UPPER_LEFT upper left} corner, or {@code null} if undefined.
+     * @throws IllegalStateException if this tile has been {@linkplain #Tile(Rectangle, AffineTransform)
+     *         created without location} and has not yet been processed by {@link MosaicCalculator}.
+     */
+    public synchronized AffineTransform2D getGridToCRS() throws IllegalStateException {
+        ensureDefined();
+        /*
+         * The cast should not fail: if the `gridToCRS` is the one specified at construction time,
+         * then `ensureDefined()` should have thrown an IllegalStateException. Otherwise this tile
+         * have been processed by `MosaicCalculator`, which has set an `AffineTransform2D` instance.
+         * If we get a ClassCastException below, then there is a bug in our pre/post conditions.
+         */
+        return (AffineTransform2D) gridToCRS;
+    }
+
+    /**
+     * Sets the new <cite>"grid to real world"</cite> transform to use after the translation performed by
+     * {@link #translate(int, int)}, if any. The given instance should be immutable; it will not be cloned.
+     *
+     * @param  at  the <cite>"grid to real world"</cite> transform mapping pixel
+     *             {@linkplain PixelOrientation#UPPER_LEFT upper left} corner.
+     * @throws IllegalStateException if another transform was already assigned to this tile.
+     */
+    final void setGridToCRS(final AffineTransform at) throws IllegalStateException {
+        assert Thread.holdsLock(this);
+        if (gridToCRS == null) {
+            gridToCRS = at;
+        } else if (!gridToCRS.equals(at)) {
+            throw new IllegalStateException();
+        }
+    }
+
+    /**
+     * Translates this tile. For internal usage by {@link MosaicCalculator} only.
+     *
+     * <p>Reminder: {@link #setGridToCRS(AffineTransform)} should be invoked after this method.</p>
+     *
+     * @param  dx  the translation to apply on <var>x</var> values (often 0).
+     * @param  dy  the translation to apply on <var>y</var> values (often 0).
+     */
+    final void translate(final int dx, final int dy) {
+        assert Thread.holdsLock(this);
+        x += dx;
+        y += dy;
+        gridToCRS = null;
+    }
+
+    /**
+     * Returns a name for the tile format or tile input, or an empty value if none.
+     * The format name can be inferred for example from an {@link javax.imageio.spi.ImageReaderSpi}.
+     * The input name is typically (but not necessarily) a file name or URL.
+     *
+     * @param  input  {@code false} for the file format name, or {@code true} for the file input name.
+     * @return the format or input name.
+     */
+    public Optional<String> getName(final boolean input) {
+        return Optional.empty();
+    }
+
+    /**
+     * Returns the image index to be given to the image reader for reading this tile.
+     * The default implementation returns 0.
+     *
+     * @return the image index, numbered from 0.
+     *
+     * @see ImageReader#read(int)
+     */
+    public int getImageIndex() {
+        return 0;
+    }
+
+    /**
+     * Compares two tiles for optimal order in sequential reads. Default implementation sorts by
+     * increasing {@linkplain #getImageIndex image index}. This ordering allows efficient access
+     * for tiles that are stored sequentially in a file.
+     *
+     * <p>For tiles having the same image index, additional criterion are used like increasing
+     * subsampling, increasing <var>y</var> then increasing <var>x</var> coordinates.
+     * But the actual set of additional criterion may change in any future version.
+     *
+     * @param  other  the tile to compare with.
+     * @return -1 if this tile should be read before {@code other},
+     *         +1 if it should be read after or 0 if equal.
+     */
+    @Override
+    public final int compareTo(final Tile other) {
+        int c = getImageIndex() - other.getImageIndex();
+        if (c == 0) {
+            /*
+             * From this point it does not matter much for disk access. But we continue to
+             * define criterions for consistency with `equals(Object)` method. We compare
+             * subsampling first because it may be undefined while it is needed for (x,y)
+             * ordering. Undefined subsampling will be ordered first (this is arbitrary).
+             */
+            final int sy =  this.ySubsampling;
+            final int oy = other.ySubsampling;
+            c = sy - oy;
+            if (c == 0) {
+                final int sx =  this.xSubsampling;
+                final int ox = other.xSubsampling;
+                c = sx - ox;
+                if (c == 0) {
+                    c = (y * sy) - (other.y * oy);
+                    if (c == 0) {
+                        c = (x * sx) - (other.x * ox);
+                    }
+                }
+            }
+        }
+        return c;
+    }
+
+    /**
+     * Compares this tile with the specified one for equality. Two tiles are considered equal
+     * if they are of the same class and have the same {@linkplain #getRegion() region} and
+     * same {@linkplain #getSubsampling() subsampling}. Subclasses should override if there
+     * is more properties to compare such as image format and index.
+     *
+     * @param  object  the object to compare with.
+     * @return {@code true} if both objects are equal.
+     */
+    @Override
+    public boolean equals(final Object object) {
+        if (object == this) {
+            return true;
+        }
+        if (object != null && object.getClass() == getClass()) {
+            final Tile that = (Tile) object;
+            if (this.x == that.x  &&  this.y == that.y &&
+                this.xSubsampling == that.xSubsampling &&
+                this.ySubsampling == that.ySubsampling)
+            {
+                /*
+                 * Compares width and height only if they are defined in both tiles.  We do not
+                 * invoke `getRegion()` because it may be expensive and useless anyway: If both
+                 * tiles have the same image reader, image index and input, then logically they
+                 * must have the same size - invoking `getRegion()` would read exactly the same
+                 * image twice.
+                 */
+                return (width  == 0 || that.width  == 0 || width  == that.width) &&
+                       (height == 0 || that.height == 0 || height == that.height);
+            }
+        }
+        return false;
+    }
+
+    /**
+     * Returns a hash code value for this tile.
+     */
+    @Override
+    public int hashCode() {
+        return x + 37*y;
+    }
+
+    /**
+     * Returns a string representation of this tile for debugging purposes.
+     *
+     * @return a string representation of this tile.
+     */
+    @Override
+    public String toString() {
+        final StringBuilder buffer = new StringBuilder(Classes.getShortClassName(this)).append('[');
+        if ((xSubsampling | ySubsampling) != 0) {
+            buffer.append("location=(");
+            if (width == 0 && height == 0) {
+                final Point location = getLocation();
+                buffer.append(location.x).append(',').append(location.y);
+            } else try {
+                final Rectangle region = getRegion();
+                buffer.append(region.x).append(',').append(region.y)
+                      .append("), size=(").append(region.width).append(',').append(region.height);
+            } catch (IOException e) {
+                /*
+                 * Should not happen since we checked that `getRegion()` should be easy.
+                 * If it happen anyway, put the exception message at the place where
+                 * coordinates were supposed to appear, so we can debug.
+                 */
+                buffer.append(e);
+            }
+            final Dimension subsampling = getSubsampling();
+            buffer.append("), subsampling=(").append(subsampling.width)
+                  .append(',').append(subsampling.height).append(')');
+        } else {
+            /*
+             * Location and subsampling not yet computed, so don't display it. We can not
+             * invoke `getRegion()` neither since it would throw an IllegalStateException.
+             * Since we have to read the fields directly, make sure that this instance is
+             * not a subclass, otherwise those values may be wrong.
+             */
+            if ((width != 0 || height != 0) && getClass() == Tile.class) {
+                buffer.append("size=(").append(width).append(',').append(height).append(')');
+            }
+        }
+        return buffer.append(']').toString();
+    }
+
+    /**
+     * Returns a string representation of a collection of tiles. The tiles are formatted in a
+     * table in iteration order. Tip: consider sorting the tiles before to invoke this method;
+     * tiles are {@link Comparable} for this purpose.
+     *
+     * <p>This method is not public because it can consume a large amount of memory (the underlying
+     * {@link StringBuffer} can be quite large). Users are encouraged to use the method expecting a
+     * {@link Writer}, which may be expensive too but less than this method.</p>
+     *
+     * @param tiles    the tiles to format in a table.
+     * @param maximum  the maximum number of tiles to format. If there is more tiles, a message will be
+     *                 formatted below the table. A reasonable value like 5000 is recommended because
+     *                 attempt to format millions of tiles leads to {@link OutOfMemoryError}.
+     * @return a string representation of the given tiles as a table.
+     */
+    static String toString(final Collection<Tile> tiles, final int maximum) {
+        final StringWriter writer = new StringWriter();
+        try {
+            writeTable(tiles, writer, maximum);
+        } catch (IOException e) {
+            // Should never happen since we are writing to a StringWriter.
+            throw new AssertionError(e);
+        }
+        return writer.toString();
+    }
+
+    /**
+     * Formats a collection of tiles in a table. The tiles are appended in iteration order.
+     * Tip: consider sorting the tiles before to invoke this method;
+     * tiles are {@link Comparable} for this purpose.
+     *
+     * @param tiles    the tiles to format in a table.
+     * @param out      where to write the table.
+     * @param maximum  the maximum number of tiles to format. If there is more tiles, a message will be
+     *                 formatted below the table. A reasonable value like 5000 is recommended because
+     *                 attempt to format millions of tiles leads to {@link OutOfMemoryError}.
+     * @throws IOException if an error occurred while writing to the given writer.
+     */
+    public static void writeTable(final Collection<Tile> tiles, final Writer out, final int maximum) throws IOException {
+        int remaining = maximum;
+        final TableAppender table = new TableAppender(out);
+        table.setMultiLinesCells(false);
+        table.nextLine('═');
+        table.append("Format\tInput\tindex\tx\ty\twidth\theight\tdx\tdy\n");
+        table.nextLine('─');
+        table.setMultiLinesCells(true);
+        for (final Tile tile : tiles) {
+            if (--remaining < 0) {
+                break;
+            }
+            table.setCellAlignment(TableAppender.ALIGN_LEFT);
+            tile.getName(false).ifPresent(table::append); table.nextColumn();
+            tile.getName(true) .ifPresent(table::append); table.nextColumn();
+            table.setCellAlignment(TableAppender.ALIGN_RIGHT);
+            table.append(String.valueOf(tile.getImageIndex()));
+            table.nextColumn();
+            /*
+             * Extract now the tile information that we are going to format. Those information may
+             * be replaced by the information provided by getter methods (they should be the same,
+             * unless a subclass override those methods).
+             */
+            int x            = tile.x;
+            int y            = tile.y;
+            int width        = tile.width;
+            int height       = tile.height;
+            int xSubsampling = tile.xSubsampling;
+            int ySubsampling = tile.ySubsampling;
+            try {
+                final Dimension subsampling = tile.getSubsampling();
+                xSubsampling = subsampling.width;
+                ySubsampling = subsampling.height;
+                try {
+                    final Rectangle region = tile.getRegion();
+                    x      = region.x;
+                    y      = region.y;
+                    width  = region.width;
+                    height = region.height;
+                } catch (IOException e) {
+                    /*
+                     * The (x,y) are likely to be correct since only (width,height) are read
+                     * from the image file. So set only (width,height) to "unknown" and keep
+                     * the remaining, with (x,y) obtained from direct access to Tile fields.
+                     */
+                    width  = 0;
+                    height = 0;
+                }
+            } catch (IllegalStateException e) {
+                // Ignore. Format using the information read from the fields as a fallback.
+            }
+            table.append(String.valueOf(x));
+            table.nextColumn();
+            table.append(String.valueOf(y));
+            if ((width | height) != 0) {
+                table.nextColumn();
+                table.append(String.valueOf(width));
+                table.nextColumn();
+                table.append(String.valueOf(height));
+            } else {
+                table.nextColumn();
+                table.nextColumn();
+            }
+            if ((xSubsampling | ySubsampling) != 0) {
+                table.nextColumn();
+                table.append(String.valueOf(xSubsampling));
+                table.nextColumn();
+                table.append(String.valueOf(ySubsampling));
+            }
+            table.nextLine();
+        }
+        table.nextLine('═');
+        /*
+         * Table completed. Flushs to the writer and appends additional text if we have
+         * not formatted every tiles. IOException may be thrown starting from this point
+         * (the above code is not expected to thrown any IOException).
+         */
+        table.flush();
+        if (remaining < 0) {
+            out.write(Vocabulary.getResources((Locale) null).getString(Vocabulary.Keys.More_1, tiles.size() - maximum));
+            out.write(System.lineSeparator());
+        }
+    }
+}
diff --git a/core/sis-referencing/src/main/java/org/apache/sis/internal/referencing/j2d/TranslatedTransform.java b/core/sis-referencing/src/main/java/org/apache/sis/internal/referencing/j2d/TranslatedTransform.java
new file mode 100644
index 0000000..c755db7
--- /dev/null
+++ b/core/sis-referencing/src/main/java/org/apache/sis/internal/referencing/j2d/TranslatedTransform.java
@@ -0,0 +1,75 @@
+/*
+ * 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.internal.referencing.j2d;
+
+import java.awt.Dimension;
+import java.awt.geom.AffineTransform;
+
+
+/**
+ * An affine transform which is translated relative to an original transform.
+ * The translation terms are stored separately without modifying the transform.
+ * This class if for internal use by {@link MosaicCalculator} only.
+ *
+ * @author  Martin Desruisseaux (Geomatys)
+ * @version 1.1
+ * @since   1.1
+ * @module
+ */
+final class TranslatedTransform {
+    /**
+     * The translated "grid to real world" transform, as an immutable instance.
+     */
+    private final AffineTransform gridToCRS;
+
+    /**
+     * The translation in "absolute units". This is the same units than for tiles at subsampling (1,1).
+     */
+    private final int dx, dy;
+
+    /**
+     * Creates a new translated transform. The translation is specified in "absolute units",
+     * i.e. in the same units than for tiles at subsampling (1,1).
+     *
+     * @param  subsampling  the {@linkplain Tile#getSubsampling() tile subsampling}.
+     * @param  reference    the "grid to real world" transform at subsampling (1,1).
+     * @param  dx           the translation along <var>x</var> axis in "absolute units".
+     * @param  dy           the translation along <var>y</var> axis in "absolute units".
+     */
+    TranslatedTransform(final Dimension subsampling, AffineTransform reference, int dx, int dy) {
+        this.dx = dx / subsampling.width;                           // It is okay to round toward zero.
+        this.dy = dy / subsampling.height;
+        dx %= subsampling.width;
+        dy %= subsampling.height;
+        reference = new AffineTransform(reference);
+        reference.scale(subsampling.width, subsampling.height);
+        reference.translate(dx, dy);                                // Correction for non-integer division of (dx,dy).
+        gridToCRS = new ImmutableAffineTransform(reference);
+    }
+
+    /**
+     * Applies the translation and the new "grid to CRS" transform on the given tile.
+     *
+     * @param  tile  the tile on which to apply the translation.
+     */
+    final void applyTo(final Tile tile) {
+        synchronized (tile) {
+            tile.translate(dx, dy);
+            tile.setGridToCRS(gridToCRS);
+        }
+    }
+}
diff --git a/core/sis-referencing/src/main/java/org/apache/sis/internal/referencing/j2d/package-info.java b/core/sis-referencing/src/main/java/org/apache/sis/internal/referencing/j2d/package-info.java
index 1df2079..3d4904d 100644
--- a/core/sis-referencing/src/main/java/org/apache/sis/internal/referencing/j2d/package-info.java
+++ b/core/sis-referencing/src/main/java/org/apache/sis/internal/referencing/j2d/package-info.java
@@ -26,7 +26,7 @@
  * may change in incompatible ways in any future version without notice.
  *
  * @author  Martin Desruisseaux (Geomatys)
- * @version 1.0
+ * @version 1.1
  *
  * @see org.apache.sis.internal.feature.j2d
  *
diff --git a/core/sis-referencing/src/main/java/org/apache/sis/internal/referencing/provider/DatumShiftGridCompressed.java b/core/sis-referencing/src/main/java/org/apache/sis/internal/referencing/provider/DatumShiftGridCompressed.java
index 2dca2c9..e24bcbd 100644
--- a/core/sis-referencing/src/main/java/org/apache/sis/internal/referencing/provider/DatumShiftGridCompressed.java
+++ b/core/sis-referencing/src/main/java/org/apache/sis/internal/referencing/provider/DatumShiftGridCompressed.java
@@ -124,6 +124,9 @@ final class DatumShiftGridCompressed<C extends Quantity<C>, T extends Quantity<T
 
     /**
      * Returns a new grid with the same geometry than this grid but different data arrays.
+     * This method is invoked by {@link #useSharedData()} when it detects that a newly created
+     * grid uses the same data than an existing grid. The {@code other} object is the old grid,
+     * so we can share existing data.
      */
     @Override
     protected final DatumShiftGridFile<C,T> setData(final Object[] other) {
diff --git a/core/sis-referencing/src/main/java/org/apache/sis/internal/referencing/provider/DatumShiftGridFile.java b/core/sis-referencing/src/main/java/org/apache/sis/internal/referencing/provider/DatumShiftGridFile.java
index 8c51f64..1826544 100644
--- a/core/sis-referencing/src/main/java/org/apache/sis/internal/referencing/provider/DatumShiftGridFile.java
+++ b/core/sis-referencing/src/main/java/org/apache/sis/internal/referencing/provider/DatumShiftGridFile.java
@@ -72,7 +72,11 @@ abstract class DatumShiftGridFile<C extends Quantity<C>, T extends Quantity<T>>
         @Override protected int cost(final DatumShiftGridFile<?,?> grid) {
             int p = 1;
             for (final Object array : grid.getData()) {
-                p *= Array.getLength(array);
+                if (array instanceof DatumShiftGridFile<?,?>) {
+                    p += cost((DatumShiftGridFile<?,?>) array);
+                } else {
+                    p *= Array.getLength(array);
+                }
             }
             return p;
         }
@@ -120,7 +124,7 @@ abstract class DatumShiftGridFile<C extends Quantity<C>, T extends Quantity<T>>
      *
      * @see #setSubGrids(Collection)
      */
-    private DatumShiftGridFile<C,T>[] subgrids;
+    DatumShiftGridFile<C,T>[] subgrids;
 
     /**
      * Creates a new datum shift grid for the given grid geometry.
@@ -170,6 +174,27 @@ abstract class DatumShiftGridFile<C extends Quantity<C>, T extends Quantity<T>>
         subgrids   = other.subgrids;
     }
 
+
+    /**
+     * Creates a new datum shift grid with the same configuration than the given grid,
+     * except the size and transform which are set to the given values.
+     *
+     * @param other             the other datum shift grid from which to copy parameters.
+     * @param coordinateToGrid  conversion from the "real world" coordinates to grid indices including fractional parts.
+     * @param nx                number of cells along the <var>x</var> axis in the grid.
+     * @param ny                number of cells along the <var>y</var> axis in the grid.
+     */
+    DatumShiftGridFile(final DatumShiftGridFile<C,T> other,
+                       final AffineTransform2D coordinateToGrid, final int nx, final int ny)
+    {
+        super(other.getCoordinateUnit(), coordinateToGrid, new int[] {nx, ny},
+              other.isCellValueRatio(), other.getTranslationUnit());
+        descriptor = other.descriptor;
+        files      = other.files;
+        this.nx    = nx;
+        accuracy   = other.accuracy;
+    }
+
     /**
      * Sets the sub-grids that are direct children of this grid.
      * This method can be invoked only once.
@@ -413,6 +438,9 @@ abstract class DatumShiftGridFile<C extends Quantity<C>, T extends Quantity<T>>
 
         /**
          * Returns a new grid with the same geometry than this grid but different data arrays.
+         * This method is invoked by {@link #useSharedData()} when it detects that a newly created
+         * grid uses the same data than an existing grid. The {@code other} object is the old grid,
+         * so we can share existing data.
          */
         @Override
         protected final DatumShiftGridFile<C,T> setData(final Object[] other) {
diff --git a/core/sis-referencing/src/main/java/org/apache/sis/internal/referencing/provider/DatumShiftGridGroup.java b/core/sis-referencing/src/main/java/org/apache/sis/internal/referencing/provider/DatumShiftGridGroup.java
new file mode 100644
index 0000000..d9b5d32
--- /dev/null
+++ b/core/sis-referencing/src/main/java/org/apache/sis/internal/referencing/provider/DatumShiftGridGroup.java
@@ -0,0 +1,205 @@
+/*
+ * 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.internal.referencing.provider;
+
+import java.util.Map;
+import java.util.List;
+import java.nio.file.Path;
+import java.io.IOException;
+import java.awt.Rectangle;
+import java.awt.geom.Point2D;
+import java.awt.geom.AffineTransform;
+import javax.measure.Quantity;
+import org.opengis.util.FactoryException;
+import org.opengis.referencing.operation.NoninvertibleTransformException;
+import org.apache.sis.internal.referencing.j2d.AffineTransform2D;
+import org.apache.sis.internal.referencing.j2d.MosaicCalculator;
+import org.apache.sis.internal.referencing.j2d.Tile;
+import org.apache.sis.internal.referencing.Resources;
+import org.apache.sis.internal.util.CollectionsExt;
+
+
+/**
+ * A group of datum shift grids. This is used when a NTv2 file contains more than one grid with no common parent.
+ * This class creates a synthetic parent with an affine transform approximating all grids. The affine transform is
+ * close to identity transform. Its main purpose is to locate a grid during inverse transforms, before refinements
+ * using the real grids.  So a "best match" transform (for example estimated using least squares method) would not
+ * be useful because the differences would be small compared to grid cell sizes.
+ *
+ * @author  Martin Desruisseaux (Geomatys)
+ * @version 1.1
+ * @since   1.1
+ * @module
+ */
+final class DatumShiftGridGroup<C extends Quantity<C>, T extends Quantity<T>> extends DatumShiftGridFile<C,T> {
+    /**
+     * For each {@code subgrids[i]}, {@code regions[i]} is the range of indices valid of that grid.
+     *
+     * @todo We should replace by an R-Tree. For now we assume that the array is small enough.
+     */
+    private final Rectangle[] regions;
+
+    /**
+     * Converts indices from this grid to indices in sub-grids.
+     */
+    private final AffineTransform[] toSubGrids;
+
+    /**
+     * Creates a new group for the given list of sub-grids. That list shall contain at least 2 elements.
+     * The first sub-grid is taken as a template for setting parameter values such as filename (all list
+     * elements should declare the same filename parameters, so the selected element should not matter).
+     *
+     * @param grids      sub-grids with their indices range. The array is declared as {@code Tile[]}
+     *                   because this is the type returned by {@link MosaicCalculator#tiles()}, but
+     *                   each element shall be an instance of {@link Region}.
+     * @param gridToCRS  conversion from grid indices to "real world" coordinates.
+     * @param nx         number of cells along the <var>x</var> axis in the grid.
+     * @param ny         number of cells along the <var>y</var> axis in the grid.
+     * @throws IOException declared because {@link Tile#getRegion()} declares it, but should not happen.
+     */
+    @SuppressWarnings({"rawtypes", "unchecked"})
+    private DatumShiftGridGroup(final Tile[] grids, final AffineTransform2D gridToCRS, final int nx, final int ny)
+            throws IOException, NoninvertibleTransformException
+    {
+        super((DatumShiftGridFile<C,T>) ((Region) grids[0]).grid, gridToCRS.inverse(), nx, ny);
+        subgrids   = new DatumShiftGridFile[grids.length];
+        regions    = new Rectangle[grids.length];
+        toSubGrids = new AffineTransform[grids.length];
+        for (int i=0; i<grids.length; i++) {
+            final Region r = (Region) grids[i];
+            final AffineTransform tr = new AffineTransform(gridToCRS);
+            tr.preConcatenate((AffineTransform) r.grid.getCoordinateToGrid());
+            subgrids  [i] = (DatumShiftGridFile<C,T>) r.grid;
+            regions   [i] = r.getAbsoluteRegion();
+            toSubGrids[i] = tr;
+        }
+    }
+
+    /**
+     * A sub-grid wrapped with information about the region where it applies.
+     * The region is expressed as indices in a larger grid. That larger grid
+     * is what {@link MosaicCalculator} will try to infer.
+     */
+    @SuppressWarnings("serial")
+    private static final class Region extends Tile {
+        /** The wrapped sub-grid. */
+        final DatumShiftGridFile<?,?> grid;
+
+        /**
+         * Creates a new wrapper for the given sub-grid.
+         *
+         * @param size  value of {@link DatumShiftGridFile#getGridSize()}.
+         */
+        Region(final DatumShiftGridFile<?,?> grid, final int[] size) throws NoninvertibleTransformException {
+            super(new Rectangle(size[0], size[1]), (AffineTransform) grid.getCoordinateToGrid().inverse());
+            this.grid = grid;
+        }
+    }
+
+    /**
+     * Puts the given sub-grid in a group. This method infers itself what would be the size
+     * of a grid containing all given sub-grids.
+     *
+     * @param  file  filename to report in case of error.
+     * @param  subgrids  the sub-grids to put under a common root.
+     * @throws FactoryException if the sub-grid can not be combined in a single mosaic or pyramid.
+     * @throws IOException declared because {@link Tile#getRegion()} declares it, but should not happen.
+     */
+    static <C extends Quantity<C>, T extends Quantity<T>> DatumShiftGridGroup<C,T> create(
+            final Path file, final List<DatumShiftGridFile<C,T>> subgrids)
+            throws IOException, FactoryException, NoninvertibleTransformException
+    {
+        final MosaicCalculator mosaic = new MosaicCalculator(null);
+        for (final DatumShiftGridFile<C,T> grid : subgrids) {
+            mosaic.add(new Region(grid, grid.getGridSize()));
+        }
+        final Map.Entry<Tile,Tile[]> result = CollectionsExt.singletonOrNull(mosaic.tiles().entrySet());
+        if (result == null) {
+            throw new FactoryException(Resources.format(Resources.Keys.MisalignedDatumShiftGrid_1, file));
+        }
+        final Tile global = result.getKey();
+        final Rectangle r = global.getRegion();
+        return new DatumShiftGridGroup<>(result.getValue(), global.getGridToCRS(), r.width, r.height);
+    }
+
+    /**
+     * Creates a new grid sharing the same data than an existing grid.
+     * This constructor is for {@link #setData(Object[])} usage only.
+     */
+    private DatumShiftGridGroup(final DatumShiftGridGroup<C,T> other, final DatumShiftGridFile<C,T>[] data) {
+        super(other);
+        subgrids   = data;
+        regions    = other.regions;
+        toSubGrids = other.toSubGrids;
+    }
+
+    /**
+     * Returns a new grid with the same geometry than this grid but different data arrays.
+     * This method is invoked by {@link #useSharedData()} when it detects that a newly created
+     * grid uses the same data than an existing grid. The {@code other} object is the old grid,
+     * so we can share existing data.
+     */
+    @Override
+    @SuppressWarnings("unchecked")
+    protected final DatumShiftGridFile<C,T> setData(final Object[] other) {
+        return new DatumShiftGridGroup<>(this, (DatumShiftGridFile<C,T>[]) other);
+    }
+
+    /**
+     * Returns direct references (not cloned) to the data arrays. This method is for cache management,
+     * {@link #equals(Object)} and {@link #hashCode()} implementations only and should not be invoked
+     * in other context.
+     */
+    @Override
+    @SuppressWarnings("ReturnOfCollectionOrArrayField")
+    protected Object[] getData() {
+        return subgrids;
+    }
+
+    /**
+     * Returns the number of dimensions of the translation vectors interpolated by this datum shift grid.
+     * This implementation takes the first sub-grid as a template. The selected grid should not matter
+     * since they shall all have the same number of target dimensions.
+     */
+    @Override
+    public int getTranslationDimensions() {
+        return subgrids[0].getTranslationDimensions();
+    }
+
+    /**
+     * Returns the translation stored at the given two-dimensional grid indices for the given dimension.
+     *
+     * @param  dim    the dimension of the translation vector component to get.
+     * @param  gridX  the grid index on the <var>x</var> axis, from 0 inclusive to {@code gridSize[0]} exclusive.
+     * @param  gridY  the grid index on the <var>y</var> axis, from 0 inclusive to {@code gridSize[1]} exclusive.
+     * @return the translation for the given dimension in the grid cell at the given index.
+     */
+    @Override
+    public double getCellValue(final int dim, final int gridX, final int gridY) {
+        for (int i=0; i<regions.length; i++) {
+            final Rectangle r = regions[i];
+            if (r.contains(gridX, gridY)) {
+                Point2D pt = new Point2D.Double(gridX, gridY);
+                pt = toSubGrids[i].transform(pt, pt);
+                return subgrids[i].getCellValue(dim,
+                        Math.toIntExact(Math.round(pt.getX())),
+                        Math.toIntExact(Math.round(pt.getY())));
+            }
+        }
+        throw new IndexOutOfBoundsException();
+    }
+}
diff --git a/core/sis-referencing/src/main/java/org/apache/sis/internal/referencing/provider/NTv2.java b/core/sis-referencing/src/main/java/org/apache/sis/internal/referencing/provider/NTv2.java
index 7df539b..9563bf3 100644
--- a/core/sis-referencing/src/main/java/org/apache/sis/internal/referencing/provider/NTv2.java
+++ b/core/sis-referencing/src/main/java/org/apache/sis/internal/referencing/provider/NTv2.java
@@ -472,13 +472,10 @@ public final class NTv2 extends AbstractProvider {
                 }
             }
             switch (roots.size()) {
-                case 0: throw new FactoryException(Errors.format(Errors.Keys.CanNotRead_1, file));
-                case 1: return roots.get(0);
+                case 0:  throw new FactoryException(Errors.format(Errors.Keys.CanNotRead_1, file));
+                case 1:  return roots.get(0);
+                default: return DatumShiftGridGroup.create(file, roots);
             }
-            /*
-             * If there is more than one root, creates a synthetic grid for hosting them.
-             */
-            return roots.get(0);     // TODO
         }
 
         /**
diff --git a/core/sis-referencing/src/main/java/org/apache/sis/referencing/operation/matrix/AffineTransforms2D.java b/core/sis-referencing/src/main/java/org/apache/sis/referencing/operation/matrix/AffineTransforms2D.java
index 876b6a0..3467be5 100644
--- a/core/sis-referencing/src/main/java/org/apache/sis/referencing/operation/matrix/AffineTransforms2D.java
+++ b/core/sis-referencing/src/main/java/org/apache/sis/referencing/operation/matrix/AffineTransforms2D.java
@@ -38,7 +38,7 @@ import static java.awt.geom.AffineTransform.*;
  * Those {@code AffineTransform} instances can be viewed as 3×3 matrices.
  *
  * @author  Martin Desruisseaux (IRD, Geomatys)
- * @version 0.4
+ * @version 1.1
  * @since   0.4
  * @module
  */
@@ -414,4 +414,16 @@ public final class AffineTransforms2D extends Static {
         if (scale == 0) return abs(shear);                  // Not as common as above, but still common enough.
         return hypot(scale, shear);
     }
+
+    /**
+     * Returns a global scale factor for the specified affine transform. This scale factor combines
+     * {@link #getScaleX0 getScaleX0(tr)} and {@link #getScaleY0 getScaleY0(tr)}. The way to compute
+     * such a "global" scale is somewhat arbitrary and may change in any future version.
+     *
+     * @param  tr  the affine transform to inspect.
+     * @return a "global" scale factor.
+     */
+    public static double getScale(final AffineTransform tr) {
+        return 0.5 * (AffineTransforms2D.getScaleX0(tr) + AffineTransforms2D.getScaleY0(tr));
+    }
 }
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 4d0f30c..2e1b0e5 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
@@ -645,6 +645,11 @@ public final class Vocabulary extends IndexedResourceBundle {
         public static final short ModifiedJulian = 103;
 
         /**
+         * … {0} more…
+         */
+        public static final short More_1 = 197;
+
+        /**
          * Multiplicity
          */
         public static final short Multiplicity = 104;
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 df7d99c..4fc7175 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
@@ -132,6 +132,7 @@ MissingValue            = Missing value
 Measures                = Measures
 Methods                 = Methods
 ModifiedJulian          = Modified Julian
+More_1                  = \u2026 {0} more\u2026
 Multiplicity            = Multiplicity
 Name                    = Name
 Nodata                  = No data
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 e5bef50..0c1510f 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
@@ -139,6 +139,7 @@ MissingValue            = Valeur manquante
 Measures                = Mesures
 Methods                 = M\u00e9thodes
 ModifiedJulian          = Julien modifi\u00e9
+More_1                  = \u2026 {0} de plus\u2026
 Multiplicity            = Multiplicit\u00e9
 Name                    = Nom
 Nodata                  = Absence de donn\u00e9es


Mime
View raw message