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/11 18:03:10 UTC

[sis] branch geoapi-4.0 updated: Improvement: `BandAggregateImage` now merges the bands of repeated sources no matter their position in the array of sources. Before this commit, the bands of repeated sources where merged only for consecutive sources (e.g. at index `i` and `i+1`). The merging of repeated sources is necessary for `BandAggregateGridResource` implementation, which relies on that. While the merging of consecutive sources was sufficient in most cases, it was a risk of causing confusing behavior if not generaliz [...]

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 e9e0b2342b Improvement: `BandAggregateImage` now merges the bands of repeated sources no matter their position in the array of sources. Before this commit, the bands of repeated sources where merged only for consecutive sources (e.g. at index `i` and `i+1`). The merging of repeated sources is necessary for `BandAggregateGridResource` implementation, which relies on that. While the merging of consecutive sources was sufficient in most cases, it was a risk of causing confusing beha [...]
e9e0b2342b is described below

commit e9e0b2342b52686ae433ad0eb0c577b599b5964c
Author: Martin Desruisseaux <ma...@geomatys.com>
AuthorDate: Tue Apr 11 19:55:06 2023 +0200

    Improvement: `BandAggregateImage` now merges the bands of repeated sources no matter their position in the array of sources.
    Before this commit, the bands of repeated sources where merged only for consecutive sources (e.g. at index `i` and `i+1`).
    The merging of repeated sources is necessary for `BandAggregateGridResource` implementation, which relies on that.
    While the merging of consecutive sources was sufficient in most cases, it was a risk of causing confusing behavior
    if not generalized for consistency at all positions in the `sources` array.
---
 .../org/apache/sis/gui/map/ValuesFormatter.java    |   3 +-
 .../coverage/grid/BandAggregateGridCoverage.java   |   2 +-
 .../sis/coverage/grid/GridCoverageProcessor.java   |  37 ++-
 .../org/apache/sis/image/BandAggregateImage.java   |  21 +-
 .../java/org/apache/sis/image/BandSelectImage.java |   5 +-
 .../java/org/apache/sis/image/ImageProcessor.java  |  51 ++--
 .../org/apache/sis/image/MultiSourceLayout.java    |  17 +-
 .../java/org/apache/sis/image/Visualization.java   |   2 +-
 .../sis/internal/coverage/MultiSourceArgument.java | 318 ++++++++++++++-------
 .../sis/internal/coverage/RangeArgument.java       |   7 +-
 .../apache/sis/image/BandAggregateImageTest.java   |   7 +-
 .../org/apache/sis/internal/util/Numerics.java     |  13 +
 .../main/java/org/apache/sis/util/ArraysExt.java   | 136 +++++----
 .../java/org/apache/sis/util/ArraysExtTest.java    |  32 ++-
 .../aggregate/BandAggregateGridResource.java       |   5 +-
 15 files changed, 427 insertions(+), 229 deletions(-)

diff --git a/application/sis-javafx/src/main/java/org/apache/sis/gui/map/ValuesFormatter.java b/application/sis-javafx/src/main/java/org/apache/sis/gui/map/ValuesFormatter.java
index 5f28a52a4f..f2c4cb01ef 100644
--- a/application/sis-javafx/src/main/java/org/apache/sis/gui/map/ValuesFormatter.java
+++ b/application/sis-javafx/src/main/java/org/apache/sis/gui/map/ValuesFormatter.java
@@ -42,6 +42,7 @@ import org.apache.sis.measure.NumberRange;
 import org.apache.sis.measure.UnitFormat;
 import org.apache.sis.util.Characters;
 import org.apache.sis.util.logging.Logging;
+import org.apache.sis.internal.util.Numerics;
 
 import static org.apache.sis.internal.gui.LogHandler.LOGGER;
 
