You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@sis.apache.org by de...@apache.org on 2023/04/08 16:38:08 UTC

[sis] 02/03: Add a `MultiSourceImage` package-private abstract class and add support for prefetch operation.

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

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

commit d3164ba70d09272dea3950f305dfc45f404bae18
Author: Martin Desruisseaux <ma...@geomatys.com>
AuthorDate: Sat Apr 8 15:29:12 2023 +0200

    Add a `MultiSourceImage` package-private abstract class and add support for prefetch operation.
---
 .../coverage/grid/BandAggregateGridCoverage.java   |   4 +-
 .../sis/coverage/grid/GridCoverageProcessor.java   |   4 +-
 .../org/apache/sis/image/BandAggregateImage.java   | 100 ++----------
 .../java/org/apache/sis/image/BandSharing.java     |  31 ++--
 .../java/org/apache/sis/image/ImageProcessor.java  |   6 +-
 .../org/apache/sis/image/MultiSourceImage.java     | 159 ++++++++++++++++++
 ...inedImageLayout.java => MultiSourceLayout.java} |  12 +-
 .../org/apache/sis/image/MultiSourcePrefetch.java  | 178 +++++++++++++++++++++
 ...urcesArgument.java => MultiSourceArgument.java} |   4 +-
 .../apache/sis/image/BandAggregateImageTest.java   |  37 ++++-
 .../aggregate/BandAggregateGridResource.java       |   4 +-
 11 files changed, 416 insertions(+), 123 deletions(-)

diff --git a/core/sis-feature/src/main/java/org/apache/sis/coverage/grid/BandAggregateGridCoverage.java b/core/sis-feature/src/main/java/org/apache/sis/coverage/grid/BandAggregateGridCoverage.java
index 6b95c83080..113033a9fb 100644
--- a/core/sis-feature/src/main/java/org/apache/sis/coverage/grid/BandAggregateGridCoverage.java
+++ b/core/sis-feature/src/main/java/org/apache/sis/coverage/grid/BandAggregateGridCoverage.java
@@ -24,7 +24,7 @@ import org.opengis.referencing.operation.TransformException;
 import org.apache.sis.image.DataType;
 import org.apache.sis.image.ImageProcessor;
 import org.apache.sis.internal.feature.Resources;
-import org.apache.sis.internal.coverage.MultiSourcesArgument;
+import org.apache.sis.internal.coverage.MultiSourceArgument;
 import org.apache.sis.internal.util.CollectionsExt;
 
 
