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 3f88ee02c2 Add an "opaque overlay" merge strategy for 
`CoverageAggregator`. Detect automatically when the "slices" are actually tiles 
in a mosaic, in which case the the "opaque overlay" strategy can be 
automatically selected. Improves `ImageOverlay` implementation for avoiding to 
copy tiles when possible.
3f88ee02c2 is described below

commit 3f88ee02c2e4ba7a4b208ee1dcc2178d50b95bc5
Author: Martin Desruisseaux <martin.desruisse...@geomatys.com>
AuthorDate: Wed Jan 15 17:05:42 2025 +0100

    Add an "opaque overlay" merge strategy for `CoverageAggregator`.
    Detect automatically when the "slices" are actually tiles in a mosaic,
    in which case the the "opaque overlay" strategy can be automatically 
selected.
    Improves `ImageOverlay` implementation for avoiding to copy tiles when 
possible.
---
 .../apache/sis/coverage/grid/GridDerivation.java   |   4 +-
 .../main/org/apache/sis/image/ComputedImage.java   |   2 +-
 .../main/org/apache/sis/image/ComputedTiles.java   |  12 +-
 .../main/org/apache/sis/image/ImageOverlay.java    |  59 +++++-
 .../main/org/apache/sis/image/ImageProcessor.java  |   4 +
 .../main/org/apache/sis/image/TileCache.java       |   5 +-
 .../aggregate/ConcatenatedGridCoverage.java        | 144 +++++++------
 .../sis/storage/aggregate/CoverageAggregator.java  |  11 +-
 .../sis/storage/aggregate/DimensionSelector.java   |  99 ++++++---
 .../apache/sis/storage/aggregate/GridSlice.java    |   7 +-
 .../sis/storage/aggregate/GridSliceLocator.java    |   7 +-
 .../sis/storage/aggregate/GroupAggregate.java      |   6 +-
 .../sis/storage/aggregate/GroupBySample.java       |   4 +-
 .../sis/storage/aggregate/GroupByTransform.java    |  62 ++++--
 .../sis/storage/aggregate/MergeStrategy.java       | 222 +++++++++++++++------
 .../org/apache/sis/storage/internal/Resources.java |   4 +-
 .../sis/storage/internal/Resources.properties      |   2 +-
 .../sis/storage/internal/Resources_fr.properties   |   2 +-
 18 files changed, 454 insertions(+), 202 deletions(-)

diff --git 
a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/coverage/grid/GridDerivation.java
 