@@ -473,7 +474,7 @@ final class ValuesFormatter extends ValuesUnderCursor.Formatter {
      *         or does not use a supported bits pattern.
      */
     private static Long toNodataKey(final int band, final float value) {
-        return (((long) MathFunctions.toNanOrdinal(value)) << Integer.SIZE) | band;
+        return Numerics.tuple(MathFunctions.toNanOrdinal(value), band);
     }
 
     /**
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 113033a9fb..9e671073a9 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
@@ -92,7 +92,7 @@ final class BandAggregateGridCoverage extends GridCoverage {
     BandAggregateGridCoverage(final MultiSourceArgument<GridCoverage> aggregate, final ImageProcessor processor) {
         super(aggregate.domain(GridCoverage::getGridGeometry), aggregate.ranges());
         this.sources           = aggregate.sources();
-        this.bandsPerSource    = aggregate.bandsPerSource();
+        this.bandsPerSource    = aggregate.bandsPerSource(true);
         this.numBands          = aggregate.numBands();
         this.sourceOfGridToCRS = aggregate.sourceOfGridToCRS();
         this.processor         = processor;
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 f7f9017fc9..b56fd29e0f 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
@@ -714,17 +714,8 @@ public class GridCoverageProcessor implements Cloneable {
      * The {@linkplain GridCoverage#getSampleDimensions() list of sample dimensions} of
      * the aggregated coverage will be the concatenation of the lists of all sources.
      *
-     * <h4>Restrictions</h4>
-     * All coverages shall have compatible domain, defined as below:
-     *
-     * <ul>
-     *   <li>Same CRS.</li>
-     *   <li>Same <cite>grid to CRS</cite> transform except for translation terms.</li>
-     *   <li>Translation terms that differ only by an integer amount of grid cells.</li>
-     * </ul>
-     *
-     * The intersection of the domain of all coverages shall be non-empty,
-     * and all coverages shall use the same data type in their rendered image.
+     * <p>This convenience method delegates to {@link #aggregateRanges(GridCoverage[], int[][])}.
+     * See that method for more information on restrictions.</p>
      *
      * @param  sources  coverages whose ranges shall be aggregated, in order. At least one coverage must be provided.
      * @return the aggregated coverage, or {@code sources[0]} returned directly if only one coverage was supplied.
@@ -742,16 +733,23 @@ public class GridCoverageProcessor implements Cloneable {
     /**
      * Aggregates in a single coverage the specified bands of a sequence of source coverages, in order.
      * This method performs the same work than {@link #aggregateRanges(GridCoverage...)},
-     * but with the possibility to specify the bands to retain in each source coverage.
-     * The {@code bandsPerSource} argument specifies the bands to select in each source coverage.
-     * That array can be {@code null} for selecting all bands in all source coverages,
-     * or may contain {@code null} elements for selecting all bands of the corresponding coverage.
-     * An empty array element (i.e. zero band to select) discards the corresponding source coverage.
-     * In the latter case, the discarded element in the {@code sources} array may be {@code null}.
+     * but with the possibility to specify the sample dimensions to retain in each source coverage.
+     * The {@code bandsPerSource} argument specifies the sample dimensions to keep, in order.
+     * That array can be {@code null} for selecting all sample dimensions in all source coverages,
+     * or may contain {@code null} elements for selecting all sample dimensions of the corresponding coverage.
+     * An empty array element (i.e. zero sample dimension to select) discards the corresponding source coverage.
+     *
+     * <h4>Restrictions</h4>
+     * <ul>
+     *   <li>All coverage shall use the same CRS.</li>
+     *   <li>All coverage shall use the same <cite>grid to CRS</cite> transform except for translation terms.</li>
+     *   <li>Translation terms in <cite>grid to CRS</cite> can differ only by an integer amount of grid cells.</li>
+     *   <li>The intersection of the domain of all coverages shall be non-empty.</li>
+     *   <li>All coverages shall use the same data type in their rendered image.</li>
+     * </ul>
      *
      * @param  sources  coverages whose bands shall be aggregated, in order. At least one coverage must be provided.
-     * @param  bandsPerSource  bands to use for each source coverage, in order.
-     *                  May be {@code null} or may contain {@code null} elements.
+     * @param  bandsPerSource  bands to use for each source coverage, in order. May contain {@code null} elements.
      * @return the aggregated coverage, or one of the sources if it can be used directly.
      * @throws IllegalGridGeometryException if a grid geometry is not compatible with the others.
      * @throws IllegalArgumentException if some band indices are duplicated or outside their range of validity.
@@ -762,7 +760,6 @@ public class GridCoverageProcessor implements Cloneable {
      */
     public GridCoverage aggregateRanges(GridCoverage[] sources, int[][] bandsPerSource) {
         final var aggregate = new MultiSourceArgument<>(sources, bandsPerSource);
-        aggregate.identityAsNull();
         aggregate.validate(GridCoverage::getSampleDimensions);
         if (aggregate.isIdentity()) {
             return aggregate.sources()[0];
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 255176dcc8..9583193eae 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
@@ -116,6 +116,10 @@ class BandAggregateImage extends MultiSourceImage {
             sources[i] = source;
             bandsPerSource[i] = new int[] {band - lower};
         }
+        /*
+         * Tne same image may be repeated many times in the `sources` array, each time with only one band specified.
+         * But we rely on `create(…)` post-processing for merging multiple references to a single one for each image.
+         */
         if (unwrapper != null) {
             unwrapper.apply(sources, bandsPerSource);
             return null;
@@ -146,14 +150,23 @@ class BandAggregateImage extends MultiSourceImage {
         } else {
             image = new BandAggregateImage(layout, colorizer, allowSharing, parallel);
         }
+        RenderedImage result = image;
         if (image.getNumSources() == 1) {
-            RenderedImage source = image.getSource();
+            result = image.getSource();
             if ((forceColors && colorizer != null)) {
-                source = RecoloredImage.applySameColors(source, image);
+                result = RecoloredImage.applySameColors(result, image);
             }
-            return source;
+        } else {
+            result = ImageProcessor.unique(result);
+        }
+        /*
+         * If we need to use `BandSelectImage` for reordering bands, the `unwrap` argument
+         * MUST be false for avoiding `StackOverflowError` with never-ending recusivity.
+         */
+        if (layout.bandSelect != null) {
+            result = BandSelectImage.create(result, false, layout.bandSelect);
         }
-        return ImageProcessor.unique(image);
+        return result;
     }
 
     /**
diff --git a/core/sis-feature/src/main/java/org/apache/sis/image/BandSelectImage.java b/core/sis-feature/src/main/java/org/apache/sis/image/BandSelectImage.java
index 2b126c5d28..a5aeaff2cd 100644
--- a/core/sis-feature/src/main/java/org/apache/sis/image/BandSelectImage.java
+++ b/core/sis-feature/src/main/java/org/apache/sis/image/BandSelectImage.java
@@ -105,10 +105,11 @@ class BandSelectImage extends SourceAlignedImage {
      * Creates a new "band select" operation for the given source.
      *
      * @param  source  the image in which to select bands.
+     * @param  unwrap  whether to allow unwrapping of {@link BandAggregateImage} source.
      * @param  bands   the bands to select. Not cloned in order to share common arrays when possible.
      *                 If that array instance was user supplied, then it should be cloned by caller.
      */
-    static RenderedImage create(RenderedImage source, int... bands) {
+    static RenderedImage create(RenderedImage source, final boolean unwrap, int... bands) {
         final int numBands = ImageUtilities.getNumBands(source);
         if (bands.length == numBands && ArraysExt.isRange(0, bands)) {
             return source;
@@ -128,7 +129,7 @@ class BandSelectImage extends SourceAlignedImage {
             bands  = select.getSourceBands(bands);
             source = select.getSource();
         }
-        if (source instanceof BandAggregateImage) {
+        if (unwrap && source instanceof BandAggregateImage) {
             return ((BandAggregateImage) source).subset(bands, cm, null);
         }
         /*
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 a6149ea3f0..57741fd29c 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
@@ -916,7 +916,7 @@ public class ImageProcessor implements Cloneable {
      */
     public RenderedImage selectBands(final RenderedImage source, final int... bands) {
         ArgumentChecks.ensureNonNull("source", source);
-        return BandSelectImage.create(source, bands.clone());
+        return BandSelectImage.create(source, true, bands.clone());
     }
 
     /**
@@ -926,26 +926,9 @@ public class ImageProcessor implements Cloneable {
      * contain values from the pixels at the same coordinates in all source images.
      * The result image will be bounded by the intersection of all source images.
      *
-     * <p>If all source images are {@link WritableRenderedImage} instances,
-     * then the returned image will also be a {@link WritableRenderedImage}.
-     * In such case values written in the returned image will be copied back
-     * to the source images.</p>
-     *
-     * <h4>Restrictions</h4>
-     * All images shall use the same {@linkplain SampleModel#getDataType() data type},
-     * and all source images shall intersect each other with a non-empty intersection area.
-     * However it is not required that all images have the same bounds or the same tiling scheme.
-     *
-     * <h4>Memory saving</h4>
-     * The returned image may opportunistically share the underlying data arrays of
-     * some source images. Bands are really copied only when sharing is not possible.
-     * The actual strategy may be a mix of both arrays sharing and bands copies.
-     *
-     * <h4>Properties used</h4>
-     * This operation uses the following properties in addition to method parameters:
-     * <ul>
-     *   <li>{@linkplain #getColorizer() Colorizer}.</li>
-     * </ul>
+     * <p>This convenience method delegates to {@link #aggregateBands(RenderedImage[], int[][])}.
+     * See that method for more information on restrictions, writable images, memory saving and
+     * properties used.</p>
      *
      * @param  sources  images whose bands shall be aggregated, in order. At least one image must be provided.
      * @return the aggregated image, or {@code sources[0]} returned directly if only one image was supplied.
@@ -969,17 +952,27 @@ public class ImageProcessor implements Cloneable {
      * An empty array element (i.e. zero band to select) discards the corresponding source image.
      * In the latter case, the discarded element in the {@code sources} array may be {@code null}.
      *
-     * <p>If all source images are {@link WritableRenderedImage} instances,
+     * <h4>Restrictions</h4>
+     * All images shall use the same {@linkplain SampleModel#getDataType() data type},
+     * and all source images shall intersect each other with a non-empty intersection area.
+     * However it is not required that all images have the same bounds or the same tiling scheme.
+     *
+     * <h4>Writable image</h4>
+     * If all source images are {@link WritableRenderedImage} instances,
      * then the returned image will also be a {@link WritableRenderedImage}.
      * In such case values written in the returned image will be copied back
-     * to the source images.</p>
+     * to the source images.
      *
-     * <h4>Restrictions</h4>
-     * <ul>
-     *   <li>All images shall use the same {@linkplain SampleModel#getDataType() data type}.</li>
-     *   <li>All source images shall intersect each other with a non-empty intersection area.</li>
-     *   <li>The same band for a given source image cannot be used twice.</li>
-     * </ul>
+     * <h4>Memory saving</h4>
+     * The returned image may opportunistically share the underlying data arrays of
+     * some source images. Bands are really copied only when sharing is not possible.
+     * The actual strategy may be a mix of both arrays sharing and bands copies.
+     *
+     * <h4>Repeated bands</h4>
+     * For any value of <var>i</var>, the array at {@code bandsPerSource[i]} shall not contain duplicated values.
+     * This restriction is for capturing common errors, in order to reduce the risk of accidental band repetition.
+     * However the same band can be repeated indirectly if the same image is repeated at different values of <var>i</var>.
+     * But even when a source band is referenced many times, all occurrences still share pixel data copied at most once.
      *
      * <h4>Properties used</h4>
      * This operation uses the following properties in addition to method parameters:
diff --git a/core/sis-feature/src/main/java/org/apache/sis/image/MultiSourceLayout.java b/core/sis-feature/src/main/java/org/apache/sis/image/MultiSourceLayout.java
index 415915c93e..42c005dcc1 100644
--- a/core/sis-feature/src/main/java/org/apache/sis/image/MultiSourceLayout.java
+++ b/core/sis-feature/src/main/java/org/apache/sis/image/MultiSourceLayout.java
@@ -75,6 +75,11 @@ final class MultiSourceLayout extends ImageLayout {
      */
     private final int[][] bandsPerSource;
 
+    /**
+     * Final band select operation to apply on the aggregated result, or {@code null} if none.
+     */
+    final int[] bandSelect;
+
     /**
      * The sample model of the combined image.
      * All {@linkplain BandedSampleModel#getBandOffsets() band offsets} are zeros and
@@ -128,12 +133,12 @@ final class MultiSourceLayout extends ImageLayout {
     @Workaround(library="JDK", version="1.8")
     static MultiSourceLayout create(RenderedImage[] sources, int[][] bandsPerSource, boolean allowSharing) {
         final var aggregate = new MultiSourceArgument<RenderedImage>(sources, bandsPerSource);
-        aggregate.identityAsNull();
         aggregate.unwrap(BandAggregateImage::unwrap);
         aggregate.validate(ImageUtilities::getNumBands);
 
+        int[] bandSelect   = aggregate.mergeDuplicatedSources();
         sources            = aggregate.sources();
-        bandsPerSource     = aggregate.bandsPerSource();
+        bandsPerSource     = aggregate.bandsPerSource(true);
         Rectangle domain   = null;          // Nullity check used for telling when the first image is processed.
         int scanlineStride = 0;
         int tileWidth      = 0;
@@ -203,7 +208,7 @@ final class MultiSourceLayout extends ImageLayout {
         final var preferredTileSize = new Dimension((int) cx, (int) cy);
         final boolean exactTileSize = ((cx | cy) >>> Integer.SIZE) == 0;
         allowSharing &= exactTileSize;
-        return new MultiSourceLayout(sources, bandsPerSource, domain, preferredTileSize, exactTileSize,
+        return new MultiSourceLayout(sources, bandsPerSource, bandSelect, domain, preferredTileSize, exactTileSize,
                 chooseMinTile(tileGridXOffset, domain.x, preferredTileSize.width),
                 chooseMinTile(tileGridYOffset, domain.y, preferredTileSize.height),
                 commonDataType, aggregate.numBands(), allowSharing ? scanlineStride : 0);
@@ -214,13 +219,14 @@ final class MultiSourceLayout extends ImageLayout {
      *
      * @param  sources            images to combine, in order.
      * @param  bandsPerSource     bands to use for each source image, in order. May contain {@code null} elements.
+     * @param  bandSelect         final band select operation to apply on the aggregated result, or {@code null}.
      * @param  domain             bounds of the image to create.
      * @param  preferredTileSize  the preferred tile size.
      * @param  commonDataType     data type of the combined image.
      * @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 MultiSourceLayout(final RenderedImage[] sources, final int[][] bandsPerSource,
+    private MultiSourceLayout(final RenderedImage[] sources, final int[][] bandsPerSource, final int[] bandSelect,
             final Rectangle domain, final Dimension preferredTileSize, final boolean exactTileSize,
             final int minTileX, final int minTileY, final int commonDataType, final int numBands,
             final int scanlineStride)
@@ -228,6 +234,7 @@ final class MultiSourceLayout extends ImageLayout {
         super(preferredTileSize, false);
         this.exactTileSize  = exactTileSize;
         this.bandsPerSource = bandsPerSource;
+        this.bandSelect     = bandSelect;
         this.sources        = sources;
         this.domain         = domain;
         this.minTileX       = minTileX;
@@ -242,7 +249,7 @@ final class MultiSourceLayout extends ImageLayout {
             RenderedImage source = sources[i];
             final int[] bands = bandsPerSource[i];
             if (bands != null) {
-                source = BandSelectImage.create(source, bands);
+                source = BandSelectImage.create(source, true, bands);
             }
             filteredSources[i] = source;
         }
diff --git a/core/sis-feature/src/main/java/org/apache/sis/image/Visualization.java b/core/sis-feature/src/main/java/org/apache/sis/image/Visualization.java
index 1b86bdd292..7305d66d7e 100644
--- a/core/sis-feature/src/main/java/org/apache/sis/image/Visualization.java
+++ b/core/sis-feature/src/main/java/org/apache/sis/image/Visualization.java
@@ -264,7 +264,7 @@ final class Visualization extends ResampledImage {
                     break;
                 }
             }
-            source = BandSelectImage.create(source, visibleBand);
+            source = BandSelectImage.create(source, true, visibleBand);
             final SampleDimension visibleSD = (sampleDimensions != null && visibleBand < sampleDimensions.length)
                                             ? sampleDimensions[visibleBand] : null;
             /*
diff --git a/core/sis-feature/src/main/java/org/apache/sis/internal/coverage/MultiSourceArgument.java b/core/sis-feature/src/main/java/org/apache/sis/internal/coverage/MultiSourceArgument.java
index 51318aafec..8c53d447a0 100644
--- a/core/sis-feature/src/main/java/org/apache/sis/internal/coverage/MultiSourceArgument.java
+++ b/core/sis-feature/src/main/java/org/apache/sis/internal/coverage/MultiSourceArgument.java
@@ -17,9 +17,11 @@
 package org.apache.sis.internal.coverage;
 
 import java.util.List;
+import java.util.BitSet;
 import java.util.Arrays;
 import java.util.ArrayList;
 import java.util.HashMap;
+import java.util.IdentityHashMap;
 import java.util.Objects;
 import java.util.function.Consumer;
 import java.util.function.Function;
@@ -27,6 +29,7 @@ import java.util.function.ToIntFunction;
 import org.apache.sis.coverage.SampleDimension;
 import org.apache.sis.coverage.grid.GridGeometry;
 import org.apache.sis.coverage.grid.IllegalGridGeometryException;
+import org.apache.sis.internal.util.Numerics;
 import org.apache.sis.util.resources.Errors;
 import org.apache.sis.util.ArgumentChecks;
 import org.apache.sis.util.ArraysExt;
@@ -45,6 +48,9 @@ import org.apache.sis.util.ComparisonMode;
  * This is done by an {@link #unwrap(Consumer)}, which should be invoked in order to get a flattened
  * view of nested aggregations.</p>
  *
+ * <p>All methods in this class may return direct references to internal arrays.
+ * This is okay if instances of this class are discarded immediately after usage.</p>
+ *
  * @author  Martin Desruisseaux (Geomatys)
  * @version 1.4
  *
@@ -52,55 +58,80 @@ import org.apache.sis.util.ComparisonMode;
  *
  * @since 1.4
  */
+@SuppressWarnings("ReturnOfCollectionOrArrayField")     // See class Javadoc.
 public final class MultiSourceArgument<S> {
     /**
      * The user-specified sources, usually grid coverages or rendered images.
      * This is initially a copy of the array specified at construction time.
      * This array is modified in-place by {@code validate(…)} methods for
      * removing empty sources and flattening nested aggregations.
+     *
+     * @see #sources()
      */
     private S[] sources;
 
     /**
      * Indices of selected bands or sample dimensions for each source.
+     * This array is modified in-place by {@code validate(…)} methods
+     * for removing empty elements and flattening nested aggregations.
+     *
      * The length of this array must be always equal to the {@link #sources} array length.
-     * The array is non-null but may contain {@code null} elements for meaning "all bands".
-     * This array is modified in-place by {@code validate(…)} methods for removing empty
-     * elements and flattening nested aggregations.
+     * The array is non-null but may contain {@code null} elements for meaning "all bands"
+     * before validation. After validation, all null elements are replaced by sequences.
+     *
+     * @see #bandsPerSource(boolean)
      */
     private int[][] bandsPerSource;
 
     /**
-     * Whether to allow null elements in {@link #bandsPerSource} for meaning "all bands".
+     * Number of bands per source. This array is built by {@code validate(…)} methods.
      */
-    private boolean identityAsNull;
+    private int[] numBandsPerSource;
 
     /**
-     * A method which may decompose a source in a sequence of deeper sources associated with their bands to select.
+     * Whether the bands selection for a given source is an identity operation.
+     * For a source at index <var>i</var>, the bit <var>i</var> is set to 1 if
+     * {@code bandsPerSource[i]} is a sequence selecting all bands in order.
+     *
+     * <p>This field is initially null and assigned on validation.
+     * Consequently this field can also be used for checking whether
+     * one of the {@code validate(…)} methods has been invoked.</p>
+     *
+     * @see #validate(Function)
+     * @see #validate(ToIntFunction)
      */
-    private Consumer<Unwrapper> unwrapper;
+    private BitSet isIdentity;
 
     /**
-     * Union of all selected bands in all specified sources, or {@code null} if not applicable.
+     * Number of valid elements in {@link #sources} array after empty elements have been removed.
+     * This is initially zero and is set after a {@code validate(…)} method has been invoked.
      */
-    private List<SampleDimension> ranges;
+    private int validatedSourceCount;
 
     /**
      * Total number of bands. This is the length of the {@link #ranges} list,
      * except that this information is provided even if {@code ranges} is null.
      */
-    private int numBands;
+    private int totalBandCount;
+
+    /**
+     * Union of all selected bands in all specified sources, or {@code null} if not applicable.
+     */
+    private List<SampleDimension> ranges;
 
     /**
      * Index of a source having the same "grid to CRS" transform than the grid geometry
      * returned by {@link #domain(Function)}. If there is none, then this value is -1.
      */
-    private int sourceOfGridToCRS;
+    private int sourceOfGridToCRS = -1;
 
     /**
-     * Whether one of the {@code validate(…)} methods has been invoked.
+     * A method which may decompose a source in a sequence of deeper sources associated with their bands to select.
+     * Shall be set (if desired) before a {@code validate(…)} method is invoked.
+     *
+     * @see #unwrap(Consumer)
      */
-    private boolean validated;
+    private Consumer<Unwrapper> unwrapper;
 
     /**
      * Prepares an argument validator for the given sources and bands arguments.
@@ -133,17 +164,19 @@ public final class MultiSourceArgument<S> {
         }
         this.sources        = sources.clone();
         this.bandsPerSource = bandsPerSource;
-        sourceOfGridToCRS   = -1;
+        numBandsPerSource   = new int[sources.length];
     }
 
     /**
-     * Requests the use of {@code null} elements for meaning "all bands".
-     * The null elements can appear in the {@link #bandsPerSource()} array,
-     * but the array itself will still never null.
+     * Ensures that a {@code validate(…)} method has been invoked (or not).
+     *
+     * @param  expected  {@code true} if the caller expects validation to be done, or
+     *                   {@code false} if the caller expects validation to not be done yet.
      */
-    public void identityAsNull() {
-        if (validated) throw new IllegalStateException();
-        identityAsNull = true;
+    private void checkValidationState(final boolean expected) {
+        if ((isIdentity == null) == expected) {
+            throw new IllegalStateException();
+        }
     }
 
     /**
@@ -156,7 +189,7 @@ public final class MultiSourceArgument<S> {
      * @param  filter  the method to invoke for getting the sources of an image or coverage.
      */
     public void unwrap(final Consumer<Unwrapper> filter) {
-        if (validated) throw new IllegalStateException();
+        checkValidationState(false);
         unwrapper = filter;
     }
 
@@ -226,9 +259,9 @@ public final class MultiSourceArgument<S> {
                 throw new IllegalArgumentException(Errors.format(Errors.Keys.MismatchedArrayLengths));
             }
             if (done) throw new IllegalStateException();
-            sources = ArraysExt.insert(sources, index+1, n-1);
+            sources        = ArraysExt.insert(sources,        index+1, n-1);
             bandsPerSource = ArraysExt.insert(bandsPerSource, index+1, n-1);
-            System.arraycopy(components, 0, sources, index, n);
+            System.arraycopy(components,     0, sources,        index, n);
             System.arraycopy(componentBands, 0, bandsPerSource, index, n);
             done = true;
         }
@@ -241,41 +274,39 @@ public final class MultiSourceArgument<S> {
      * @throws IllegalArgumentException if some band indices are duplicated or outside their range of validity.
      */
     public void validate(final ToIntFunction<S> counter) {
+        checkValidationState(false);
         validate(null, Objects.requireNonNull(counter));
     }
 
     /**
      * Clones and validates the arguments given to the constructor.
      * Also computes the union of bands in the sources given at construction time.
-     * The union result is stored in {@link #ranges}.
+     * The union result is stored in {@link #ranges()}.
      *
      * @param  getter  method to invoke for getting the list of sample dimensions.
      * @throws IllegalArgumentException if some band indices are duplicated or outside their range of validity.
      */
     public void validate(final Function<S, List<SampleDimension>> getter) {
+        checkValidationState(false);
         ranges = new ArrayList<>();
         validate(Objects.requireNonNull(getter), null);
     }
 
     /**
-     * Computes the union of bands in the sources given at construction time.
-     * This method also verifies the indices in band arguments.
-     * Sources with no indices are removed from the iterator.
+     * Clones and validates the arguments given to the constructor.
+     * This method ensures that all band indices are in their ranges of validity with no duplicated value.
+     * Then this method stores a copy of the band indices, replacing {@code null} values by sequences.
+     * If an empty array of bands is specified, then the corresponding source is omitted.
      *
-     * <p>Exactly one of {@code getter} or {@code count} arguments shall be non-null.</p>
+     * <p>Exactly one of {@code getter} or {@code counter} arguments shall be non-null.</p>
      *
      * @param  getter   method to invoke for getting the list of sample dimensions.
      * @param  counter  method to invoke for counting the number of bands in a source.
      * @throws IllegalArgumentException if some band indices are duplicated or outside their range of validity.
      */
     private void validate(final Function<S, List<SampleDimension>> getter, final ToIntFunction<S> counter) {
-        final HashMap<Integer,int[]> pool = identityAsNull ? null : new HashMap<>();
-        int filteredCount = 0;
-        /*
-         * This loop ensures that all band indices are in their ranges of validity
-         * with no duplicated value, then stores a copy of the band indices or null.
-         * If an empty array of bands is specified, then the source is omitted.
-         */
+        final HashMap<Integer,int[]> identityPool = new HashMap<>();
+        isIdentity = new BitSet();
 next:   for (int i=0; i<sources.length; i++) {          // `sources.length` may change during the loop.
             S source;
             int[] selected;
@@ -301,52 +332,120 @@ next:   for (int i=0; i<sources.length; i++) {          // `sources.length` may
                 selected = range.getSelectedBands();
                 /*
                  * Verify if the source is a nested aggregation, in order to get a flattened view.
-                 * This replacement must be done before the optimization for consecutive images.
+                 * This replacement must be done before the check for duplicated image references.
+                 * The call to `unwrap` may result in a need to grow `numBandsPerSource` array.
                  */
             } while (unwrap(i, source, selected));
             /*
-             * Store now the sample dimensions before the `selected` array get modified.
-             * Should be done only after `RangeArgument.validate(…)` has been successful.
+             * Now that the arguments have been validated, overwrite the array elements.
+             * The new values may be written at an index lower than `i` if some empty
+             * sources have been excluded.
              */
+            if (validatedSourceCount >= numBandsPerSource.length) {
+                // Needed if `unwrap(source)` has expanded the sources array.
+                numBandsPerSource = Arrays.copyOf(numBandsPerSource, sources.length);
+            }
             if (ranges != null) {
-                for (int b : selected) {
-                    ranges.add(sourceBands.get(b));
+                for (int j : selected) {
+                    ranges.add(sourceBands.get(j));
                 }
             }
-            /*
-             * If the source in current iteration is the same than the previous source, merge the bands together.
-             * The `BandAggregateGridResource.read(…)` implementation relies on that optimization.
-             */
-            if (filteredCount > 0 && sources[filteredCount-1] == source) {
-                final int[] previous = bandsPerSource[--filteredCount];
-                ArgumentChecks.ensureNonNullElement("bandsPerSource", filteredCount,   previous);
-                ArgumentChecks.ensureNonNullElement("bandsPerSource", filteredCount+1, selected);
-                numBands -= previous.length;   // Rollback the value added in previous iteration.
-
-                final int[] merged = Arrays.copyOf(previous, previous.length + selected.length);
-                System.arraycopy(selected, 0, merged, previous.length, selected.length);
-                range = RangeArgument.validate(numSourceBands, merged, null);
-                selected = range.getSelectedBands();
-            }
-            /*
-             * Store a copy of the `bandsPerSource` argument given at construction time.
-             * Its validation has been done by `RangeArgument.validate(…)` above calls.
-             */
             if (range.isIdentity()) {
-                if (pool != null) {
-                    int[] previous = pool.putIfAbsent(numSourceBands, selected);
-                    if (previous != null) selected = previous;
-                } else {
-                    selected = null;
+                isIdentity.set(validatedSourceCount);
+                int[] previous = identityPool.putIfAbsent(numSourceBands, selected);
+                if (previous != null) selected = previous;
+            }
+            sources          [validatedSourceCount] = source;
+            bandsPerSource   [validatedSourceCount] = selected;
+            numBandsPerSource[validatedSourceCount] = numSourceBands;
+            totalBandCount += range.getNumBands();
+            validatedSourceCount++;
+        }
+    }
+
+    /**
+     * If the same sources are repeated many times, merges them in a single reference.
+     * The {@link #sources()} and {@link #bandsPerSource(boolean)} values are modified in-place.
+     * The bands associated to each source reference are merged together, but not necessarily in the same order.
+     * Caller must perform a "band select" operation using the array returned by this method
+     * in order to reconstitute the band order specified by the user.
+     *
+     * <h4>Use cases</h4>
+     * {@code BandAggregateImage.subset(…)} and
+     * {@code BandAggregateGridResource.read(…)}
+     * implementations rely on this optimization.
+     *
+     * @return the bands to specify in a "band select" operation for reconstituting the user-specified band order.
+     *         If all band selections are identity operations, then this method returns {@code null}.
+     */
+    public int[] mergeDuplicatedSources() {
+        checkValidationState(true);
+        if (isIdentity.cardinality() == validatedSourceCount) {
+            return null;
+        }
+        /*
+         * Merge together the bands of all sources that are repeated.
+         * The band indices are stored in 64 bits tuples as below:
+         *
+         *     (band in source) | (band in target aggregate)
+         */
+        final var mergedBands = new IdentityHashMap<S,long[]>();
+        int targetBand = 0;
+        for (int i=0; i<validatedSourceCount; i++) {
+            final int[] selected = bandsPerSource[i];
+            final long[] tuples = new long[selected.length];
+            for (int j=0; j<selected.length; j++) {
+                tuples[j] = Numerics.tuple(selected[j], targetBand++);
+            }
+            mergedBands.merge(sources[i], tuples, ArraysExt::concatenate);
+        }
+        /*
+         * Iterate again over the sources, rewriting the arrays with consolidated bands.
+         * We need to keep trace of how the bands were reordered.
+         */
+        final int[] reordered = new int[totalBandCount];
+        final int count = validatedSourceCount;
+        validatedSourceCount = 0;
+        targetBand = 0;
+        for (int i=0; i<count; i++) {
+            final S      source = sources[i];
+            final long[] tuples = mergedBands.remove(source);
+            if (tuples != null) {
+                boolean noop = isIdentity.get(i);
+                int[] selected = bandsPerSource[i];
+                if (tuples.length > selected.length) {
+                    /*
+                     * Found a case where the same source appears two ore more times.
+                     * Sort the bands in increasing order for making easier to detect
+                     * duplicated values, and because it increases the chances to get
+                     * an identity selection (bands in same order) for that source.
+                     */
+                    Arrays.sort(tuples);
+                    selected = new int[tuples.length];
+                    noop = (tuples.length == numBandsPerSource[i]) && ArraysExt.isRange(0, selected);
+                }
+                /*
+                 * Rewrite the `selected` array with the potentially merged bands.
+                 * If the source was not repeated, `selected` should be unchanged.
+                 * But we loop anyway because we also need to write `reordered`.
+                 */
+                for (int j=0; j < tuples.length; j++) {
+                    final long t = tuples[j];
+                    final int sourceBand = (int) (t >>> Integer.SIZE);
+                    reordered[(int) t] = sourceBand + targetBand;
+                    selected[j] = sourceBand;
                 }
+                targetBand += tuples.length;
+                bandsPerSource[validatedSourceCount] = selected;
+                isIdentity.set(validatedSourceCount, noop);
+                sources[validatedSourceCount++] = source;
             }
-            bandsPerSource[filteredCount] = selected;
-            sources[filteredCount++] = source;
-            numBands += range.getNumBands();
         }
-        sources = ArraysExt.resize(sources, filteredCount);
-        bandsPerSource = ArraysExt.resize(bandsPerSource, filteredCount);
-        validated = true;
+        final int n = isIdentity.length();
+        if (n > validatedSourceCount) {
+            isIdentity.clear(validatedSourceCount, n);
+        }
+        return reordered;
     }
 
     /**
@@ -355,7 +454,8 @@ next:   for (int i=0; i<sources.length; i++) {          // `sources.length` may
      * @return whether {@code sources[0]} could be used directly.
      */
     public boolean isIdentity() {
-        return bandsPerSource.length == 1 && bandsPerSource[0] == null;
+        checkValidationState(true);
+        return validatedSourceCount == 1 && isIdentity.cardinality() == 1;
     }
 
     /**
@@ -364,31 +464,38 @@ next:   for (int i=0; i<sources.length; i++) {          // `sources.length` may
      *
      * @return all validated sources.
      */
-    @SuppressWarnings("ReturnOfCollectionOrArrayField")
     public S[] sources() {
-        if (validated) return sources;
-        throw new IllegalStateException();
+        checkValidationState(true);
+        return sources = ArraysExt.resize(sources, validatedSourceCount);
     }
 
     /**
-     * Computes the intersection of the grid geometries of all sources.
-     * This method also verifies that all grid geometries are compatible.
-     *
-     * @param  getter  the method to invoke for getting grid geometry from a source.
-     * @return intersection of all grid geometries.
-     * @throws IllegalGridGeometryException if a grid geometry is not compatible with the others.
+     * Returns the indices of selected bands as (potentially modified)
+     * copies of the arrays argument given to the constructor.
      *
-     * @todo Current implementation requires that all grid geometry are equal. We need to relax that.
+     * @param  identityAsNull  whether to use {@code null} elements for meaning "all bands".
+     * @return indices of selected sample dimensions for each source.
+     *         Never null but may contain null elements if {@code identityAsNull} is {@code true}.
      */
-    public GridGeometry domain(final Function<S, GridGeometry> getter) {
-        GridGeometry intersection = getter.apply(sources[0]);
-        for (int i=1; i < sources.length; i++) {
-            if (!intersection.equals(getter.apply(sources[i]), ComparisonMode.IGNORE_METADATA)) {
-                throw new IllegalGridGeometryException("Not yet supported on coverages with different grid geometries.");
+    public int[][] bandsPerSource(final boolean identityAsNull) {
+        checkValidationState(true);
+        bandsPerSource = ArraysExt.resize(bandsPerSource, validatedSourceCount);
+        if (identityAsNull) {
+            for (int i=0; (i = isIdentity.nextSetBit(i)) >= 0; i++) {
+                bandsPerSource[i] = null;
             }
         }
-        sourceOfGridToCRS = 0;      // TODO: to be computed when different grid geometries will be allowed. Prefer widest extent.
-        return intersection;
+        return bandsPerSource;
+    }
+
+    /**
+     * Returns the total number of bands.
+     *
+     * @return total number of bands.
+     */
+    public int numBands() {
+        checkValidationState(true);
+        return totalBandCount;
     }
 
     /**
@@ -397,33 +504,31 @@ next:   for (int i=0; i<sources.length; i++) {          // `sources.length` may
      *
      * @return all selected sample dimensions.
      */
-    @SuppressWarnings("ReturnOfCollectionOrArrayField")
     public List<SampleDimension> ranges() {
         if (ranges != null) return ranges;
         throw new IllegalStateException();
     }
 
     /**
-     * Returns the total number of bands.
+     * Computes the intersection of the grid geometries of all sources.
+     * This method also verifies that all grid geometries are compatible.
      *
-     * @return total number of bands.
-     */
-    public int numBands() {
-        if (validated) return numBands;
-        throw new IllegalStateException();
-    }
-
-    /**
-     * Returns the indices of selected bands as (potentially modified)
-     * copies of the arrays argument given to the constructor.
+     * @param  getter  the method to invoke for getting grid geometry from a source.
+     * @return intersection of all grid geometries.
+     * @throws IllegalGridGeometryException if a grid geometry is not compatible with the others.
      *
-     * @return indices of selected sample dimensions for each source.
-     *         Never null but may contain null elements if {@link #identityAsNull()} has been invoked.
+     * @todo Current implementation requires that all grid geometry are equal. We need to relax that.
      */
-    @SuppressWarnings("ReturnOfCollectionOrArrayField")
-    public int[][] bandsPerSource() {
-        if (validated) return bandsPerSource;
-        throw new IllegalStateException();
+    public GridGeometry domain(final Function<S, GridGeometry> getter) {
+        checkValidationState(true);
+        GridGeometry intersection = getter.apply(sources[0]);
+        for (int i=1; i < validatedSourceCount; i++) {
+            if (!intersection.equals(getter.apply(sources[i]), ComparisonMode.IGNORE_METADATA)) {
+                throw new IllegalGridGeometryException("Not yet supported on coverages with different grid geometries.");
+            }
+        }
+        sourceOfGridToCRS = 0;      // TODO: to be computed when different grid geometries will be allowed. Prefer widest extent.
+        return intersection;
     }
 
     /**
@@ -433,6 +538,7 @@ next:   for (int i=0; i<sources.length; i++) {          // `sources.length` may
      * @return index of a sources having the same "grid to CRS" than the domain, or -1 if none.
      */
     public int sourceOfGridToCRS() {
+        checkValidationState(true);
         return sourceOfGridToCRS;
     }
 }
diff --git a/core/sis-feature/src/main/java/org/apache/sis/internal/coverage/RangeArgument.java b/core/sis-feature/src/main/java/org/apache/sis/internal/coverage/RangeArgument.java
index 78103751c2..3815f507ac 100644
--- a/core/sis-feature/src/main/java/org/apache/sis/internal/coverage/RangeArgument.java
+++ b/core/sis-feature/src/main/java/org/apache/sis/internal/coverage/RangeArgument.java
@@ -27,6 +27,7 @@ import org.apache.sis.coverage.grid.GridExtent;
 import org.apache.sis.internal.coverage.j2d.ColorModelFactory;
 import org.apache.sis.internal.coverage.j2d.SampleModelFactory;
 import org.apache.sis.internal.feature.Resources;
+import org.apache.sis.internal.util.Numerics;
 import org.apache.sis.math.MathFunctions;
 import org.apache.sis.util.ArgumentChecks;
 import org.apache.sis.util.ArraysExt;
@@ -97,7 +98,7 @@ public final class RangeArgument {
         if (ranges == null || ranges.length == 0) {
             packed = new long[numSampleDimensions];
             for (int i=1; i<numSampleDimensions; i++) {
-                packed[i] = (((long) i) << Integer.SIZE) | i;
+                packed[i] = Numerics.tuple(i, i);
             }
         } else {
             /*
@@ -110,7 +111,7 @@ public final class RangeArgument {
                     throw new IllegalArgumentException(resources(listeners).getString(
                             Resources.Keys.InvalidSampleDimensionIndex_2, numSampleDimensions - 1, r));
                 }
-                packed[i] = (((long) r) << Integer.SIZE) | i;
+                packed[i] = Numerics.tuple(r, i);
             }
             /*
              * Sort by increasing `range` value, but keep together with index in `ranges` where each
@@ -152,7 +153,7 @@ public final class RangeArgument {
             return false;
         }
         for (int i=0; i<packed.length; i++) {
-            if (packed[i] != ((((long) i) << Integer.SIZE) | i)) {
+            if (packed[i] != Numerics.tuple(i, i)) {
                 return false;
             }
         }
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 c0b7adf5cf..f5ae9bd381 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
@@ -442,9 +442,9 @@ public final class BandAggregateImageTest extends TestCase {
         result = BandAggregateImage.create(new RenderedImage[] {im1, result}, null, null, false, allowSharing, false);
         assertArrayEquals(sourceImages, ((BandAggregateImage) result).getSourceArray());
 
-        assertSame(im1, BandSelectImage.create(result, 0, 1, 2));
-        assertSame(im2, BandSelectImage.create(result, 3));
-        assertSame(im3, BandSelectImage.create(result, 4, 5));
+        assertSame(im1, BandSelectImage.create(result, true, 0, 1, 2));
+        assertSame(im2, BandSelectImage.create(result, true, 3));
+        assertSame(im3, BandSelectImage.create(result, true, 4, 5));
     }
 
     /**
@@ -458,6 +458,7 @@ public final class BandAggregateImageTest extends TestCase {
      *   <li><var>X</var> is the <var>x</var> coordinate (column 0-based index) of the sample value relative to current tile.</li>
      * </ol>
      */
+    @SuppressWarnings("AssignmentToCollectionOrArrayFieldFromParameter")
     private void initializeAllTiles(final TiledImageMock... images) {
         sourceImages = images;
         int band = 0;
diff --git a/core/sis-utility/src/main/java/org/apache/sis/internal/util/Numerics.java b/core/sis-utility/src/main/java/org/apache/sis/internal/util/Numerics.java
index 7633317629..6b8a48982b 100644
--- a/core/sis-utility/src/main/java/org/apache/sis/internal/util/Numerics.java
+++ b/core/sis-utility/src/main/java/org/apache/sis/internal/util/Numerics.java
@@ -198,6 +198,19 @@ public final class Numerics extends Static {
         return (bit & ~(Long.SIZE - 1)) == 0 ? (1L << bit) : 0;
     }
 
+    /**
+     * Returns a 64 bits value made of the juxtaposition of the two given 32 bits values.
+     * The resulting tuple can be decomposed back in the two 32 bits integer components with
+     * {@code (int) (tuple >>> Integer.SIZE)} for high part and {@code (int) tuple} for the low part.
+     *
+     * @param  hi   the 32 higher bits.
+     * @param  low  the 32 lower bits.
+     * @return the two given 32 bits integers juxtaposed in a 64 bits integer.
+     */
+    public static long tuple(final int hi, final int low) {
+        return (((long) hi) << Integer.SIZE) | Integer.toUnsignedLong(low);
+    }
+
     /**
      * Returns {@code true} if the given number is an integer value.
      * Special cases:
diff --git a/core/sis-utility/src/main/java/org/apache/sis/util/ArraysExt.java b/core/sis-utility/src/main/java/org/apache/sis/util/ArraysExt.java
index ae973f5f6e..821ad8157f 100644
--- a/core/sis-utility/src/main/java/org/apache/sis/util/ArraysExt.java
+++ b/core/sis-utility/src/main/java/org/apache/sis/util/ArraysExt.java
@@ -67,7 +67,7 @@ import java.lang.reflect.Array;
  * objects.
  *
  * @author Martin Desruisseaux (IRD, Geomatys)
- * @version 1.2
+ * @version 1.4
  *
  * @see Arrays
  *
@@ -1184,6 +1184,91 @@ public final class ArraysExt extends Static {
         return copy;
     }
 
+    /**
+     * Returns the concatenation of all given arrays. This method performs the following checks:
+     *
+     * <ul>
+     *   <li>If the {@code arrays} argument is {@code null} or contains only {@code null}
+     *       elements, then this method returns {@code null}.</li>
+     *   <li>Otherwise if the {@code arrays} argument contains exactly one non-null array with
+     *       a length greater than zero, then that array is returned. It is not copied.</li>
+     *   <li>Otherwise a new array with a length equals to the sum of the length of every
+     *       non-null arrays is created, and the content of non-null arrays are appended
+     *       in the new array in declaration order.</li>
+     * </ul>
+     *
+     * @param  <T>     the type of arrays.
+     * @param  arrays  the arrays to concatenate, or {@code null}.
+     * @return the concatenation of all non-null arrays (may be a direct reference to one
+     *         of the given array if it can be returned with no change), or {@code null}.
+     *
+     * @see #append(Object[], Object)
+     * @see #unionOfSorted(int[], int[])
+     */
+    @SafeVarargs
+    public static <T> T[] concatenate(final T[]... arrays) {
+        T[] result = null;
+        if (arrays != null) {
+            int length = 0;
+            for (T[] array : arrays) {
+                if (array != null) {
+                    length += array.length;
+                }
+            }
+            int offset = 0;
+            for (T[] array : arrays) {
+                if (array != null) {
+                    if (result == null) {
+                        if (array.length == length) {
+                            return array;
+                        }
+                        result = Arrays.copyOf(array, length);
+                    } else {
+                        System.arraycopy(array, 0, result, offset, array.length);
+                    }
+                    offset += array.length;
+                }
+            }
+        }
+        return result;
+    }
+
+    /**
+     * Returns the concatenation of the given arrays.
+     * If any of the supplied arrays is null or empty, then the other array is returned directly (not copied).
+     *
+     * @param  a1  the first array to concatenate, or {@code null}.
+     * @param  a2  the second array to concatenate, or {@code null}.
+     * @return the concatenation of given arrays. May be one of the given arrays returned without copying.
+     *
+     * @since 1.4
+     */
+    public static long[] concatenate(final long[] a1, final long[] a2) {
+        if (a1 == null || a1.length == 0) return a2;
+        if (a2 == null || a2.length == 0) return a1;
+        final long[] copy = Arrays.copyOf(a1, a1.length + a2.length);
+        System.arraycopy(a2, 0, copy, a1.length, a2.length);
+        return copy;
+    }
+
+    /**
+     * Returns the concatenation of the given arrays.
+     * If any of the supplied arrays is null or empty, then the other array is returned directly (not copied).
+     *
+     * @param  a1  the first array to concatenate, or {@code null}.
+     * @param  a2  the second array to concatenate, or {@code null}.
+     * @return the concatenation of given arrays. May be one of the given arrays returned without copying.
+     *
+     * @since 1.4
+     */
+    public static int[] concatenate(final int[] a1, final int[] a2) {
+        if (a1 == null || a1.length == 0) return a2;
+        if (a2 == null || a2.length == 0) return a1;
+        final int[] copy = Arrays.copyOf(a1, a1.length + a2.length);
+        System.arraycopy(a2, 0, copy, a1.length, a2.length);
+        return copy;
+    }
+
     /**
      * Removes the duplicated elements in the given array. This method should be invoked only for small arrays,
      * typically less than 10 distinct elements. For larger arrays, use {@link java.util.LinkedHashSet} instead.
@@ -2180,55 +2265,6 @@ public final class ArraysExt extends Static {
         return false;
     }
 
-    /**
-     * Returns the concatenation of all given arrays. This method performs the following checks:
-     *
-     * <ul>
-     *   <li>If the {@code arrays} argument is {@code null} or contains only {@code null}
-     *       elements, then this method returns {@code null}.</li>
-     *   <li>Otherwise if the {@code arrays} argument contains exactly one non-null array with
-     *       a length greater than zero, then that array is returned. It is not copied.</li>
-     *   <li>Otherwise a new array with a length equals to the sum of the length of every
-     *       non-null arrays is created, and the content of non-null arrays are appended
-     *       in the new array in declaration order.</li>
-     * </ul>
-     *
-     * @param  <T>     the type of arrays.
-     * @param  arrays  the arrays to concatenate, or {@code null}.
-     * @return the concatenation of all non-null arrays (may be a direct reference to one
-     *         of the given array if it can be returned with no change), or {@code null}.
-     *
-     * @see #append(Object[], Object)
-     * @see #unionOfSorted(int[], int[])
-     */
-    @SafeVarargs
-    public static <T> T[] concatenate(final T[]... arrays) {
-        T[] result = null;
-        if (arrays != null) {
-            int length = 0;
-            for (T[] array : arrays) {
-                if (array != null) {
-                    length += array.length;
-                }
-            }
-            int offset = 0;
-            for (T[] array : arrays) {
-                if (array != null) {
-                    if (result == null) {
-                        if (array.length == length) {
-                            return array;
-                        }
-                        result = Arrays.copyOf(array, length);
-                    } else {
-                        System.arraycopy(array, 0, result, offset, array.length);
-                    }
-                    offset += array.length;
-                }
-            }
-        }
-        return result;
-    }
-
     /**
      * Returns the union of two sorted arrays. The input arrays shall be sorted in strictly increasing order.
      * The output array is the union of the input arrays without duplicated values,
diff --git a/core/sis-utility/src/test/java/org/apache/sis/util/ArraysExtTest.java b/core/sis-utility/src/test/java/org/apache/sis/util/ArraysExtTest.java
index 420326aa06..c316494fe5 100644
--- a/core/sis-utility/src/test/java/org/apache/sis/util/ArraysExtTest.java
+++ b/core/sis-utility/src/test/java/org/apache/sis/util/ArraysExtTest.java
@@ -27,10 +27,40 @@ import static org.junit.Assert.*;
  *
  * @author  Martin Desruisseaux (Geomatys)
  * @author  Johann Sorel (Geomatys)
- * @version 1.0
+ * @version 1.4
  * @since   0.3
  */
 public final class ArraysExtTest extends TestCase {
+    /**
+     * Tests {@link ArraysExt#concatenate(Object[]...)}.
+     */
+    @Test
+    public void testConcatenate() {
+        final Integer[] a1 = {2, 8, 4, 8};
+        final Integer[] a2 = {1, 2, 8};
+        assertArrayEquals(new Integer[] {2, 8, 4, 8, 1, 2, 8}, ArraysExt.concatenate(a1, a2));
+    }
+
+    /**
+     * Tests {@link ArraysExt#concatenate(long[], long[])}.
+     */
+    @Test
+    public void testConcatenateLong() {
+        final long[] a1 = {2, 8, 4, 8};
+        final long[] a2 = {1, 2, 8};
+        assertArrayEquals(new long[] {2, 8, 4, 8, 1, 2, 8}, ArraysExt.concatenate(a1, a2));
+    }
+
+    /**
+     * Tests {@link ArraysExt#concatenate(int[], int[])}.
+     */
+    @Test
+    public void testConcatenateInt() {
+        final int[] a1 = {2, 8, 4, 8};
+        final int[] a2 = {1, 2, 8};
+        assertArrayEquals(new int[] {2, 8, 4, 8, 1, 2, 8}, ArraysExt.concatenate(a1, a2));
+    }
+
     /**
      * Tests {@link ArraysExt#removeDuplicated(Object[])}.
      */
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 f548c4289d..d32e47d19a 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
@@ -133,8 +133,7 @@ public class BandAggregateGridResource extends AbstractGridCoverageResource {
      * <p>The {@code bandsPerSource} argument specifies the bands to select in each resource.
      * That array can be {@code null} for selecting all bands in all resources,
      * or may contain {@code null} elements for selecting all bands of the corresponding resource.
-     * An empty array element (i.e. zero band to select) discards the corresponding resource.
-     * In the latter case, the discarded element in the {@code sources} array may be {@code null}.</p>
+     * An empty array element (i.e. zero band to select) discards the corresponding resource.</p>
      *
      * <h4>Restrictions</h4>
      * All resources shall have compatible domain, defined as below:
@@ -169,7 +168,7 @@ public class BandAggregateGridResource extends AbstractGridCoverageResource {
             this.sources          = aggregate.sources();
             this.gridGeometry     = aggregate.domain(BandAggregateGridResource::domain);
             this.sampleDimensions = List.copyOf(aggregate.ranges());
-            this.bandsPerSource   = aggregate.bandsPerSource();
+            this.bandsPerSource   = aggregate.bandsPerSource(false);
             this.processor        = (processor != null) ? processor : new GridCoverageProcessor();
         } catch (BackingStoreException e) {
             throw e.unwrapOrRethrow(DataStoreException.class);