@@ -89,7 +89,7 @@ final class BandAggregateGridCoverage extends GridCoverage {
      * @throws IllegalArgumentException if there is an incompatibility between some source coverages
      *         or if some band indices are duplicated or outside their range of validity.
      */
-    BandAggregateGridCoverage(final MultiSourcesArgument<GridCoverage> aggregate, final ImageProcessor processor) {
+    BandAggregateGridCoverage(final MultiSourceArgument<GridCoverage> aggregate, final ImageProcessor processor) {
         super(aggregate.domain(GridCoverage::getGridGeometry), aggregate.ranges());
         this.sources           = aggregate.sources();
         this.bandsPerSource    = aggregate.bandsPerSource();
diff --git a/core/sis-feature/src/main/java/org/apache/sis/coverage/grid/GridCoverageProcessor.java b/core/sis-feature/src/main/java/org/apache/sis/coverage/grid/GridCoverageProcessor.java
index a57a73b479..f7f9017fc9 100644
--- a/core/sis-feature/src/main/java/org/apache/sis/coverage/grid/GridCoverageProcessor.java
+++ b/core/sis-feature/src/main/java/org/apache/sis/coverage/grid/GridCoverageProcessor.java
@@ -39,7 +39,7 @@ import org.apache.sis.image.Colorizer;
 import org.apache.sis.image.ImageProcessor;
 import org.apache.sis.image.Interpolation;
 import org.apache.sis.internal.coverage.SampleDimensions;
-import org.apache.sis.internal.coverage.MultiSourcesArgument;
+import org.apache.sis.internal.coverage.MultiSourceArgument;
 import org.apache.sis.util.ArgumentChecks;
 import org.apache.sis.util.logging.Logging;
 import org.apache.sis.util.collection.WeakHashSet;
@@ -761,7 +761,7 @@ public class GridCoverageProcessor implements Cloneable {
      * @since 1.4
      */
     public GridCoverage aggregateRanges(GridCoverage[] sources, int[][] bandsPerSource) {
-        final var aggregate = new MultiSourcesArgument<>(sources, bandsPerSource);
+        final var aggregate = new MultiSourceArgument<>(sources, bandsPerSource);
         aggregate.identityAsNull();
         aggregate.validate(GridCoverage::getSampleDimensions);
         if (aggregate.isIdentity()) {
diff --git a/core/sis-feature/src/main/java/org/apache/sis/image/BandAggregateImage.java b/core/sis-feature/src/main/java/org/apache/sis/image/BandAggregateImage.java
index 46c0a540cf..7ffbd60648 100644
--- a/core/sis-feature/src/main/java/org/apache/sis/image/BandAggregateImage.java
+++ b/core/sis-feature/src/main/java/org/apache/sis/image/BandAggregateImage.java
@@ -16,8 +16,6 @@
  */
 package org.apache.sis.image;
 
-import java.util.Arrays;
-import java.util.Objects;
 import java.awt.Rectangle;
 import java.awt.image.ColorModel;
 import java.awt.image.BandedSampleModel;
@@ -43,34 +41,7 @@ import org.apache.sis.internal.coverage.j2d.ImageUtilities;
  *
  * @since 1.4
  */
-class BandAggregateImage extends WritableComputedImage {
-    /**
-     * The source images with only the bands to aggregate, in order.
-     * Those images are views; the band sample values are not copied.
-     */
-    protected final RenderedImage[] filteredSources;
-
-    /**
-     * Color model of the aggregated image.
-     *
-     * @see #getColorModel()
-     */
-    private final ColorModel colorModel;
-
-    /**
-     * Domain of pixel coordinates. All images shall share the same pixel coordinate space,
-     * meaning that a pixel at coordinates (<var>x</var>, <var>y</var>) in this image will
-     * contain the sample values of all source images at the same coordinates.
-     * It does <em>not</em> mean that all source images shall have the same bounds.
-     */
-    private final int minX, minY, width, height;
-
-    /**
-     * Index of the first tile. Contrarily to pixel coordinates,
-     * the tile coordinate space does not need to be the same for all images.
-     */
-    private final int minTileX, minTileY;
-
+class BandAggregateImage extends MultiSourceImage {
     /**
      * Whether the sharing of data arrays is allowed.
      * When a source tile has the same bounds and scanline stride than the target tile,
@@ -87,19 +58,20 @@ class BandAggregateImage extends WritableComputedImage {
      * @param  bandsPerSource  bands to use for each source image, in order. May contain {@code null} elements.
      * @param  colorizer       provider of color model to use for this image, or {@code null} for automatic.
      * @param  allowSharing    whether to allow the sharing of data buffers (instead of copying) if possible.
+     * @param  parallel        whether parallel computation is allowed.
      * @throws IllegalArgumentException if there is an incompatibility between some source images
      *         or if some band indices are duplicated or outside their range of validity.
      * @return the band aggregate image.
      */
     static RenderedImage create(final RenderedImage[] sources, final int[][] bandsPerSource,
-                                final Colorizer colorizer, final boolean allowSharing)
+                                final Colorizer colorizer, final boolean allowSharing, final boolean parallel)
     {
-        final var layout = CombinedImageLayout.create(sources, bandsPerSource, allowSharing);
+        final var layout = MultiSourceLayout.create(sources, bandsPerSource, allowSharing);
         final BandAggregateImage image;
         if (layout.isWritable()) {
-            image = new Writable(layout, colorizer, allowSharing);
+            image = new Writable(layout, colorizer, allowSharing, parallel);
         } else {
-            image = new BandAggregateImage(layout, colorizer, allowSharing);
+            image = new BandAggregateImage(layout, colorizer, allowSharing, parallel);
         }
         if (image.filteredSources.length == 1) {
             final RenderedImage c = image.filteredSources[0];
@@ -120,30 +92,13 @@ class BandAggregateImage extends WritableComputedImage {
      * @param  layout     pixel and tile coordinate spaces of this image, together with sample model.
      * @param  colorizer  provider of color model to use for this image, or {@code null} for automatic.
      */
-    BandAggregateImage(final CombinedImageLayout layout, final Colorizer colorizer, final boolean allowSharing) {
-        super(layout.sampleModel, layout.sources);
+    BandAggregateImage(final MultiSourceLayout layout, final Colorizer colorizer,
+                       final boolean allowSharing, final boolean parallel)
+    {
+        super(layout, colorizer, parallel);
         this.allowSharing = allowSharing;
-        final Rectangle r = layout.domain;
-        minX            = r.x;
-        minY            = r.y;
-        width           = r.width;
-        height          = r.height;
-        minTileX        = layout.minTileX;
-        minTileY        = layout.minTileY;
-        filteredSources = layout.filteredSources;
-        colorModel      = layout.createColorModel(colorizer);
-        ensureCompatible(colorModel);
     }
 
-    /** Returns the information inferred at construction time. */
-    @Override public ColorModel getColorModel() {return colorModel;}
-    @Override public int        getWidth()      {return width;}
-    @Override public int        getHeight()     {return height;}
-    @Override public int        getMinX()       {return minX;}
-    @Override public int        getMinY()       {return minY;}
-    @Override public int        getMinTileX()   {return minTileX;}
-    @Override public int        getMinTileY()   {return minTileY;}
-
     /**
      * Creates a raster containing the selected bands of source images.
      *
@@ -159,15 +114,13 @@ class BandAggregateImage extends WritableComputedImage {
         /*
          * If we are allowed to share the data arrays, try that first.
          * The cast to `BandedSampleModel` is safe because this is the
-         * type given by `CombinedImageLayout` in the constructor.
+         * type given by `MultiSourceLayout` in the constructor.
          */
         BandSharedRaster shared = null;
         if (allowSharing) {
             final BandSharing sharing = BandSharing.create((BandedSampleModel) sampleModel);
             if (sharing != null) {
-                final long x = Math.multiplyFull(tileX - minTileX, getTileWidth())  + minX;
-                final long y = Math.multiplyFull(tileY - minTileY, getTileHeight()) + minY;
-                tile = shared = sharing.createRaster(x, y, filteredSources);
+                tile = shared = sharing.createRaster(tileToPixel(tileX, tileY), filteredSources);
             }
         }
         /*
@@ -206,8 +159,10 @@ class BandAggregateImage extends WritableComputedImage {
          * @param  layout     pixel and tile coordinate spaces of this image, together with sample model.
          * @param  colorizer  provider of color model to use for this image, or {@code null} for automatic.
          */
-        Writable(final CombinedImageLayout layout, final Colorizer colorizer, final boolean allowSharing) {
-            super(layout, colorizer, allowSharing);
+        Writable(final MultiSourceLayout layout, final Colorizer colorizer,
+                 final boolean allowSharing, final boolean parallel)
+        {
+            super(layout, colorizer, allowSharing, parallel);
         }
 
         /**
@@ -291,32 +246,11 @@ class BandAggregateImage extends WritableComputedImage {
         }
     }
 
-    /**
-     * Returns a hash code value for this image.
-     */
-    @Override
-    public int hashCode() {
-        return sampleModel.hashCode() + 37 * (Arrays.hashCode(filteredSources) + 31 * Objects.hashCode(colorModel));
-    }
-
     /**
      * Compares the given object with this image for equality.
-     *
-     * <h4>Implementation note</h4>
-     * We do not invoke {@link #equalsBase(Object)} for saving the comparisons of {@link ComputedImage#sources} array.
-     * The comparison of {@link #filteredSources} array will indirectly include the comparison of raw source images.
      */
     @Override
     public boolean equals(final Object object) {
-        if (object instanceof BandAggregateImage) {
-            final BandAggregateImage other = (BandAggregateImage) object;
-            return minTileX == other.minTileX &&
-                   minTileY == other.minTileY &&
-                   getBounds().equals(other.getBounds()) &&
-                   sampleModel.equals(other.sampleModel) &&
-                   Objects.equals(colorModel, other.colorModel) &&
-                   Arrays.equals(filteredSources, other.filteredSources);
-        }
-        return false;
+        return super.equals(object) && ((BandAggregateImage) object).allowSharing == allowSharing;
     }
 }
diff --git a/core/sis-feature/src/main/java/org/apache/sis/image/BandSharing.java b/core/sis-feature/src/main/java/org/apache/sis/image/BandSharing.java
index 29ffb0fba5..4f2bf46e9c 100644
--- a/core/sis-feature/src/main/java/org/apache/sis/image/BandSharing.java
+++ b/core/sis-feature/src/main/java/org/apache/sis/image/BandSharing.java
@@ -102,12 +102,11 @@ abstract class BandSharing {
      * Prepares sharing the arrays of the given sources when possible.
      * This method does not allocate new {@link DataBuffer} banks.
      *
-     * @param  x        <var>x</var> pixel coordinate of the tile.
-     * @param  y        <var>y</var> pixel coordinate of the tile.
-     * @param  sources  the sources for which to aggregate all bands.
+     * @param  location  smallest (<var>x</var>,<var>y</var>) pixel coordinates of the tile.
+     * @param  sources   the sources for which to aggregate all bands.
      * @return data buffer size, or 0 if there is nothing to share.
      */
-    private int prepare(final long x, final long y, final RenderedImage[] sources) {
+    private int prepare(final Point location, final RenderedImage[] sources) {
         final int tileWidth      = target.getWidth();
         final int tileHeight     = target.getHeight();
         final int scanlineStride = target.getScanlineStride();
@@ -121,17 +120,15 @@ abstract class BandSharing {
             if (source.getTileWidth()  == tileWidth &&
                 source.getTileHeight() == tileHeight)
             {
-                long tileX = x - source.getTileGridXOffset();
-                long tileY = y - source.getTileGridYOffset();
+                int tileX = Math.subtractExact(location.x, source.getTileGridXOffset());
+                int tileY = Math.subtractExact(location.y, source.getTileGridYOffset());
                 if (((tileX % tileWidth) | (tileY % tileHeight)) == 0) {
                     tileX /= tileWidth;
                     tileY /= tileHeight;
-                    final int tx = Math.toIntExact(tileX);
-                    final int ty = Math.toIntExact(tileY);
                     final int n  = si << 1;
-                    sourceTileIndices[n  ] = tx;
-                    sourceTileIndices[n+1] = ty;
-                    final Raster raster = source.getTile(tx, ty);
+                    sourceTileIndices[n  ] = tileX;
+                    sourceTileIndices[n+1] = tileY;
+                    final Raster raster = source.getTile(tileX, tileY);
                     final SampleModel c = raster.getSampleModel();
                     if (c instanceof ComponentSampleModel) {
                         final var sm = (ComponentSampleModel) c;
@@ -172,18 +169,16 @@ abstract class BandSharing {
      * Creates a raster sharing the arrays of given sources when possible.
      * This method assumes a target {@link BandedSampleModel} where all band offsets.
      *
-     * @param  x        <var>x</var> pixel coordinate of the tile.
-     * @param  y        <var>y</var> pixel coordinate of the tile.
-     * @param  sources  the sources for which to aggregate all bands.
-     * @return a raster containing the aggregation of all bands, or {@code null} if the is nothing to share.
+     * @param  location  smallest (<var>x</var>,<var>y</var>) pixel coordinates of the tile.
+     * @param  sources   the sources for which to aggregate all bands.
+     * @return a raster  containing the aggregation of all bands, or {@code null} if there is nothing to share.
      */
-    final BandSharedRaster createRaster(final long x, final long y, final RenderedImage[] sources) {
-        final int size = prepare(x, y, sources);
+    final BandSharedRaster createRaster(final Point location, final RenderedImage[] sources) {
+        final int size = prepare(location, sources);
         if (size == 0) {
             return null;
         }
         final DataBuffer buffer = allocate(size);
-        final var location = new Point(Math.toIntExact(x), Math.toIntExact(y));
         return new BandSharedRaster(sourceTileIndices, parents, target, buffer, location);
     }
 
diff --git a/core/sis-feature/src/main/java/org/apache/sis/image/ImageProcessor.java b/core/sis-feature/src/main/java/org/apache/sis/image/ImageProcessor.java
index fab9c30fe6..56238cb8a2 100644
--- a/core/sis-feature/src/main/java/org/apache/sis/image/ImageProcessor.java
+++ b/core/sis-feature/src/main/java/org/apache/sis/image/ImageProcessor.java
@@ -976,6 +976,7 @@ public class ImageProcessor implements Cloneable {
      * This operation uses the following properties in addition to method parameters:
      * <ul>
      *   <li>{@linkplain #getColorizer() Colorizer}.</li>
+     *   <li>{@linkplain #getExecutionMode() Execution mode} (parallel or sequential).</li>
      * </ul>
      *
      * @param  sources  images whose bands shall be aggregated, in order. At least one image must be provided.
@@ -989,10 +990,12 @@ public class ImageProcessor implements Cloneable {
     public RenderedImage aggregateBands(final RenderedImage[] sources, final int[][] bandsPerSource) {
         ArgumentChecks.ensureNonEmpty("sources", sources);
         final Colorizer colorizer;
+        final boolean parallel;
         synchronized (this) {
             colorizer = this.colorizer;
+            parallel = executionMode != Mode.SEQUENTIAL;
         }
-        return unique(BandAggregateImage.create(sources, bandsPerSource, colorizer, true));
+        return unique(BandAggregateImage.create(sources, bandsPerSource, colorizer, true, parallel));
     }
 
     /**
@@ -1503,6 +1506,7 @@ public class ImageProcessor implements Cloneable {
      * @throws ImagingOpException if an error occurred during calculation.
      */
     public List<NavigableMap<Double,Shape>> isolines(final RenderedImage data, final double[][] levels, final MathTransform gridToCRS) {
+        ArgumentChecks.ensureNonNull("data", data);
         final boolean parallel;
         synchronized (this) {
             parallel = parallel(data);
diff --git a/core/sis-feature/src/main/java/org/apache/sis/image/MultiSourceImage.java b/core/sis-feature/src/main/java/org/apache/sis/image/MultiSourceImage.java
new file mode 100644
index 0000000000..ed67b5446c
--- /dev/null
+++ b/core/sis-feature/src/main/java/org/apache/sis/image/MultiSourceImage.java
@@ -0,0 +1,159 @@
+/*
+ * 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.image;
+
+import java.awt.Point;
+import java.util.Arrays;
+import java.util.Objects;
+import java.awt.Rectangle;
+import java.awt.image.ColorModel;
+import java.awt.image.RenderedImage;
+import java.awt.image.WritableRenderedImage;
+import org.apache.sis.internal.coverage.j2d.ImageUtilities;
+import org.apache.sis.util.Disposable;
+
+
+/**
+ * An image which is the result of a computation involving more than one source.
+ * All sources shall use the same pixel coordinate system. However the sources
+ * do not need to have the same bounds or use the same tile matrix.
+ *
+ * <p>This implementation is for images that are <em>potentially</em> writable.
+ * Whether the image is effectively writable depends on whether all sources are
+ * instances of {@link WritableRenderedImage}.</p>
+ *
+ * @author  Martin Desruisseaux (Geomatys)
+ * @version 1.4
+ * @since   1.4
+ */
+abstract class MultiSourceImage extends WritableComputedImage {
+    /**
+     * The source images, potentially with a preprocessing applied.
+     * Those sources may be different than {@link #getSources()} for example with the
+     * application of a "band select" operation for retaining only the bands needed.
+     */
+    protected final RenderedImage[] filteredSources;
+
+    /**
+     * Color model of this image.
+     *
+     * @see #getColorModel()
+     */
+    protected final ColorModel colorModel;
+
+    /**
+     * Domain of pixel coordinates. All images shall share the same pixel coordinate space,
+     * meaning that a pixel at coordinates (<var>x</var>, <var>y</var>) in this image will
+     * contain the sample values of all source images at the same coordinates.
+     * It does <em>not</em> mean that all source images shall have the same bounds.
+     */
+    private final int minX, minY, width, height;
+
+    /**
+     * Index of the first tile. Contrarily to pixel coordinates,
+     * the tile coordinate space does not need to be the same for all images.
+     */
+    private final int minTileX, minTileY;
+
+    /**
+     * Whether parallel computation is allowed.
+     */
+    private final boolean parallel;
+
+    /**
+     * Creates a new multi-sources image.
+     *
+     * @param  layout     pixel and tile coordinate spaces of this image, together with sample model.
+     * @param  colorizer  provider of color model to use for this image, or {@code null} for automatic.
+     * @param  parallel   whether parallel computation is allowed.
+     */
+    MultiSourceImage(final MultiSourceLayout layout, final Colorizer colorizer, final boolean parallel) {
+        super(layout.sampleModel, layout.sources);
+        final Rectangle r = layout.domain;
+        minX            = r.x;
+        minY            = r.y;
+        width           = r.width;
+        height          = r.height;
+        minTileX        = layout.minTileX;
+        minTileY        = layout.minTileY;
+        filteredSources = layout.filteredSources;
+        colorModel      = layout.createColorModel(colorizer);
+        ensureCompatible(colorModel);
+        this.parallel = parallel;
+    }
+
+    /** Returns the information inferred at construction time. */
+    @Override public final ColorModel getColorModel() {return colorModel;}
+    @Override public final int        getWidth()      {return width;}
+    @Override public final int        getHeight()     {return height;}
+    @Override public final int        getMinX()       {return minX;}
+    @Override public final int        getMinY()       {return minY;}
+    @Override public final int        getMinTileX()   {return minTileX;}
+    @Override public final int        getMinTileY()   {return minTileY;}
+
+    /**
+     * Converts a tile (column, row) indices to smallest (<var>x</var>, <var>y</var>) pixel coordinates
+     * inside the tile. The returned value is a coordinate of the pixel in upper-left corner.
+     *
+     * @param  tileX  the tile index for which to get pixel coordinate.
+     * @param  tileY  the tile index for which to get pixel coordinate.
+     * @return smallest (<var>x</var>, <var>y</var>) pixel coordinates inside the tile.
+     */
+    final Point tileToPixel(final int tileX, final int tileY) {
+        return new Point(Math.toIntExact((((long) tileX) - minTileX) * getTileWidth()  + minX),
+                         Math.toIntExact((((long) tileY) - minTileY) * getTileHeight() + minY));
+    }
+
+    /**
+     * Notifies the source images that tiles will be computed soon in the given region.
+     * This method forwards the notification to all images that are instances of {@link PlanarImage}.
+     */
+    @Override
+    protected Disposable prefetch(final Rectangle tiles) {
+        /*
+         * Convert tile indices to pixel indices. The latter will be converted back to
+         * tile indices for each source because the tile numbering may not be the same.
+         */
+        final Rectangle aoi = ImageUtilities.tilesToPixels(this, tiles);
+        return new MultiSourcePrefetch(filteredSources, aoi).run(parallel);
+    }
+
+    /**
+     * Returns a hash code value for this image.
+     */
+    @Override
+    public int hashCode() {
+        return hashCodeBase() + 37 * (Arrays.hashCode(filteredSources) + 31 * Objects.hashCode(colorModel));
+    }
+
+    /**
+     * Compares the given object with this image for equality.
+     */
+    @Override
+    public boolean equals(final Object object) {
+        if (equalsBase(object)) {
+            final MultiSourceImage other = (MultiSourceImage) object;
+            return parallel == other.parallel &&
+                   minTileX == other.minTileX &&
+                   minTileY == other.minTileY &&
+                   getBounds().equals(other.getBounds()) &&
+                   Objects.equals(colorModel, other.colorModel) &&
+                   Arrays.equals(filteredSources, other.filteredSources);
+        }
+        return false;
+    }
+}
diff --git a/core/sis-feature/src/main/java/org/apache/sis/image/CombinedImageLayout.java b/core/sis-feature/src/main/java/org/apache/sis/image/MultiSourceLayout.java
similarity index 97%
rename from core/sis-feature/src/main/java/org/apache/sis/image/CombinedImageLayout.java
rename to core/sis-feature/src/main/java/org/apache/sis/image/MultiSourceLayout.java
index 7f283984fe..afa30b76e5 100644
--- a/core/sis-feature/src/main/java/org/apache/sis/image/CombinedImageLayout.java
+++ b/core/sis-feature/src/main/java/org/apache/sis/image/MultiSourceLayout.java
@@ -33,7 +33,7 @@ import org.apache.sis.internal.feature.Resources;
 import org.apache.sis.internal.coverage.j2d.ImageLayout;
 import org.apache.sis.internal.coverage.j2d.ImageUtilities;
 import org.apache.sis.internal.coverage.j2d.ColorModelFactory;
-import org.apache.sis.internal.coverage.MultiSourcesArgument;
+import org.apache.sis.internal.coverage.MultiSourceArgument;
 import org.apache.sis.coverage.grid.DisjointExtentException;
 
 
@@ -54,7 +54,7 @@ import org.apache.sis.coverage.grid.DisjointExtentException;
  *
  * @since 1.4
  */
-final class CombinedImageLayout extends ImageLayout {
+final class MultiSourceLayout extends ImageLayout {
     /**
      * The source images. This is a copy of the user-specified array,
      * except that images associated to an empty set of bands are discarded.
@@ -126,8 +126,8 @@ final class CombinedImageLayout extends ImageLayout {
      *         or if some band indices are duplicated or outside their range of validity.
      */
     @Workaround(library="JDK", version="1.8")
-    static CombinedImageLayout create(RenderedImage[] sources, int[][] bandsPerSource, boolean allowSharing) {
-        final var aggregate = new MultiSourcesArgument<RenderedImage>(sources, bandsPerSource);
+    static MultiSourceLayout create(RenderedImage[] sources, int[][] bandsPerSource, boolean allowSharing) {
+        final var aggregate = new MultiSourceArgument<RenderedImage>(sources, bandsPerSource);
         aggregate.identityAsNull();
         aggregate.validate(ImageUtilities::getNumBands);
 
@@ -202,7 +202,7 @@ final class CombinedImageLayout extends ImageLayout {
         final var preferredTileSize = new Dimension((int) cx, (int) cy);
         final boolean exactTileSize = ((cx | cy) >>> Integer.SIZE) == 0;
         allowSharing &= exactTileSize;
-        return new CombinedImageLayout(sources, bandsPerSource, domain, preferredTileSize, exactTileSize,
+        return new MultiSourceLayout(sources, bandsPerSource, domain, preferredTileSize, exactTileSize,
                 chooseMinTile(tileGridXOffset, domain.x, preferredTileSize.width),
                 chooseMinTile(tileGridYOffset, domain.y, preferredTileSize.height),
                 commonDataType, aggregate.numBands(), allowSharing ? scanlineStride : 0);
@@ -219,7 +219,7 @@ final class CombinedImageLayout extends ImageLayout {
      * @param  scanlineStride     common scanline stride if data buffers will be shared, or 0 if no sharing.
      * @param  numBands           number of bands of the image to create.
      */
-    private CombinedImageLayout(final RenderedImage[] sources, final int[][] bandsPerSource,
+    private MultiSourceLayout(final RenderedImage[] sources, final int[][] bandsPerSource,
             final Rectangle domain, final Dimension preferredTileSize, final boolean exactTileSize,
             final int minTileX, final int minTileY, final int commonDataType, final int numBands,
             final int scanlineStride)
diff --git a/core/sis-feature/src/main/java/org/apache/sis/image/MultiSourcePrefetch.java b/core/sis-feature/src/main/java/org/apache/sis/image/MultiSourcePrefetch.java
new file mode 100644
index 0000000000..8873f58c24
--- /dev/null
+++ b/core/sis-feature/src/main/java/org/apache/sis/image/MultiSourcePrefetch.java
@@ -0,0 +1,178 @@
+/*
+ * 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.image;
+
+import java.util.concurrent.Future;
+import java.util.concurrent.Callable;
+import java.awt.Rectangle;
+import java.awt.image.RenderedImage;
+import java.awt.image.ImagingOpException;
+import org.apache.sis.util.Disposable;
+import org.apache.sis.internal.system.CommonExecutor;
+import org.apache.sis.internal.coverage.j2d.ImageUtilities;
+
+
+/**
+ * A helper class for forwarding a {@code prefetch(…)} operation to multiple sources.
+ * This implementation assumes that all sources share the same pixel coordinates space.
+ * However the tile matrix does not need to be the same.
+ *
+ * @author  Martin Desruisseaux (Geomatys)
+ * @version 1.4
+ * @since   1.4
+ */
+final class MultiSourcePrefetch implements Disposable {
+    /**
+     * A filtered list of images on which to prefetch at least one tile.
+     * This array contains only {@link PlanarImage} instances which intersect the area of interest.
+     */
+    private final PlanarImage[] sources;
+
+    /**
+     * Indices of tiles to prefetch for each image in the {@link #sources} array.
+     */
+    private final Rectangle[] tileIndices;
+
+    /**
+     * Number of valid elements in {@link #sources} and {@link #tileIndices} arrays.
+     * Note that it will not be worth to use parallelism if the count is less than two.
+     */
+    private int count;
+
+    /**
+     * Handlers to invoke for releasing resources after the prefetch operation is completed.
+     */
+    private Disposable[] cleaners;
+
+    /**
+     * Number of valid elements in the {@link #cleaners} array.
+     */
+    private int cleanerCount;
+
+    /**
+     * If an error occurred while invoking {@code prefetch(…)} or {@code dispose()} on a source, that error.
+     * An error during disposal will not prevent other handlers to be disposed as well. If errors also occur
+     * during the disposal of other handlers, the other exceptions are added as suppressed exceptions.
+     */
+    private RuntimeException error;
+
+    /**
+     * Prepares (without launching) a prefetch operation using the given source images.
+     *
+     * @param  images  sources on which to apply a prefetch operation.
+     * @param  aoi     pixel coordinates of the region to prefetch.
+     */
+    MultiSourcePrefetch(final RenderedImage[] images, final Rectangle aoi) {
+        sources = new PlanarImage[images.length];
+        tileIndices = new Rectangle[images.length];
+        for (final RenderedImage source : images) {
+            if (source instanceof PlanarImage) {
+                Rectangle r = new Rectangle(aoi);
+                ImageUtilities.clipBounds(source, r);
+                r = ImageUtilities.pixelsToTiles(source, r);
+                if (!r.isEmpty()) {
+                    tileIndices[count] = r;
+                    sources[count++] = (PlanarImage) source;
+                }
+            }
+        }
+    }
+
+    /**
+     * Forwards the prefetchs calls to source images.
+     *
+     * <h4>Implementation note</h4>
+     * In many cases the background threads are not really necessary because {@code prefetch(…)} will
+     * only forward to another {@code prefetch(…)} until we reach a final {@code prefetch(…)} which
+     * happen to be a no-op. But it some cases, that final {@code prefetch(…)} will read tiles from
+     * a TIFF or netCDF file (for example).
+     *
+     * @param  parallel  whether parallelism is allowed.
+     * @return a handler for disposing resources after prefetch, or {@code null} if none.
+     */
+    final Disposable run(boolean parallel) {
+        switch (count) {
+            case 0: return null;
+            case 1: parallel = false;
+        }
+        @SuppressWarnings({"unchecked","rawtypes"})
+        final var workers = (Future<Disposable>[]) (parallel ? new Future[count] : null);
+        cleaners = new Disposable[count];
+        for (int i=0; i<count; i++) {
+            final PlanarImage source = sources[i];
+            final Rectangle r = tileIndices[i];
+            if (parallel) {
+                Callable<Disposable> worker = () -> source.prefetch(r);
+                workers[i] = CommonExecutor.instance().submit(worker);
+            } else {
+                final Disposable cleaner = source.prefetch(r);
+                if (cleaner != null) {
+                    cleaners[cleanerCount++] = cleaner;
+                }
+            }
+        }
+        /*
+         * Block until all background threads finished their work. This is needed because `PrefetchedImage`
+         * will start to query tiles after this method returned, so the source images need to be ready.
+         */
+        if (parallel) {
+            for (final Future<Disposable> worker : workers) try {
+                final Disposable cleaner = worker.get();
+                if (cleaner != null) {
+                    cleaners[cleanerCount++] = cleaner;
+                }
+            } catch (Exception e) {
+                addError(e);
+                dispose();      // Will rethrow the exception after disposal.
+            }
+        }
+        switch (cleanerCount) {
+            case 0:  return null;
+            case 1:  return cleaners[0];
+            default: return this;
+        }
+    }
+
+    /**
+     * Disposes the handlers of all sources.
+     */
+    @Override
+    public void dispose() {
+        for (int i=0; i<cleanerCount; i++) try {
+            cleaners[i].dispose();
+        } catch (Exception e) {
+            addError(e);
+        }
+        if (error != null) {
+            throw error;
+        }
+    }
+
+    /**
+     * Declares that an exception occurred. The exception will be thrown after all handlers have been disposed.
+     * If more than one exception occurs, all additional errors are added as suppressed exceptions.
+     */
+    private void addError(final Exception e) {
+        if (error != null) {
+            error.addSuppressed(e);
+        } else if (e instanceof RuntimeException) {
+            error = (RuntimeException) e;
+        } else {
+            error = (ImagingOpException) new ImagingOpException(e.getMessage()).initCause(e);
+        }
+    }
+}
diff --git a/core/sis-feature/src/main/java/org/apache/sis/internal/coverage/MultiSourcesArgument.java b/core/sis-feature/src/main/java/org/apache/sis/internal/coverage/MultiSourceArgument.java
similarity index 99%
rename from core/sis-feature/src/main/java/org/apache/sis/internal/coverage/MultiSourcesArgument.java
rename to core/sis-feature/src/main/java/org/apache/sis/internal/coverage/MultiSourceArgument.java
index 5978d0a5c3..27d82af545 100644
--- a/core/sis-feature/src/main/java/org/apache/sis/internal/coverage/MultiSourcesArgument.java
+++ b/core/sis-feature/src/main/java/org/apache/sis/internal/coverage/MultiSourceArgument.java
@@ -50,7 +50,7 @@ import org.apache.sis.util.ComparisonMode;
  *
  * @since 1.4
  */
-public final class MultiSourcesArgument<S> {
+public final class MultiSourceArgument<S> {
     /**
      * The sources of sample dimensions with empty sources removed.
      * After a {@code validate(…)} method has been invoked, this array become a
@@ -101,7 +101,7 @@ public final class MultiSourcesArgument<S> {
      * @param  sources         the sources from which to get the sample dimensions.
      * @param  bandsPerSource  sample dimensions for each source. May contain {@code null} elements.
      */
-    public MultiSourcesArgument(final S[] sources, final int[][] bandsPerSource) {
+    public MultiSourceArgument(final S[] sources, final int[][] bandsPerSource) {
         this.sources = sources;
         this.bandsPerSource = bandsPerSource;
         sourceOfGridToCRS = -1;
diff --git a/core/sis-feature/src/test/java/org/apache/sis/image/BandAggregateImageTest.java b/core/sis-feature/src/test/java/org/apache/sis/image/BandAggregateImageTest.java
index d3ec94a516..ea7641b54d 100644
--- a/core/sis-feature/src/test/java/org/apache/sis/image/BandAggregateImageTest.java
+++ b/core/sis-feature/src/test/java/org/apache/sis/image/BandAggregateImageTest.java
@@ -95,7 +95,7 @@ public final class BandAggregateImageTest extends TestCase {
         im2.getRaster().setSamples(0, 0, width, height, 0, IntStream.range(0, width*height).map(s -> s * 2).toArray());
         sourceImages = new RenderedImage[] {im1, im2};
 
-        final RenderedImage result = BandAggregateImage.create(sourceImages, null, null, allowSharing);
+        final RenderedImage result = BandAggregateImage.create(sourceImages, null, null, allowSharing, false);
         assertNotNull(result);
         assertEquals(0, result.getMinTileX());
         assertEquals(0, result.getMinTileY());
@@ -188,7 +188,7 @@ public final class BandAggregateImageTest extends TestCase {
         initializeAllTiles(im1, im2);
         sourceImages = new RenderedImage[] {im1, im2};
 
-        RenderedImage result = BandAggregateImage.create(sourceImages, null, null, allowSharing);
+        RenderedImage result = BandAggregateImage.create(sourceImages, null, null, allowSharing, false);
         assertNotNull(result);
         assertEquals(minX,   result.getMinX());
         assertEquals(minY,   result.getMinY());
@@ -255,7 +255,7 @@ public final class BandAggregateImageTest extends TestCase {
             new int[] {1},      // Take second band of image 1.
             null,               // Take all bands of image 2.
             new int[] {0}       // Take first band of image 1.
-        }, null, allowSharing);
+        }, null, allowSharing, false);
         assertNotNull(result);
         assertEquals(minX,   result.getMinX());
         assertEquals(minY,   result.getMinY());
@@ -316,7 +316,7 @@ public final class BandAggregateImageTest extends TestCase {
         initializeAllTiles(tiled2x2, tiled4x1, oneTile);
         sourceImages = new RenderedImage[] {tiled2x2, tiled4x1, oneTile};
 
-        final RenderedImage result = BandAggregateImage.create(sourceImages, null, null, allowSharing);
+        final RenderedImage result = BandAggregateImage.create(sourceImages, null, null, allowSharing, false);
         assertNotNull(result);
         assertEquals(minX,   result.getMinX());
         assertEquals(minY,   result.getMinY());
@@ -350,7 +350,25 @@ public final class BandAggregateImageTest extends TestCase {
      */
     @Test
     @DependsOnMethod("testImagesUsingSameExtentButDifferentTileSizes")
-    public void testImagesUsingDifferentExtentsAndDifferentSquaredTiling() {
+    public void testImagesUsingDifferentExtentsAndDifferentTiling() {
+        testHeterogeneous(false);
+    }
+
+    /**
+     * Tests {@link BandAggregateImage#prefetch(Rectangle)}.
+     */
+    @Test
+    @DependsOnMethod("testImagesUsingDifferentExtentsAndDifferentTiling")
+    public void testPrefetch() {
+        testHeterogeneous(true);
+    }
+
+    /**
+     * Implementation of test methods using tiles of different extents and different tile matrices.
+     *
+     * @param  prefetch  whether to test prefetch operation.
+     */
+    private void testHeterogeneous(final boolean prefetch) {
         /*
          * Tip: band number match image tile width. i.e:
          *
@@ -366,7 +384,7 @@ public final class BandAggregateImageTest extends TestCase {
         initializeAllTiles(untiled, tiled2x2, tiled4x4, tiled6x6);
         sourceImages = new RenderedImage[] {untiled, tiled2x2, tiled4x4, tiled6x6};
 
-        final RenderedImage result = BandAggregateImage.create(sourceImages, null, null, allowSharing);
+        RenderedImage result = BandAggregateImage.create(sourceImages, null, null, allowSharing, prefetch);
         assertNotNull(result);
         assertEquals(4, result.getMinX());
         assertEquals(2, result.getMinY());
@@ -380,6 +398,9 @@ public final class BandAggregateImageTest extends TestCase {
         assertEquals(2, result.getNumYTiles());
         assertEquals(6, result.getSampleModel().getNumBands());
 
+        if (prefetch) {
+            result = new ImageProcessor().prefetch(result, new Rectangle(4, 2, 8, 4));
+        }
         final Raster raster = result.getData();
         assertEquals(new Rectangle(4, 2, 8, 4), raster.getBounds());
         assertArrayEquals(
@@ -392,7 +413,9 @@ public final class BandAggregateImageTest extends TestCase {
             },
             raster.getPixels(4, 2, 8, 4, (int[]) null)
         );
-        verifySharing(result, false, allowSharing, true, false, false, false);
+        if (!prefetch) {
+            verifySharing(result, false, allowSharing, true, false, false, false);
+        }
     }
 
     /**
diff --git a/storage/sis-storage/src/main/java/org/apache/sis/storage/aggregate/BandAggregateGridResource.java b/storage/sis-storage/src/main/java/org/apache/sis/storage/aggregate/BandAggregateGridResource.java
index adb90fecb4..52e0f8b141 100644
--- a/storage/sis-storage/src/main/java/org/apache/sis/storage/aggregate/BandAggregateGridResource.java
+++ b/storage/sis-storage/src/main/java/org/apache/sis/storage/aggregate/BandAggregateGridResource.java
@@ -26,7 +26,7 @@ import org.apache.sis.coverage.grid.GridCoverage;
 import org.apache.sis.coverage.grid.GridGeometry;
 import org.apache.sis.coverage.grid.GridCoverageProcessor;
 import org.apache.sis.coverage.grid.IllegalGridGeometryException;
-import org.apache.sis.internal.coverage.MultiSourcesArgument;
+import org.apache.sis.internal.coverage.MultiSourceArgument;
 import org.apache.sis.internal.coverage.RangeArgument;
 import org.apache.sis.storage.Resource;
 import org.apache.sis.storage.GridCoverageResource;
@@ -163,7 +163,7 @@ public class BandAggregateGridResource extends AbstractGridCoverageResource {
     {
         super(parent);
         try {
-            final var aggregate = new MultiSourcesArgument<GridCoverageResource>(sources, bandsPerSource);
+            final var aggregate = new MultiSourceArgument<GridCoverageResource>(sources, bandsPerSource);
             aggregate.validate(BandAggregateGridResource::range);
             this.name             = name;
             this.sources          = aggregate.sources();