b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/coverage/grid/GridDerivation.java
index fa15a7bcef..44ccc2be1f 100644
--- 
a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/coverage/grid/GridDerivation.java
+++ 
b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/coverage/grid/GridDerivation.java
@@ -1144,7 +1144,7 @@ public class GridDerivation {
      * The slicing is applied on all dimensions except the specified 
dimensions to keep.
      *
      * <h4>Example</h4>
-     * given a <var>n</var>-dimensional cube, the following call creates a 
slice of the two first dimensions
+     * Given a <var>n</var>-dimensional cube, the following call creates a 
slice of the two first dimensions
      * (numbered 0 and 1, typically the dimensions of <var>x</var> and 
<var>y</var> axes)
      * located at the center (ratio 0.5) of all other dimensions (typically 
<var>z</var> and/or <var>t</var> axes):
      *
@@ -1163,7 +1163,7 @@ public class GridDerivation {
         ArgumentChecks.ensureNonNull("dimensionsToKeep", dimensionsToKeep);
         subGridSetter = "sliceByRatio";
         final GridExtent extent = getBaseExtentExpanded(true);
-        final GeneralDirectPosition slicePoint = new 
GeneralDirectPosition(extent.getDimension());
+        final var slicePoint = new 
GeneralDirectPosition(extent.getDimension());
         baseExtent = extent.sliceByRatio(slicePoint, sliceRatio, 
dimensionsToKeep);
         if (scaledExtent != null) {
             scaledExtent = scaledExtent.sliceByRatio(slicePoint, sliceRatio, 
dimensionsToKeep);
diff --git 
a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/ComputedImage.java
 
b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/ComputedImage.java
index 8fe2511a93..f8a3bd4f50 100644
--- 
a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/ComputedImage.java
+++ 
b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/ComputedImage.java
@@ -803,7 +803,7 @@ public abstract class ComputedImage extends PlanarImage 
implements Disposable {
      * tiles from the cache and stops observation of {@link 
WritableRenderedImage} sources.
      * This image should not be used anymore after this method call.
      *
-     * <p><b>Note:</b> keep in mind that this image may be referenced as a 
source of other images.
+     * <p><b>Note:</b> caller should keep in mind that this image may be 
referenced as a source of other images.
      * In case of doubt, it may be safer to rely on the garbage collector 
instead of invoking this method.</p>
      */
     @Override
diff --git 
a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/ComputedTiles.java
 
b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/ComputedTiles.java
index 82778d97e1..b6aee07c9f 100644
--- 
a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/ComputedTiles.java
+++ 
b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/ComputedTiles.java
@@ -310,12 +310,14 @@ final class ComputedTiles extends 
WeakReference<ComputedImage> implements Dispos
 
     /**
      * Invoked when the {@link ComputedImage} has been garbage-collected. This 
method removes all cached
-     * tiles that were owned by the image and stops observing all sources.
+     * tiles that were owned by the image and stops observing all sources. If 
the same {@link Raster} was
+     * shared by many images, other images are not impacted.
      *
-     * This method should not perform other cleaning work because it is not 
guaranteed to be invoked if this
-     * {@code ComputedTiles} is not registered as a {@link TileObserver} and 
if {@link TileCache#GLOBAL} does
-     * not contain any tile for the {@link ComputedImage}. The reason is 
because there would be nothing
-     * preventing this weak reference to be garbage collected before {@code 
dispose()} is invoked.
+     * <p>This method should not perform other cleaning work because it is not 
guaranteed to be invoked.
+     * In some case, there is nothing preventing this weak reference to be 
garbage collected before this
+     * {@code dispose()} method is invoked. The case is: if {@code 
ComputedTiles} is not registered as a
+     * {@link TileObserver} and if {@link TileCache#GLOBAL} does not contain 
any tile associated to this
+     * {@link ComputedImage} in its key.</p>
      *
      * @see ComputedImage#dispose()
      */
diff --git 
a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/ImageOverlay.java
 
b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/ImageOverlay.java
index a5f484f3a6..a81e34a308 100644
--- 
a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/ImageOverlay.java
+++ 
b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/ImageOverlay.java
@@ -41,6 +41,7 @@ import org.apache.sis.math.Statistics;
 import org.apache.sis.measure.Quantities;
 import org.apache.sis.feature.internal.Resources;
 import org.apache.sis.image.privy.ImageUtilities;
+import org.apache.sis.image.privy.TilePlaceholder;
 
 
 /**
@@ -76,6 +77,11 @@ final class ImageOverlay extends MultiSourceImage {
      */
     private final Area[] contributions;
 
+    /**
+     * Pool of shared rasters for empty tiles. Used when to sources intersect 
a tile to compute.
+     */
+    private final TilePlaceholder emptyTiles;
+
     /**
      * Creates a new image overlay or returns one of the given sources if 
equivalent.
      * All source images shall have the same pixels coordinate system and the 
same number of bands.
@@ -192,6 +198,7 @@ final class ImageOverlay extends MultiSourceImage {
         super(sources, bounds, minTile, sampleModel, colorModel, parallel);
         this.validArea = validArea.isRectangular() ? validArea.getBounds2D() : 
validArea;
         this.contributions = contributions;
+        emptyTiles = TilePlaceholder.empty(sampleModel);
     }
 
     /**
@@ -390,22 +397,60 @@ final class ImageOverlay extends MultiSourceImage {
      */
     @Override
     protected Raster computeTile(final int tileX, final int tileY, 
WritableRaster target) {
-        if (target == null) {
-            target = createTile(tileX, tileY);
-        }
-        final Rectangle aoi = target.getBounds();
+        final Rectangle aoi = new Rectangle(
+                ImageUtilities.tileToPixelX(this, tileX),
+                ImageUtilities.tileToPixelY(this, tileY),
+                getTileWidth(),
+                getTileHeight());
+
+        Raster shared = null;
         final int n = getNumSources();
         for (int i=n; --i >= 0;) {
             if (contributions[i].intersects(aoi)) {
                 final RenderedImage source = getSource(i);
                 final Rectangle bounds = getBounds();
                 ImageUtilities.clipBounds(source, bounds);
-                if (!bounds.isEmpty()) {
-                    copyData(bounds, source, target);
+                if (bounds.isEmpty()) {
+                    continue;
+                }
+                /*
+                 * Found a source image which intersects the tile to write. If 
this is the first time,
+                 * get the source tile at the same coordinates as the 
destination tile. If the raster
+                 * covers exactly the same region, maybe we will be able to 
return it directly without
+                 * copying the pixel values.
+                 */
+                if (target == null) {
+                    if (shared == null) {
+                        final int tx = ImageUtilities.pixelToTileX(source, 
aoi.x);
+                        final int dx = tx - source.getMinTileX();
+                        if (dx >= 0 && dx < source.getNumXTiles()) {
+                            final int ty = ImageUtilities.pixelToTileY(source, 
aoi.y);
+                            final int dy = ty - source.getMinTileY();
+                            if (dy >= 0 && dy < source.getNumYTiles()) {
+                                shared = source.getTile(tx, ty);
+                                if (shared.getMinX() == aoi.x && 
shared.getMinY() == aoi.y) {
+                                    if 
(sampleModel.equals(shared.getSampleModel())) {
+                                        continue;   // Accept the tile and 
skip the copy operation.
+                                    }
+                                }
+                                shared = null;
+                            }
+                        }
+                    }
+                    /*
+                     * The source tile cannot be used directly. Its value will 
be copied in a new tile.
+                     * If `shared` was a candidate for return without copy, it 
needs to be copied now.
+                     */
+                    target = WritableRaster.createWritableRaster(sampleModel, 
aoi.getLocation());
+                    if (shared != null) {
+                        target.setRect(shared);
+                        shared = null;
+                    }
                 }
+                copyData(bounds, source, target);
             }
         }
-        return target;
+        return (target != null) ? target : (shared != null) ? shared : 
emptyTiles.create(aoi.getLocation());
     }
 
     /**
diff --git 
a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/ImageProcessor.java
 
b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/ImageProcessor.java
index d3cef1538e..9cdae190a2 100644
--- 
a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/ImageProcessor.java
+++ 
b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/ImageProcessor.java
@@ -1016,6 +1016,10 @@ public class ImageProcessor implements Cloneable {
      * that some sources will never be drawn (i.e., are fully hidden behind 
the first images).
      * If only one source appears to be effectively used, this method returns 
that image directly.</p>
      *
+     * <h4>Optimization</h4>
+     * The returned image may share some tiles from any source images (without 
copy)
+     * if the tile can be used directly with no change.
+     *
      * <h4>Preconditions</h4>
      * All source images shall have the same number of bands (but not 
necessarily the same sample model).
      * All source images should have equivalent color model, otherwise color 
consistency is not guaranteed.
diff --git 
a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/TileCache.java 
b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/TileCache.java
index 7e4df33a4f..7e26aba855 100644
--- 
a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/TileCache.java
+++ 
b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/TileCache.java
@@ -31,11 +31,14 @@ import org.apache.sis.pending.jdk.JDK16;
  * Tiles are kept by strong references until a memory usage limit is reached, 
in which case
  * the references of oldest tiles become soft references.
  *
+ * <p>The same {@link Raster} may be shared by many images. Removing the tiles 
of an image
+ * does not impact other images even if they share the same rasters.</p>
+ *
  * <h2>Design note</h2>
  * The use of a common cache for all images makes easier to set an 
application-wide limit
  * (for example 25% of available memory). The use of soft reference does not 
cause as much
  * memory retention as it may seem because those references are hold only as 
long as the
- * image exist. When an image is garbage collected, the corresponding soft 
references are
+ * image exists. When an image is garbage collected, the corresponding soft 
references are
  * {@linkplain Key#dispose() cleaned}.
  *
  * @author  Martin Desruisseaux (Geomatys)
diff --git 
a/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/aggregate/ConcatenatedGridCoverage.java
 
b/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/aggregate/ConcatenatedGridCoverage.java
index 46d2ccac74..b2cb1e7465 100644
--- 
a/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/aggregate/ConcatenatedGridCoverage.java
+++ 
b/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/aggregate/ConcatenatedGridCoverage.java
@@ -17,6 +17,7 @@
 package org.apache.sis.storage.aggregate;
 
 import java.util.List;
+import java.util.Arrays;
 import java.util.ArrayList;
 import java.awt.image.RenderedImage;
 import org.opengis.referencing.operation.TransformException;
@@ -29,6 +30,7 @@ import org.apache.sis.coverage.grid.DisjointExtentException;
 import org.apache.sis.storage.GridCoverageResource;
 import org.apache.sis.storage.DataStoreException;
 import org.apache.sis.storage.internal.Resources;
+import org.apache.sis.util.ArraysExt;
 import org.apache.sis.util.privy.Numerics;
 import org.apache.sis.util.collection.Cache;
 import org.apache.sis.util.logging.Logging;
@@ -242,7 +244,7 @@ final class ConcatenatedGridCoverage extends GridCoverage {
         final Object[] c = slices.clone();
         for (int i=0; i<c.length; i++) {
             if (!isDeferred(i)) {
-                final GridCoverage source = (GridCoverage) c[i];        // 
Should never fail.
+                final var source = (GridCoverage) c[i];     // Should never 
fail.
                 changed |= (c[i] = source.forConvertedValues(converted)) != 
source;
                 template = source;
             } else {
@@ -268,44 +270,41 @@ final class ConcatenatedGridCoverage extends GridCoverage 
{
      * Most recently used slices are cached for future invocations of this 
method.
      *
      * @param  extent  a subspace of this grid coverage where all dimensions 
except two have a size of 1 cell.
-     * @return the grid slice as a rendered image. Image location is relative 
to {@code sliceExtent}.
+     * @return the grid slice as a rendered image. Image location is relative 
to {@code extent}.
      */
     @Override
     public RenderedImage render(GridExtent extent) {
-        int lower = startAt, upper = lower + slices.length;
+        int lower = startAt;
+        int upper = lower + slices.length;
         if (extent != null) {
             upper = locator.getUpper(extent, lower, upper);
             lower = locator.getLower(extent, lower, upper);
         } else {
             extent = gridGeometry.getExtent();
         }
-        final GridGeometry   request;           // The geographic area and 
temporal extent requested by user.
-        final GridGeometry[] candidates;        // Grid geometry of all slices 
that intersect the request.
-        final int count = upper - lower;
-        if (count > 1) {
+        int count = upper - lower;
+        if (count == 1) {
+            return slice(extent, lower);
+        }
+        /*
+         * We have a non-trivial number of source coverages to aggregate.
+         * The `failure` exception will be thrown at the end of this method
+         * if a merge was attempted but could not find suitable sources.
+         */
+        DisjointExtentException failure = null;
+        if (count > 0) {
             if (strategy == null) {
-                /*
-                 * Cannot infer a slice. If the user specified a single slice 
but that slice
-                 * maps to more than one coverage, the error message tells 
that this problem
-                 * can be avoided by specifying a merge strategy.
-                 */
-                final short message;
-                final Object[] arguments;
-                if (locator.isSlice(extent)) {
-                    message   = Resources.Keys.NoSliceMapped_3;
-                    arguments = new Object[] 
{locator.getDimensionName(extent), lower, count};
-                } else {
-                    message   = Resources.Keys.NoSliceSpecified_2;
-                    arguments = new Object[] 
{locator.getDimensionName(extent), count};
-                }
-                throw new 
SubspaceNotSpecifiedException(Resources.format(message, arguments));
+                throw new SubspaceNotSpecifiedException(Resources.format(
+                        Resources.Keys.NoSliceMapped_3, 
locator.getDimensionName(extent), lower, count));
             }
             /*
-             * Prepare a list of slice candidates. Later in this method, a 
single slice will be selected
+             * Prepare a list of slice candidates. Later in this method, a 
single slice may be selected
              * among those candidates using the user-specified merge strategy. 
Elements in `candidates`
-             * array will become null if that candidate did not worked and we 
want to look again among
+             * array will be removed if that candidate did not worked and we 
want to look again among
              * remaining candidates.
              */
+            final GridGeometry   request;           // The geographic area and 
temporal extent requested by user.
+            final GridGeometry[] candidates;        // Grid geometry of all 
slices that intersect the request.
             try {
                 request    = new GridGeometry(getGridGeometry(), extent, null);
                 candidates = new GridGeometry[count];
@@ -318,53 +317,68 @@ final class ConcatenatedGridCoverage extends GridCoverage 
{
             } catch (DataStoreException | TransformException e) {
                 throw new 
CannotEvaluateException(Resources.format(Resources.Keys.CanNotSelectSlice), e);
             }
-        } else {
-            request    = null;
-            candidates = null;
-        }
-        /*
-         * The following loop should be executed exactly once. However, it may 
happen that the "best" slice
-         * actually does not intersect the requested extent, for example 
because the merge strategy looked
-         * only for temporal intersection and did not saw that the geographic 
extents do not intersect.
-         */
-        DisjointExtentException failure = null;
-        if (count > 0) do {
-            int index = lower;
-            if (candidates != null) {
-                final Integer n = strategy.apply(request, candidates);
-                if (n == null) break;
-                candidates[n] = null;
-                index += n;
-            }
-            final Object slice = slices[index];
-            final GridCoverage coverage;
-            if (!isDeferred(index)) {
-                coverage = (GridCoverage) slice;        // This cast should 
never fail.
-            } else try {
-                coverage = loader.getOrLoad(index, (GridCoverageResource) 
slice).forConvertedValues(isConverted);
-            } catch (DataStoreException e) {
-                throw new 
CannotEvaluateException(Resources.format(Resources.Keys.CanNotReadSlice_1, 
index + startAt), e);
-            }
             /*
-             * At this point, coverage of the "best" slice has been fetched 
from the cache or read from resource.
-             * Delegate the rendering to that coverage, after converting the 
extent from this grid coverage space
-             * to the slice coordinate space. If the coverage said that the 
converted extent does not intersect,
-             * try the "next best" slice until we succeed or until we 
exhausted the candidate list.
+             * The following loop should be executed exactly once. However, it 
may happen that the "best" slice
+             * actually does not intersect the requested extent, for example 
because the merge strategy looked
+             * only for temporal intersection and did not saw that the 
geographic extent does not intersect.
              */
-            try {
-                final RenderedImage image = 
coverage.render(locator.toSliceExtent(extent, index));
-                if (failure != null) {
-                    Logging.ignorableException(LOGGER, 
ConcatenatedGridCoverage.class, "render", failure);
+            final int[] indexes = ArraysExt.range(lower, lower + count);
+            final var   sources = new RenderedImage[count];
+            do {
+                int accepted = 0;
+                for (final int i : strategy.filter(request, 
Arrays.copyOf(candidates, count))) {
+                    try {
+                        sources[accepted] = slice(extent, indexes[i]);
+                        accepted++;     // On a separated line for 
incrementing only on success.
+                    } catch (DisjointExtentException e) {
+                        if (failure == null) failure = e;
+                        else failure.addSuppressed(e);
+                        final int remaining = --count - i;
+                        System.arraycopy(candidates, i+1, candidates, i, 
remaining);
+                        System.arraycopy(indexes,    i+1, indexes,    i, 
remaining);
+                    }
                 }
-                return image;
-            } catch (DisjointExtentException e) {
-                if (failure == null) failure = e;
-                else failure.addSuppressed(e);
-            }
-        } while (candidates != null);
+                if (accepted > 0) {
+                    if (failure != null) {
+                        Logging.ignorableException(LOGGER, 
ConcatenatedGridCoverage.class, "render", failure);
+                    }
+                    return strategy.aggregate(ArraysExt.resize(sources, 
accepted));
+                }
+            } while (count > 0);
+        }
+        /*
+         * No coverage found in the specified area of interest.
+         */
         if (failure == null) {
             failure = new DisjointExtentException(gridGeometry.getExtent(), 
extent, locator.searchDimension);
         }
         throw failure;
     }
+
+    /**
+     * Processes to the rendering of a single slice.
+     *
+     * @param  extent  a subspace of this grid coverage where all dimensions 
except two have a size of 1 cell.
+     * @param  index   index of the slice to render.
+     * @return the grid slice as a rendered image. Image location is relative 
to {@code extent}.
+     * @throws CannotEvaluateException if the slice cannot be rendered.
+     */
+    private RenderedImage slice(final GridExtent extent, final int index) {
+        final Object slice = slices[index];
+        final GridCoverage coverage;
+        if (!isDeferred(index)) {
+            coverage = (GridCoverage) slice;        // This cast should never 
fail.
+        } else try {
+            coverage = loader.getOrLoad(index, (GridCoverageResource) 
slice).forConvertedValues(isConverted);
+        } catch (DataStoreException e) {
+            throw new 
CannotEvaluateException(Resources.format(Resources.Keys.CanNotReadSlice_1, 
index + startAt), e);
+        }
+        /*
+         * At this point, coverage of the "best" slice has been fetched from 
the cache or read from resource.
+         * Delegate the rendering to that coverage, after converting the 
extent from this grid coverage space
+         * to the slice coordinate space. If the coverage said that the 
converted extent does not intersect,
+         * try the "next best" slice until we succeed or until we exhausted 
the candidate list.
+         */
+        return coverage.render(locator.toSliceExtent(extent, index));
+    }
 }
diff --git 
a/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/aggregate/CoverageAggregator.java
 
b/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/aggregate/CoverageAggregator.java
index 6b7f14c5fc..850e08729c 100644
--- 
a/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/aggregate/CoverageAggregator.java
+++ 
b/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/aggregate/CoverageAggregator.java
@@ -282,7 +282,7 @@ public final class CoverageAggregator extends 
Group<GroupBySample> {
      */
     public void add(final GridCoverageResource resource) throws 
DataStoreException {
         final GroupBySample bySample = GroupBySample.getOrAdd(members, 
resource.getSampleDimensions());
-        final GridSlice slice = new GridSlice(resource);
+        final var slice = new GridSlice(resource);
         final List<GridSlice> slices;
         try {
             slices = slice.getList(bySample.members, strategy).members;
@@ -336,7 +336,7 @@ public final class CoverageAggregator extends 
Group<GroupBySample> {
         final var  names   = new DimensionNameType[] {
             
GridExtent.typeFromAxis(crs.getCoordinateSystem().getAxis(0)).orElse(null)
         };
-        final GridExtent extent = new GridExtent(names, indices, indices, 
true);
+        final var extent = new GridExtent(names, indices, indices, true);
         final MathTransform gridToCRS = MathTransforms.linear(span, 
Math.fma(index, -span, lower));
         add(resource, new GridGeometry(extent, PixelInCell.CELL_CORNER, 
gridToCRS, crs));
     }
@@ -362,12 +362,12 @@ public final class CoverageAggregator extends 
Group<GroupBySample> {
          * but a future version may use the state of this 
`CoverageAggregator`, for example making a better
          * effort to align the resources on the same "gridToCRS" transform.
          */
-        final DefaultTemporalCRS crs = 
DefaultTemporalCRS.castOrCopy(CommonCRS.Temporal.TRUNCATED_JULIAN.crs());
+        final var crs = 
DefaultTemporalCRS.castOrCopy(CommonCRS.Temporal.TRUNCATED_JULIAN.crs());
         double scale  = crs.toValue(span);
         double offset = crs.toValue(lower);
         long   index  = Numerics.roundAndClamp(offset / scale);             // 
See comment in above method.
         offset = crs.toValue(lower.minus(span.multipliedBy(index)));
-        final GridExtent extent = new GridExtent(DimensionNameType.TIME, 
index, index, true);
+        final var extent = new GridExtent(DimensionNameType.TIME, index, 
index, true);
         final MathTransform gridToCRS = MathTransforms.linear(scale, offset);
         add(resource, new GridGeometry(extent, PixelInCell.CELL_CORNER, 
gridToCRS, crs));
     }
@@ -546,7 +546,8 @@ public final class CoverageAggregator extends 
Group<GroupBySample> {
      * Returns the algorithm to apply when more than one grid coverage can be 
found at the same grid index.
      * This is the most recent value set by a call to {@link 
#setMergeStrategy(MergeStrategy)},
      * or {@code null} if no strategy has been specified. In the latter case,
-     * a {@link SubspaceNotSpecifiedException} will be thrown by {@link 
GridCoverage#render(GridExtent)}
+     * {@link SubspaceNotSpecifiedException} will be thrown in situations of 
ambiguity.
+     * An ambiguity happens at {@link GridCoverage#render(GridExtent)} 
invocation time
      * if more than one source coverage (slice) is found for a specified grid 
index.
      *
      * @return algorithm to apply for merging source coverages at the same 
grid index, or {@code null} if none.
diff --git 
a/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/aggregate/DimensionSelector.java
 
b/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/aggregate/DimensionSelector.java
index 7da41c60d9..2f45d076ca 100644
--- 
a/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/aggregate/DimensionSelector.java
+++ 
b/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/aggregate/DimensionSelector.java
@@ -23,6 +23,7 @@ import org.apache.sis.util.privy.Strings;
 
 /**
  * A helper class for choosing the dimension on which to perform aggregation.
+ * An instance is created for each dimension of the grid geometry of a group.
  *
  * @author  Martin Desruisseaux (Geomatys)
  */
@@ -39,6 +40,12 @@ final class DimensionSelector implements 
Comparable<DimensionSelector> {
      */
     private final long[] positions;
 
+    /**
+     * The largest extent size found among all slices.
+     * Together with {@link #sumOfSize}, it provides a way to check is the 
size is constant.
+     */
+    private long maxSize;
+
     /**
      * Sum of grid extent size of each slice.
      * This is updated for each new slice added to this selector.
@@ -46,45 +53,75 @@ final class DimensionSelector implements 
Comparable<DimensionSelector> {
     private BigInteger sumOfSize;
 
     /**
-     * Increment in unit of the extent size. This calculation is based on mean 
values only.
-     * It is computed after the {@link #positions} array has been completed 
with data from all slices.
+     * {@code true} if the increment between each slice is constant and equals 
to the extent size.
+     * In such case, the slices are actually tiles of constant size in a 
regular tile matrix.
+     * This is used for setting the value of {@link GroupByTransform#isMosaic}.
+     *
+     * <h4>Validity</h4>
+     * This value is valid only after {@link #finish()} has been invoked, 
which is itself invoked
+     * only after the {@link #positions} array has been completed with data 
from all slices.
      */
-    private double relativeIncrement;
+    boolean isMosaic;
 
     /**
-     * Difference between minimal and maximal increment.
-     * This is computed after the {@link #positions} array has been completed 
with data from all slices.
+     * {@code true} if all {@link #positions} values are the same.
+     * For example, for a list of slices in the same geographic area but at 
different days <var>t</var>,
+     * this flag will typically be {@code true} for the horizontal dimensions 
and {@code false} for the
+     * temporal dimension.
+     *
+     * <h4>Validity</h4>
+     * This value is valid only after {@link #finish()} has been invoked, 
which is itself invoked
+     * only after the {@link #positions} array has been completed with data 
from all slices.
      */
-    private long incrementRange;
+    boolean isConstantPosition;
 
     /**
-     * {@code true} if all {@link #positions} values are the same.
-     * This field is valid only after {@link #finish()} call.
+     * Average position increment in unit of the extent size.
+     * Small values mean that the position barely changes compared to the 
slice size.
+     * This is used for {@linkplain #compareTo choosing a preferred 
aggregation axis}.
+     *
+     * <h4>Validity</h4>
+     * This value is valid only after {@link #finish()} has been invoked, 
which is itself invoked
+     * only after the {@link #positions} array has been completed with data 
from all slices.
      */
-    boolean isConstantPosition;
+    private double relativeIncrement;
+
+    /**
+     * Difference between minimal and maximal increment.
+     * Small values suggest that the increment is more stable compared to 
large values.
+     * This is used for {@linkplain #compareTo choosing a preferred 
aggregation axis}.
+     *
+     * <h4>Validity</h4>
+     * This value is valid only after {@link #finish()} has been invoked, 
which is itself invoked
+     * only after the {@link #positions} array has been completed with data 
from all slices.
+     */
+    private long incrementRange;
 
     /**
      * Prepares a new selector for a single dimension.
      *
-     * @param  dim  the dimension examined by this selector.
-     * @param  n    number of slices.
+     * @param  dimension   the dimension examined by this selector.
+     * @param  sliceCount  number of slices.
      */
-    DimensionSelector(final int dim, final int n) {
-        dimension = dim;
-        positions = new long[n];
-        sumOfSize = BigInteger.ZERO;
+    DimensionSelector(final int dimension, final int sliceCount) {
+        this.dimension = dimension;
+        this.positions = new long[sliceCount];
+        this.sumOfSize = BigInteger.ZERO;
     }
 
     /**
-     * Sets the extent of a single slice.
+     * Sets the position and size of a single slice. The given position can be 
the low, mid or high grid coordinate,
+     * or anything else, as long as the choice is kept consistent across calls 
to this method on the same instance.
+     * The positions can be in any order, not necessarily increasing with the 
slice index.
      *
-     * @param i     index of the slice.
-     * @param pos   position of the slice. Could be low, mid or high index, as 
long as the choice is kept consistent.
-     * @param size  size of the extent, in number of cells.
+     * @param sliceIndex  index of the slice.
+     * @param position    position of the slice from an arbitrary measurement 
process.
+     * @param extentSize  size of the extent, in number of cells.
      */
-    final void setSliceExtent(final int i, final long pos, final long size) {
-        positions[i] = pos;
-        sumOfSize = sumOfSize.add(BigInteger.valueOf(size));
+    final void setSliceExtent(final int sliceIndex, final long position, final 
long extentSize) {
+        positions[sliceIndex] = position;
+        maxSize = Math.max(maxSize, extentSize);
+        sumOfSize = sumOfSize.add(BigInteger.valueOf(extentSize));
     }
 
     /**
@@ -107,14 +144,15 @@ final class DimensionSelector implements 
Comparable<DimensionSelector> {
                 previous = p;
             }
         }
-        isConstantPosition = (maxInc == 0);
+        isMosaic = isConstantPosition = (maxInc == 0);
         if (minInc <= maxInc) {
             relativeIncrement = sumOfInc.doubleValue() / 
sumOfSize.doubleValue();
             incrementRange = maxInc - minInc;   // Cannot overflow because 
minInc >= 0.
-            /*
-             * TODO: we may have a mosaic if `incrementRange == 0 && maxInc == 
size`.
-             *       Or maybe we could accept `maxInc <= minSize`.
-             */
+            isMosaic = (incrementRange == 0) && (isConstantPosition || maxInc 
== maxSize);
+        }
+        if (isMosaic) {
+            // Verify that all tiles have the same size.
+            isMosaic = 
sumOfSize.equals(BigInteger.valueOf(maxSize).multiply(BigInteger.valueOf(positions.length)));
         }
     }
 
@@ -142,6 +180,11 @@ final class DimensionSelector implements 
Comparable<DimensionSelector> {
      */
     @Override
     public String toString() {
-        return Strings.toString(getClass(), "dimension", dimension, 
"relativeIncrement", relativeIncrement);
+        return Strings.toString(getClass(),
+                "dimension",          dimension,
+                "isMosaic",           isMosaic,
+                "isConstantPosition", isConstantPosition,
+                "relativeIncrement",  relativeIncrement,
+                "incrementRange",     incrementRange);
     }
 }
diff --git 
a/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/aggregate/GridSlice.java
 
b/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/aggregate/GridSlice.java
index ed37ed429c..ecb640f714 100644
--- 
a/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/aggregate/GridSlice.java
+++ 
b/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/aggregate/GridSlice.java
@@ -101,7 +101,7 @@ final class GridSlice {
                     return c;
                 }
             }
-            final GroupByTransform c = new GroupByTransform(geometry, 
gridToCRS, strategy);
+            final var c = new GroupByTransform(geometry, gridToCRS, strategy);
             transforms.add(c);
             return c;
         }
@@ -121,10 +121,11 @@ final class GridSlice {
      * This is invoked by {@link 
GroupByTransform#findConcatenatedDimensions()} for choosing
      * a dimension to concatenate.
      */
-    final void getGridExtent(final int i, final DimensionSelector[] writeTo) {
+    final void getGridExtent(final int sliceIndex, final DimensionSelector[] 
writeTo) {
         final GridExtent extent = getGridExtent();
         for (int dim = writeTo.length; --dim >= 0;) {
-            writeTo[dim].setSliceExtent(i, 
Math.subtractExact(extent.getMedian(dim), offset[dim]), extent.getSize(dim));
+            long position = Math.subtractExact(extent.getMedian(dim), 
offset[dim]);
+            writeTo[dim].setSliceExtent(sliceIndex, position, 
extent.getSize(dim));
         }
     }
 
diff --git 
a/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/aggregate/GridSliceLocator.java
 
b/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/aggregate/GridSliceLocator.java
index 9c0ede5387..10e224194a 100644
--- 
a/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/aggregate/GridSliceLocator.java
+++ 
b/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/aggregate/GridSliceLocator.java
@@ -16,7 +16,6 @@
  */
 package org.apache.sis.storage.aggregate;
 
-import java.util.Map;
 import java.util.List;
 import java.util.Arrays;
 import java.util.HashMap;
@@ -86,7 +85,7 @@ final class GridSliceLocator {
         sliceLows  = new long[resources.length];
         sliceHighs = new long[resources.length];
         offsets    = new long[resources.length][];
-        final Map<GridSlice,long[]> shared = new HashMap<>();
+        final var shared = new HashMap<GridSlice,long[]>();
         for (int i=0; i<resources.length; i++) {
             final GridSlice  slice  = slices.get(i);
             final GridExtent extent = slice.getGridExtent();
@@ -111,7 +110,7 @@ final class GridSliceLocator {
     final <E> GridGeometry union(final GridGeometry base, final List<E> 
slices, final Function<E,GridExtent> getter) {
         GridExtent extent = base.getExtent();
         final int dimension = extent.getDimension();
-        final DimensionNameType[] axes = new DimensionNameType[dimension];
+        final var axes = new DimensionNameType[dimension];
         final long[] low  = new long[dimension];
         final long[] high = new long[dimension];
         for (int i=0; i<dimension; i++) {
@@ -202,7 +201,7 @@ final class GridSliceLocator {
     }
 
     /**
-     * Return the name of the extent axis in the search dimension.
+     * Returns the name of the extent axis in the search dimension.
      *
      * @param  extent  the extent from which to get an axis label.
      * @return label for the search axis.
diff --git 
a/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/aggregate/GroupAggregate.java
 
b/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/aggregate/GroupAggregate.java
index d452d74ef6..0b07621285 100644
--- 
a/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/aggregate/GroupAggregate.java
+++ 
b/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/aggregate/GroupAggregate.java
@@ -170,7 +170,7 @@ final class GroupAggregate extends AbstractResource 
implements Aggregate, Aggreg
         for (int i=0; i < copy.length; i++) {
             final Resource c = copy[i];
             if (c instanceof AggregatedResource) {
-                final AggregatedResource component = (AggregatedResource) c;
+                final var component = (AggregatedResource) c;
                 changed |= ((copy[i] = component.apply(strategy)) != 
component);
             }
         }
@@ -291,7 +291,7 @@ final class GroupAggregate extends AbstractResource 
implements Aggregate, Aggreg
     static ImmutableEnvelope unionOfComponents(final Resource[] components)
             throws DataStoreException, TransformException
     {
-        final Envelope[] envelopes = new Envelope[components.length];
+        final var envelopes = new Envelope[components.length];
         for (int i=0; i < components.length; i++) {
             final Resource r = components[i];
             if (r instanceof AbstractResource) {
@@ -319,7 +319,7 @@ final class GroupAggregate extends AbstractResource 
implements Aggregate, Aggreg
      */
     @Override
     protected Metadata createMetadata() throws DataStoreException {
-        final MetadataBuilder builder = new MetadataBuilder();
+        final var builder = new MetadataBuilder();
         builder.addTitle(name);
         builder.addExtent(envelope, listeners);
         if (sampleDimensions != null) {
diff --git 
a/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/aggregate/GroupBySample.java
 
b/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/aggregate/GroupBySample.java
index f1d9256347..71cd2a0251 100644
--- 
a/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/aggregate/GroupBySample.java
+++ 
b/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/aggregate/GroupBySample.java
@@ -54,7 +54,7 @@ final class GroupBySample extends 
Group<GroupByCRS<GroupByTransform>> {
      */
     @Override
     final String createName(final Locale locale) {
-        final StringJoiner name = new StringJoiner(", ");
+        final var name = new StringJoiner(", ");
         for (final SampleDimension range : ranges) {
             name.add(range.getName().toInternationalString().toString(locale));
         }
@@ -77,7 +77,7 @@ final class GroupBySample extends 
Group<GroupByCRS<GroupByTransform>> {
                     return c;
                 }
             }
-            final GroupBySample c = new GroupBySample(ranges);
+            final var c = new GroupBySample(ranges);
             groups.add(c);
             return c;
         }
diff --git 
a/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/aggregate/GroupByTransform.java
 
b/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/aggregate/GroupByTransform.java
index abb9ee6a55..5c0d11ff42 100644
--- 
a/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/aggregate/GroupByTransform.java
+++ 
b/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/aggregate/GroupByTransform.java
@@ -62,6 +62,14 @@ final class GroupByTransform extends Group<GridSlice> {
      */
     MergeStrategy strategy;
 
+    /**
+     * Whether the members of this group are the tiles of a mosaic.
+     * This is {@code true} if all dimensions have tiles of the same size with 
an increment equal to that size.
+     *
+     * @see DimensionSelector#isMosaic
+     */
+    private boolean isMosaic;
+
     /**
      * Creates a new group of objects associated to the given transform.
      *
@@ -73,6 +81,7 @@ final class GroupByTransform extends Group<GridSlice> {
         this.geometry  = geometry;
         this.gridToCRS = gridToCRS;
         this.strategy  = strategy;
+        this.isMosaic  = true;
     }
 
     /**
@@ -112,28 +121,36 @@ final class GroupByTransform extends Group<GridSlice> {
     /**
      * Returns grid dimensions to aggregate, in order of recommendation.
      * Aggregations should use the first dimension in the returned list.
+     * This method opportunistically updates {@link #isMosaic}.
      *
      * @todo A future version should add {@code findMosaicDimensions()}, which 
should be tested first.
      */
     private int[] findConcatenatedDimensions() {
         final DimensionSelector[] selects;
         synchronized (members) {                // Should no longer be needed 
at this step, but we are paranoiac.
-            int i = members.size();
+            int sliceIndex = members.size();
             selects = new DimensionSelector[geometry.getDimension()];
-            for (int dim = selects.length; --dim >= 0;) {
-                selects[dim] = new DimensionSelector(dim, i);
+            for (int dimension = selects.length; --dimension >= 0;) {
+                selects[dimension] = new DimensionSelector(dimension, 
sliceIndex);
             }
-            while (--i >= 0) {
-                members.get(i).getGridExtent(i, selects);
+            while (--sliceIndex >= 0) {
+                members.get(sliceIndex).getGridExtent(sliceIndex, selects);
             }
         }
+        /*
+         * The above block collected information about all slices in this 
group.
+         * The following code computes the increment along each grid dimension,
+         * then finds which axis is the one on which the members are slices.
+         */
         Arrays.stream(selects).parallel().forEach(DimensionSelector::finish);
         Arrays.sort(selects);       // Contains usually less than 5 elements.
-        final int[] dimensions = new int[selects.length];
+        final var dimensions = new int[selects.length];
         int count = 0;
-        for (int i=selects.length; --i >= 0;) {
-            if (selects[i].isConstantPosition) break;
-            dimensions[count++] = selects[i].dimension;
+        for (int dimension = selects.length; --dimension >= 0;) {
+            final DimensionSelector select = selects[dimension];
+            if (select.isConstantPosition) break;
+            dimensions[count++] = select.dimension;
+            isMosaic &= select.isMosaic;
         }
         return ArraysExt.resize(dimensions, count);
     }
@@ -147,19 +164,32 @@ final class GroupByTransform extends Group<GridSlice> {
      * @return the concatenated resource.
      */
     final Resource createResource(final StoreListeners parentListeners, final 
List<SampleDimension> ranges) {
-        final int n = members.size();
-        if (n == 1) {
+        final int count = members.size();
+        if (count == 1) {
             return members.get(0).resource;
         }
-        final var slices = new GridCoverageResource[n];
+        final var slices = new GridCoverageResource[count];
         final String name = getName(parentListeners);
         final int[] dimensions = findConcatenatedDimensions();
-        if (dimensions.length == 0) {
-            for (int i=0; i<n; i++) slices[i] = members.get(i).resource;
+        if (isMosaic) {
+            if (strategy == null) {
+                /*
+                 * We can safely default to the "overlay" merge strategy.
+                 * There is no ambiguity, because no tile should overlap.
+                 * The "overlay" operation should be able to share tile
+                 * references without copying pixel values in the common
+                 * case where all tiles use the same `SampleModel`.
+                 */
+                strategy = MergeStrategy.opaqueOverlay(null);
+            }
+        } else if (dimensions.length == 0) {
+            // Unable to group the slices in a multi-dimensional cube.
+            for (int i=0; i<count; i++) slices[i] = members.get(i).resource;
             return new GroupAggregate(parentListeners, name, slices, ranges);
         }
-        final GridSliceLocator locator = new GridSliceLocator(members, 
dimensions[0], slices);
-        final GridGeometry     domain  = locator.union(geometry, members, 
GridSlice::getGridExtent);
+        // The following constructor fills itself the `slices` array content.
+        final var locator = new GridSliceLocator(members, dimensions[0], 
slices);
+        final GridGeometry domain = locator.union(geometry, members, 
GridSlice::getGridExtent);
         return new ConcatenatedGridResource(name, parentListeners, domain, 
ranges, slices, locator, strategy);
     }
 }
diff --git 
a/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/aggregate/MergeStrategy.java
 
b/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/aggregate/MergeStrategy.java
index 1b4fa71d94..4c57d9d0f9 100644
--- 
a/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/aggregate/MergeStrategy.java
+++ 
b/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/aggregate/MergeStrategy.java
@@ -16,6 +16,8 @@
  */
 package org.apache.sis.storage.aggregate;
 
+import java.awt.Rectangle;
+import java.awt.image.RenderedImage;
 import java.time.Instant;
 import java.time.Duration;
 import org.apache.sis.storage.Resource;
@@ -24,6 +26,8 @@ import org.apache.sis.coverage.grid.GridExtent;
 import org.apache.sis.coverage.grid.GridCoverage;
 import org.apache.sis.coverage.grid.GridGeometry;
 import org.apache.sis.referencing.privy.ExtentSelector;
+import org.apache.sis.image.ImageProcessor;
+import org.apache.sis.util.ArraysExt;
 import org.apache.sis.util.privy.Strings;
 
 
@@ -31,47 +35,87 @@ import org.apache.sis.util.privy.Strings;
  * Algorithm to apply when more than one grid coverage can be found at the 
same grid index.
  * A merge may happen if an aggregated coverage is created with {@link 
CoverageAggregator},
  * and the extent of some source coverages are overlapping in the dimension to 
aggregate.
+ * {@code MergeStrategy} is ignored if only one coverage is contained in a 
requested extent.
  *
  * <h2>Example</h2>
  * A collection of {@link GridCoverage} instances may represent the same 
phenomenon
- * (for example Sea Surface Temperature) over the same geographic area but at 
different dates and times.
- * {@link CoverageAggregator} can be used for building a single data cube with 
a time axis.
+ * (for example, air temperature) over the same geographic area but at 
different days.
+ * In such case, {@link CoverageAggregator} can build a three-dimensional data 
cube
+ * where each source coverage is located at a different position on the time 
axis.
  * But if two coverages have overlapping time ranges, and if a user request 
data in the overlapping region,
- * then the aggregated coverages have more than one source coverages capable 
to provide the requested data.
- * This enumeration specify how to handle this multiplicity.
+ * then there is an ambiguity about which data to return.
+ * This {@code MergeStrategy} specifies how to handle this multiplicity.
  *
  * <h2>Default behavior</h2>
  * If no merge strategy is specified, then the default behavior is to throw
- * {@link SubspaceNotSpecifiedException} when the {@link 
GridCoverage#render(GridExtent)} method
- * is invoked and more than one source coverage (slice) is found for a 
specified grid index.
+ * {@link SubspaceNotSpecifiedException} in situations of ambiguity.
+ * An ambiguity happens at {@link GridCoverage#render(GridExtent)} invocation 
time
+ * if more than one source coverage (slice) is found for a specified grid 
index.
  *
  * @author  Martin Desruisseaux (Geomatys)
- * @version 1.3
- * @since   1.3
+ * @version 1.5
+ *
+ * @see CoverageAggregator#setMergeStrategy(MergeStrategy)
+ *
+ * @since 1.3
  */
-public final class MergeStrategy {
+public abstract class MergeStrategy {
     /**
-     * Selects a single slice using criteria based first on temporal extent, 
then on geographic area.
-     * This default instance do not use any duration.
+     * Creates a new merge strategy.
      *
-     * @see #selectByTimeThenArea(Duration)
+     * @since 1.5
      */
-    private static final MergeStrategy SELECT_BY_TIME = new 
MergeStrategy(null);
+    protected MergeStrategy() {
+    }
 
     /**
-     * Temporal granularity of the time of interest, or {@code null} if none.
-     * If non-null, intersections with TOI will be rounded to an integer 
number of this granularity.
-     * This is useful if data are expected at an approximately regular interval
-     * and we want to ignore slight variations in the temporal extent declared 
for each image.
+     * Builds an {@linkplain org.apache.sis.image.ImageProcessor#overlay image 
overlay} of all sources.
+     * The source images added first have precedence (foreground). Images 
added last are in background.
+     * All bands are referenced or copied verbatim, without special treatment 
for the alpha channel.
+     * In other words, this merge strategy does not handle transparency in 
overlapping regions.
+     *
+     * @param  areaOfInterest  range of pixel coordinates, or {@code null} for 
the union of all images.
+     * @return a merge strategy for building an overlay of all source images.
+     *
+     * @since 1.5
      */
-    private final Duration timeGranularity;
+    public static MergeStrategy opaqueOverlay(final Rectangle areaOfInterest) {
+        Overlay strategy = Overlay.DEFAULT;
+        if (areaOfInterest != null) {
+            strategy = new Overlay(strategy.processor, new 
Rectangle(areaOfInterest));
+        }
+        return strategy;
+    }
 
     /**
-     * Creates a new merge strategy. This constructor is private for now 
because
-     * we have not yet decided a callback API for custom merges.
+     * The implementation returned by {@link #opaqueOverlay(Rectangle)}.
      */
-    private MergeStrategy(final Duration timeGranularity) {
-        this.timeGranularity = timeGranularity;
+    private static final class Overlay extends MergeStrategy {
+        /** The default instance with no particular area of interest 
specified. */
+        static final Overlay DEFAULT = new Overlay(new ImageProcessor(), null);
+
+        /** The image processor with the configuration to use. */
+        final ImageProcessor processor;
+
+        /** The area of interest, or {@code null} if none. */
+        private final Rectangle areaOfInterest;
+
+        /** Creates a new strategy for an image in the given area. */
+        Overlay(final ImageProcessor processor, final Rectangle 
areaOfInterest) {
+            this.processor = processor;
+            this.areaOfInterest = areaOfInterest;
+        }
+
+        /** Aggregates the given sources. */
+        @Override protected RenderedImage aggregate(RenderedImage[] sources) {
+            return processor.overlay(sources, areaOfInterest);
+        }
+
+        /** Returns a string representation of this strategy for debugging 
purposes. */
+        @Override public String toString() {
+            return Strings.toString(MergeStrategy.class, null,
+                    "opaqueOverlay", "areaOfInterest", areaOfInterest);
+        }
     }
 
     /**
@@ -118,49 +162,93 @@ public final class MergeStrategy {
      * Current implementation does not check the vertical dimension.
      * This check may be added in a future version.
      *
-     * @param  timeGranularity  the temporal granularity of the Time of 
Interest (TOI), or {@code null} if none.
+     * @param  timeGranularity  the temporal granularity of the Time of 
Interest (<abbr>TOI</abbr>), or {@code null} if none.
      * @return a merge strategy for selecting a slice based on temporal 
criteria first.
      */
     public static MergeStrategy selectByTimeThenArea(final Duration 
timeGranularity) {
-        return (timeGranularity != null) ? new MergeStrategy(timeGranularity) 
: SELECT_BY_TIME;
+        return (timeGranularity != null) ? new FilterByTime(timeGranularity) : 
FilterByTime.DEFAULT;
     }
 
     /**
-     * Applies the merge using the strategy represented by this instance.
-     * Current implementation does only a slice selection.
-     * A future version may allow real merge operations.
-     *
-     * @param  request     the geographic area and temporal extent requested 
by user.
-     * @param  candidates  grid geometry of all slices that intersect the 
request. Null elements are ignored.
-     * @return index of best slice according the heuristic rules of this 
{@code MergeStrategy}.
+     * The implementation returned by {@link #selectByTimeThenArea(Duration)}.
      */
-    final Integer apply(final GridGeometry request, final GridGeometry[] 
candidates) {
-        final ExtentSelector<Integer> selector = new ExtentSelector<>(
-                request.getGeographicExtent().orElse(null),
-                request.getTemporalExtent());
-
-        if (timeGranularity != null) {
-            selector.setTimeGranularity(timeGranularity);
-            selector.alternateOrdering = true;
+    private static final class FilterByTime extends MergeStrategy {
+        /**
+         * The default instance with no time granularity.
+         * Temporal positions are compared at their full precision.
+         */
+        static final FilterByTime DEFAULT = new FilterByTime(null);
+
+        /**
+         * Temporal granularity of the time of interest, or {@code null} if 
none.
+         * If non-null, intersections with TOI will be rounded to an integer 
number of this granularity.
+         * This is useful if data are expected at an approximately regular 
interval
+         * and we want to ignore slight variations in the temporal extent 
declared for each image.
+         */
+        private final Duration timeGranularity;
+
+        /**
+         * Creates a new strategy for the given time granularity.
+         */
+        FilterByTime(final Duration timeGranularity) {
+            this.timeGranularity = timeGranularity;
         }
-        for (int i=0; i < candidates.length; i++) {
-            final GridGeometry candidate = candidates[i];
-            if (candidate != null) {
-                final Instant[] t = candidate.getTemporalExtent();
-                final int n = t.length;
-                selector.evaluate(candidate.getGeographicExtent().orElse(null),
-                                  (n == 0) ? null : t[0],
-                                  (n == 0) ? null : t[n-1], i);
+
+        /**
+         * Selects a single coverage using the strategy represented by this 
instance.
+         * May return an empty array if there is no source that can be used.
+         *
+         * @param  request     the geographic area and temporal extent 
requested by user.
+         * @param  candidates  grid geometry of all slices that intersect the 
request. Null elements are ignored.
+         * @return index of best slice according the heuristic rules of this 
{@code MergeStrategy}, or empty.
+         */
+        @Override
+        protected int[] filter(final GridGeometry request, final 
GridGeometry[] candidates) {
+            final var selector = new ExtentSelector<Integer>(
+                    request.getGeographicExtent().orElse(null),
+                    request.getTemporalExtent());
+
+            if (timeGranularity != null) {
+                selector.setTimeGranularity(timeGranularity);
+                selector.alternateOrdering = true;
+            }
+            for (int i=0; i < candidates.length; i++) {
+                final GridGeometry candidate = candidates[i];
+                if (candidate != null) {
+                    final Instant[] t = candidate.getTemporalExtent();
+                    final int n = t.length;
+                    
selector.evaluate(candidate.getGeographicExtent().orElse(null),
+                                      (n == 0) ? null : t[0],
+                                      (n == 0) ? null : t[n-1], i);
+                }
             }
+            final Integer best = selector.best();
+            return (best != null) ? new int[] {best} : ArraysExt.EMPTY_INT;
+        }
+
+        /**
+         * Returns the single image selected by the filter.
+         * The array length should always be exactly one.
+         */
+        @Override
+        protected RenderedImage aggregate(RenderedImage[] sources) {
+            return sources[0];
+        }
+
+        /**
+         * Returns a string representation of this strategy for debugging 
purposes.
+         */
+        @Override
+        public String toString() {
+            return Strings.toString(MergeStrategy.class, null,
+                    "selectByTimeThenArea", "timeGranularity", 
timeGranularity);
         }
-        return selector.best();
     }
 
     /**
-     * Returns a resource with same data as specified resource but using this 
merge strategy.
+     * Returns a resource with the same data as the specified resource, but 
using this merge strategy.
      * If the given resource is an instance created by {@link 
CoverageAggregator} and uses a different strategy,
-     * then a new resource using this merge strategy is returned. Otherwise 
the given resource is returned as-is.
-     * The returned resource will share the same resources and caches than the 
given resource.
+     * then a new resource using this merge strategy is returned. Otherwise, 
the given resource is returned as-is.
      *
      * @param  resource  the resource for which to update the merge strategy, 
or {@code null}.
      * @return resource with updated merge strategy, or {@code null} if the 
given resource was null.
@@ -173,12 +261,34 @@ public final class MergeStrategy {
     }
 
     /**
-     * Returns a string representation of this strategy for debugging purposes.
+     * Returns the indexes of the coverages to use in the aggregation.
+     * The {@code candidates} array contains the grid geometries of all 
coverages that intersect the request.
+     * This method can decide to accept none of those candidates (by returning 
an empty array), or to select
+     * exactly one (for example, based on {@linkplain #selectByTimeThenArea a 
temporal criterion}),
+     * or on the contrary to select all of them, or any intermediate choice.
      *
-     * @return string representation of this strategy.
+     * <p>The default implementation selects all candidates (i.e., filter 
nothing).</p>
+     *
+     * @param  request     the geographic area and temporal extent requested 
by user.
+     * @param  candidates  grid geometry of all slices that intersect the 
request.
+     * @return indexes of the slices to use according the heuristic rules of 
this {@code MergeStrategy}.
+     *
+     * @since 1.5
      */
-    @Override
-    public String toString() {
-        return Strings.toString(getClass(), "algo", "selectByTimeThenArea", 
"timeGranularity", timeGranularity);
+    protected int[] filter(GridGeometry request, GridGeometry[] candidates) {
+        return ArraysExt.range(0, candidates.length);
     }
+
+    /**
+     * Aggregates images that have been accepted by the filter. The length of 
the {@code sources} array
+     * is equal or smaller than the length of the index array returned by 
{@link #filter filter(…)}.
+     * The array may be shorter if some images were outside the request, but 
the array always contains
+     * at least one element.
+     *
+     * @param  sources  the images accepted by the filter.
+     * @return the result of the aggregation.
+     *
+     * @since 1.5
+     */
+    protected abstract RenderedImage aggregate(RenderedImage[] sources);
 }
diff --git 
a/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/internal/Resources.java
 
b/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/internal/Resources.java
index 0209faa010..36f32a4f02 100644
--- 
a/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/internal/Resources.java
+++ 
b/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/internal/Resources.java
@@ -328,8 +328,8 @@ public class Resources extends IndexedResourceBundle {
         public static final short NoCommonFeatureType = 75;
 
         /**
-         * Index {1} in dimension “{0}” maps to {2} slices. This error can be 
avoided by specifying a
-         * merge strategy.
+         * Cell coordinate {1} in dimension “{0}” maps to {2} slices or tiles. 
A smaller extent or a
+         * merge strategy should be specified.
          */
         public static final short NoSliceMapped_3 = 79;
 
diff --git 
a/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/internal/Resources.properties
 
b/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/internal/Resources.properties
index 7e876bb0e4..79c2562fb5 100644
--- 
a/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/internal/Resources.properties
+++ 
b/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/internal/Resources.properties
@@ -73,7 +73,7 @@ MetadataLocation                  = Relative path to metadata.
 MissingResourceIdentifier_1       = Resource \u201c{0}\u201d does not have an 
identifier.
 MissingSchemeInURI_1              = Missing scheme in \u201c{0}\u201d URI.
 NoCommonFeatureType               = No feature type is common to all the 
features to aggregate.
-NoSliceMapped_3                   = Index {1} in dimension \u201c{0}\u201d 
maps to {2} slices. This error can be avoided by specifying a merge strategy.
+NoSliceMapped_3                   = Cell coordinate {1} in dimension 
\u201c{0}\u201d maps to {2} slices or tiles. A smaller extent or a merge 
strategy should be specified.
 NoSliceSpecified_2                = Extent in dimension \u201c{0}\u201d should 
be a slice, but {1} cells were specified.
 NoSuchResourceDirectory_1         = No directory of resources found at 
\u201c{0}\u201d.
 NoSuchResourceInAggregate_2       = Resource \u201c{1}\u201d is not part of 
aggregate \u201c{0}\u201d.
diff --git 
a/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/internal/Resources_fr.properties
 
b/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/internal/Resources_fr.properties
index b628d0fc9d..3088bb43ac 100644
--- 
a/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/internal/Resources_fr.properties
+++ 
b/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/internal/Resources_fr.properties
@@ -78,7 +78,7 @@ MetadataLocation                  = Chemin relatif des 
m\u00e9ta-donn\u00e9es.
 MissingResourceIdentifier_1       = La ressource \u00ab\u202f{0}\u202f\u00bb 
n\u2019a pas d\u2019identifiant.
 MissingSchemeInURI_1              = Il manque le sch\u00e9ma dans l\u2019URI 
\u00ab\u202f{0}\u202f\u00bb.
 NoCommonFeatureType               = Il n\u2019y a pas de type commun \u00e0 
toutes les entit\u00e9s \u00e0 agr\u00e9ger.
-NoSliceMapped_3                   = L\u2019index {1} dans la dimension 
\u00ab\u202f{0}\u202f\u00bb correspond \u00e0 {2} tranches. Cette erreur peut 
\u00eatre \u00e9vit\u00e9e en sp\u00e9cifiant une strat\u00e9gie de fusion.
+NoSliceMapped_3                   = La coordonn\u00e9e de cellule {1} dans la 
dimension \u00ab\u202f{0}\u202f\u00bb correspond \u00e0 {2} tranches ou tuiles. 
Une \u00e9tendue plus petite, ou une strat\u00e9gie de fusion, devrait 
\u00eatre sp\u00e9cifi\u00e9e.
 NoSliceSpecified_2                = La plage dans la dimension 
\u00ab\u202f{0}\u202f\u00bb devrait \u00eatre une tranche, mais {1} cellules 
ont \u00e9t\u00e9 sp\u00e9cifi\u00e9es.
 NoSuchResourceDirectory_1         = Aucun r\u00e9pertoire de ressources 
n\u2019a \u00e9t\u00e9 trouv\u00e9 \u00e0 l\u2019emplacement 
\u00ab\u202f{0}\u202f\u00bb.
 NoSuchResourceInAggregate_2       = La ressource \u00ab\u202f{1}\u202f\u00bb 
n\u2019est pas une partie de l\u2019agr\u00e9gat \u00ab\u202f{0}\u202f\u00bb.

Reply via email to