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/10 17:16:25 UTC

[sis] 01/02: "Band select" on a band aggregation should be able to return the original component. Aggregation of aggregations should use a flattened list or source images.

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 5f36de44f40febbc4c8f4b08a92427f63d49fc9a
Author: Martin Desruisseaux <ma...@geomatys.com>
AuthorDate: Mon Apr 10 18:33:18 2023 +0200

    "Band select" on a band aggregation should be able to return the original component.
    Aggregation of aggregations should use a flattened list or source images.
---
 .../org/apache/sis/image/BandAggregateImage.java   |  90 ++++++++--
 .../java/org/apache/sis/image/BandSelectImage.java |  47 +++--
 .../java/org/apache/sis/image/ImageProcessor.java  |   2 +-
 .../org/apache/sis/image/MultiSourceImage.java     |   4 +-
 .../org/apache/sis/image/MultiSourceLayout.java    |   4 +-
 .../sis/internal/coverage/MultiSourceArgument.java | 190 ++++++++++++++-------
 .../apache/sis/image/BandAggregateImageTest.java   |  48 +++++-
 .../org/apache/sis/image/BandSelectImageTest.java  |  14 ++
 .../main/java/org/apache/sis/util/ArraysExt.java   |   2 +-
 9 files changed, 301 insertions(+), 100 deletions(-)

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 2e672a1bb1..f1d06ca4b4 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
@@ -25,6 +25,7 @@ import java.awt.image.WritableRaster;
 import java.awt.image.WritableRenderedImage;
 import org.apache.sis.util.ArraysExt;
 import org.apache.sis.internal.coverage.j2d.ImageUtilities;
+import org.apache.sis.internal.coverage.MultiSourceArgument;
 
 
 /**
@@ -51,20 +52,92 @@ class BandAggregateImage extends MultiSourceImage {
      */
     private final boolean allowSharing;
 
+    /*
+     * The method declaration order below is a little bit unusual,
+     * but it follows an execution order.
+     */
+
+    /**
+     * Returns potentially deeper sources than the user-supplied image.
+     * This method unwraps {@link BandSelectImage} for making possible to detect that two
+     * consecutive images are actually the same image, with only different bands selected.
+     *
+     * @param  unwrapper  a handler where to supply the result of an aggregate decomposition.
+     */
+    static void unwrap(final MultiSourceArgument<RenderedImage>.Unwrapper unwrapper) {
+        RenderedImage source = unwrapper.source;
+        int[] bands = unwrapper.bands;
+        while (source instanceof ImageAdapter) {
+            source = ((ImageAdapter) source).source;
+        }
+        if (source instanceof BandSelectImage) {
+            final var select = (BandSelectImage) source;
+            bands  = select.getSourceBands(bands);
+            source = select.getSource();
+        }
+        if (source instanceof BandAggregateImage) {
+            ((BandAggregateImage) source).subset(bands, null, unwrapper);
+        } else if (source != unwrapper.source) {
+            unwrapper.apply(new RenderedImage[] {source}, new int[][] {bands});
+        }
+    }
+
+    /**
+     * Decomposes this aggregate for the specified subset of bands.
+     * The result can be used either for creating a new aggregate,
+     * or consumed by {@code unwrapper} for flattening an aggregation.
+     *
+     * <p>This is a kind of constructor, but for an image derived from this instance.
+     * The returned image may be one of the source images for simplifying the result.</p>
+     *
+     * @param  bands      the bands to keep.
+     * @param  colors     the colors to apply, or {@code null} if unspecified.
+     * @param  unwrapper  where to provide decomposition result, or {@code null} for creating the image immediately.
+     * @return an image with a subset of the bands of this image, or {@code null} if {@code unwrapper} was non-null.
+     */
+    final RenderedImage subset(final int[] bands, final ColorModel colors,
+            final MultiSourceArgument<RenderedImage>.Unwrapper unwrapper)
+    {
+        final RenderedImage[] sources = new RenderedImage[bands.length];
+        final int[][] bandsPerSource = new int[bands.length][];
+        int lower=0, upper=0, sourceIndex = -1;
+        RenderedImage source = null;
+        for (int i=0; i<bands.length; i++) {
+            final int band = bands[i];
+            if (band < lower) {
+                lower = upper = 0;
+                sourceIndex = -1;
+            }
+            while (band >= upper) {
+                source = getSource(++sourceIndex);
+                lower  = upper;
+                upper += ImageUtilities.getNumBands(source);
+            }
+            sources[i] = source;
+            bandsPerSource[i] = new int[] {band - lower};
+        }
+        if (unwrapper != null) {
+            unwrapper.apply(sources, bandsPerSource);
+            return null;
+        }
+        return create(sources, bandsPerSource, (colors != null) ? Colorizer.forInstance(colors) : null, false, allowSharing, parallel);
+    }
+
     /**
      * Creates a new aggregation of bands.
      *
      * @param  sources         images to combine, in order.
      * @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  forceColors     whether to force application of {@code colorizer} when a source image is returned.
      * @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 boolean parallel)
+    static RenderedImage create(final RenderedImage[] sources, final int[][] bandsPerSource, final Colorizer colorizer,
+                                final boolean forceColors, final boolean allowSharing, final boolean parallel)
     {
         final var layout = MultiSourceLayout.create(sources, bandsPerSource, allowSharing);
         final BandAggregateImage image;
@@ -74,16 +147,13 @@ class BandAggregateImage extends MultiSourceImage {
             image = new BandAggregateImage(layout, colorizer, allowSharing, parallel);
         }
         if (image.getNumSources() == 1) {
-            final RenderedImage c = image.getSource();
-            if (image.colorModel == null) {
-                return c;
-            }
-            final ColorModel cm = c.getColorModel();
-            if (cm == null || image.colorModel.equals(cm)) {
-                return c;
+            RenderedImage source = image.getSource();
+            if ((forceColors && colorizer != null)) {
+                source = RecoloredImage.applySameColors(source, image);
             }
+            return source;
         }
-        return image;
+        return ImageProcessor.unique(image);
     }
 
     /**
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 f8fb8bc318..7dfd7c1279 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
@@ -85,32 +85,28 @@ class BandSelectImage extends SourceAlignedImage {
     }
 
     /**
-     * If the given image is already a band select operation, returns the original source
-     * and updates the band indices. If there is no replacement, then the {@code image}
-     * argument is returned as-is and the {@code bands} array shall be unmodified.
+     * Returns the indices of bands in the source image for the given bands in this image.
+     * A reference to the given array will be returned if the band indices are the same.
      *
-     * @param  image  the image to check.
-     * @param  bands  the band to select in the specified source.
-     *                Will be updated in-place if the source is replaced.
-     * @return the source of the image, or {@code image} if no replacement.
+     * @param  bands  the band to select in this image.
+     * @return the bands to select in source image.
+     *
+     * @see #getSource()
      */
-    static RenderedImage unwrap(final RenderedImage image, final int[] bands) {
-        if (image instanceof BandSelectImage) {
-            final var select = (BandSelectImage) image;
-            for (int i=0; i<bands.length; i++) {
-                bands[i] = select.bands[bands[i]];
-            }
-            return select.getSource();
+    final int[] getSourceBands(final int[] subset) {
+        final int[] select = new int[subset.length];
+        for (int i=0; i<subset.length; i++) {
+            select[i] = bands[subset[i]];
         }
-        return image;
+        return Arrays.equals(subset, select) ? subset : select;
     }
 
     /**
      * Creates a new "band select" operation for the given source.
      *
      * @param  source  the image in which to select bands.
-     * @param  bands   the bands to select. Shall be a clone of user-specified argument
-     *                 because it may be modified in-place.
+     * @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) {
         final int numBands = ImageUtilities.getNumBands(source);
@@ -118,8 +114,23 @@ class BandSelectImage extends SourceAlignedImage {
             return source;
         }
         ArgumentChecks.ensureNonEmptyBounded("bands", false, 0, numBands - 1, bands);
-        source = unwrap(source, bands);
         final ColorModel cm = ColorModelFactory.createSubset(source.getColorModel(), bands);
+        /*
+         * Since this operation applies its own ColorModel anyway, skip operation that was doing nothing else
+         * than changing the color model. Operations adding properties such as stastics are kept because this
+         * class can inherit some of them (see `REDUCED_PROPERTIES`).
+         */
+        if (source instanceof RecoloredImage) {
+            source = ((RecoloredImage) source).source;
+        }
+        if (source instanceof BandSelectImage) {
+            final var select = (BandSelectImage) source;
+            bands  = select.getSourceBands(bands);
+            source = select.getSource();
+        }
+        if (source instanceof BandAggregateImage) {
+            return ((BandAggregateImage) source).subset(bands, cm, null);
+        }
         /*
          * If the image is an instance of `BufferedImage`, create the subset immediately
          * (reminder: this operation will not copy pixel data). It allows us to return a
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 eebd30af21..ad9b1fee78 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
@@ -1004,7 +1004,7 @@ public class ImageProcessor implements Cloneable {
             colorizer = this.colorizer;
             parallel = executionMode != Mode.SEQUENTIAL;
         }
-        return unique(BandAggregateImage.create(sources, bandsPerSource, colorizer, true, parallel));
+        return BandAggregateImage.create(sources, bandsPerSource, colorizer, true, true, parallel);
     }
 
     /**
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
index 743e1580aa..9fd47dc9c1 100644
--- 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
@@ -44,7 +44,7 @@ abstract class MultiSourceImage extends WritableComputedImage {
      *
      * @see #getColorModel()
      */
-    protected final ColorModel colorModel;
+    private final ColorModel colorModel;
 
     /**
      * Domain of pixel coordinates. All images shall share the same pixel coordinate space,
@@ -63,7 +63,7 @@ abstract class MultiSourceImage extends WritableComputedImage {
     /**
      * Whether parallel computation is allowed.
      */
-    private final boolean parallel;
+    final boolean parallel;
 
     /**
      * Creates a new multi-sources image.
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 f649382dfe..7ce6ba198b 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
@@ -129,7 +129,7 @@ final class MultiSourceLayout extends ImageLayout {
     static MultiSourceLayout create(RenderedImage[] sources, int[][] bandsPerSource, boolean allowSharing) {
         final var aggregate = new MultiSourceArgument<RenderedImage>(sources, bandsPerSource);
         aggregate.identityAsNull();
-        aggregate.unwrap(BandSelectImage::unwrap);
+        aggregate.unwrap(BandAggregateImage::unwrap);
         aggregate.validate(ImageUtilities::getNumBands);
 
         sources            = aggregate.sources();
@@ -242,7 +242,7 @@ final class MultiSourceLayout extends ImageLayout {
             RenderedImage source = sources[i];
             final int[] bands = bandsPerSource[i];
             if (bands != null) {
-                source = BandSelectImage.create(source, bands.clone());
+                source = BandSelectImage.create(source, bands);
             }
             filteredSources[i] = source;
         }
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 be9fa9b6fb..6685ed04a8 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
@@ -21,8 +21,8 @@ import java.util.Arrays;
 import java.util.ArrayList;
 import java.util.HashMap;
 import java.util.Objects;
+import java.util.function.Consumer;
 import java.util.function.Function;
-import java.util.function.BiFunction;
 import java.util.function.ToIntFunction;
 import org.apache.sis.coverage.SampleDimension;
 import org.apache.sis.coverage.grid.GridGeometry;
@@ -41,8 +41,8 @@ import org.apache.sis.util.ComparisonMode;
  * <p>Instances of this class should be short-lived.
  * They are used only the time needed for constructing an image or coverage operation.</p>
  *
- * <p>This class can optionally verify if a source is itself an aggregated image or coverage.
- * This is done by an "unwrapper", which should be specified in order to provide a flattened
+ * <p>This class can optionally verify if some sources are themselves aggregated images or coverages.
+ * This is done by an {@link #unwrap(Consumer)}, which should be invoked in order to get a flattened
  * view of nested aggregations.</p>
  *
  * @author  Martin Desruisseaux (Geomatys)
@@ -54,18 +54,19 @@ import org.apache.sis.util.ComparisonMode;
  */
 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
-     * (potentially modified) copy of the array argument given to the constructor.
+     * 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.
      */
     private S[] sources;
 
     /**
-     * Indices of selected sample dimensions for each source.
-     * After a {@code validate(…)} method has been invoked, this array become a
-     * (potentially modified) copy of the array argument given to the constructor,
-     * with the same length than {@link #sources} and all elements themselves copied.
+     * Indices of selected bands or sample dimensions for each source.
+     * 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.
      */
     private int[][] bandsPerSource;
 
@@ -75,11 +76,9 @@ public final class MultiSourceArgument<S> {
     private boolean identityAsNull;
 
     /**
-     * A function which, given an (image, bands) pair, may return the source of the image.
-     * If the source is returned, then the bands array is updated with the indices in that
-     * source.
+     * A method which may decompose a source in a sequence of deeper sources associated with their bands to select.
      */
-    private BiFunction<S,int[],S> unwrapper;
+    private Consumer<Unwrapper> unwrapper;
 
     /**
      * Union of all selected bands in all specified sources, or {@code null} if not applicable.
@@ -105,15 +104,36 @@ public final class MultiSourceArgument<S> {
 
     /**
      * Prepares an argument validator for the given sources and bands arguments.
-     * One of the {@code validate(…)} method should be invoked after this constructor.
+     * The optional {@code bandsPerSource} argument specifies the bands to select in each source images.
+     * That array can be {@code null} for selecting all bands in all source images,
+     * or may contain {@code null} elements for selecting all bands of the corresponding image.
+     * An empty array element (i.e. zero band to select) discards the corresponding source image.
+     *
+     * <p>One of the {@code validate(…)} method shall be invoked after this constructor.</p>
      *
      * @param  sources         the sources from which to get the sample dimensions.
      * @param  bandsPerSource  sample dimensions for each source. May contain {@code null} elements.
      */
-    public MultiSourceArgument(final S[] sources, final int[][] bandsPerSource) {
-        this.sources = sources;
+    public MultiSourceArgument(S[] sources, int[][] bandsPerSource) {
+        /*
+         * Ensure that both arrays are non-null and have the same length.
+         * Copy those arrays because their content will be overwritten.
+         */
+        ArgumentChecks.ensureNonEmpty("sources", sources);
+        final int n = sources.length;
+        if (bandsPerSource != null) {
+            if (bandsPerSource.length > n) {
+                throw new IllegalArgumentException(Errors.format(
+                        Errors.Keys.TooManyCollectionElements_3,
+                        "bandsPerSource", bandsPerSource.length, n));
+            }
+            bandsPerSource = Arrays.copyOf(bandsPerSource, n);
+        } else {
+            bandsPerSource = new int[n][];
+        }
+        this.sources        = sources.clone();
         this.bandsPerSource = bandsPerSource;
-        sourceOfGridToCRS = -1;
+        sourceOfGridToCRS   = -1;
     }
 
     /**
@@ -127,18 +147,93 @@ public final class MultiSourceArgument<S> {
     }
 
     /**
-     * Specifies a function which, given an (image, bands) pair, may return the source of the image.
-     * If the source is returned, then the bands array is updated with the indices in that source.
-     * The function shall modify the given {@code int[]} in-place and return the new source,
-     * or return the {@code S} value unchanged if no unwrapping has been done.
+     * Specifies a method which, given a source, may decompose that source
+     * in a sequence of deeper sources associated with their bands to select.
+     * The consumer will be invoked for all sources specified to the constructor.
+     * If a source can be decomposed, then the specified consumer should invoke
+     * {@code apply(…)} on the given {@code Unwrapper} instance.
      *
-     * @param  filter  the function to invoke for getting the source of an image or coverage.
+     * @param  filter  the method to invoke for getting the sources of an image or coverage.
      */
-    public void unwrap(final BiFunction<S,int[],S> filter) {
+    public void unwrap(final Consumer<Unwrapper> filter) {
         if (validated) throw new IllegalStateException();
         unwrapper = filter;
     }
 
+    /**
+     * Asks to the {@linkplain #unwrapper} if the given source can be decomposed into deeper sources.
+     *
+     * @param  index   index of {@code source} in the {@link #sources} array.
+     * @param  source  the source to potentially unwrap.
+     * @param  bands   the bands to use in the source. Shall not be {@code null}.
+     * @return whether the source has been decomposed.
+     */
+    private boolean unwrap(int index, S source, int[] bands) {
+        if (unwrapper == null) {
+            return false;
+        }
+        final Unwrapper handler = new Unwrapper(index, source, bands);
+        unwrapper.accept(handler);
+        return handler.done;
+    }
+
+    /**
+     * Replace a user-supplied source by a deeper source with the bands to select.
+     * This is used for getting a flattened view of nested aggregations.
+     */
+    public final class Unwrapper {
+        /**
+         * Index of {@link #source} in the {@link #sources} array.
+         */
+        private final int index;
+
+        /**
+         * The source to potentially unwrap.
+         */
+        public final S source;
+
+        /**
+         * The bands to use in the source (never {@code null}).
+         * This array shall not modified because it may be a reference to an internal array.
+         */
+        public final int[] bands;
+
+        /**
+         * Whether the source has been decomposed in deeper sources.
+         */
+        private boolean done;
+
+        /**
+         * Creates a new instance to be submitted to user-supplied {@link #unwrapper}.
+         */
+        private Unwrapper(final int index, final S source, final int[] bands) {
+            this.index  = index;
+            this.source = source;
+            this.bands  = bands;
+        }
+
+        /**
+         * Notifies the enclosing {@code MultiSourceArgument} that the {@linkplain #source}
+         * shall be replaced by deeper sources. The {@code componentBands} array specifies
+         * the bands to use for each source and shall take in account the {@link #bands} subset.
+         *
+         * @param components      the deeper sources to use in replacement to {@link #source}.
+         * @param componentBands  the bands to use in replacement for {@link #bands}.
+         */
+        public void apply(final S[] components, final int[][] componentBands) {
+            final int n = components.length;
+            if (componentBands.length != n) {
+                throw new IllegalArgumentException(Errors.format(Errors.Keys.MismatchedArrayLengths));
+            }
+            if (done) throw new IllegalStateException();
+            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(componentBands, 0, bandsPerSource, index, n);
+            done = true;
+        }
+    }
+
     /**
      * Clones and validates the arguments given to the constructor.
      *
@@ -174,47 +269,27 @@ public final class MultiSourceArgument<S> {
      * @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;
         /*
-         * Ensure that both arrays are non-null and have the same length.
-         * Copy those arrays as their content may be overwritten.
-         */
-        ArgumentChecks.ensureNonEmpty("sources", sources);
-        final int sourceCount = sources.length;
-        if (bandsPerSource != null) {
-            if (bandsPerSource.length > sourceCount) {
-                throw new IllegalArgumentException(Errors.format(
-                        Errors.Keys.TooManyCollectionElements_3,
-                        "bandsPerSource", bandsPerSource.length, sourceCount));
-            }
-            bandsPerSource = Arrays.copyOf(bandsPerSource, sourceCount);
-        } else {
-            bandsPerSource = new int[sourceCount][];
-        }
-        sources = sources.clone();
-        /*
-         * Compute the number of sources and the total number of bands.
          * 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[]> pool = identityAsNull ? null : new HashMap<>();
-        int filteredCount = 0;
-        for (int i=0; i<sourceCount; i++) {
-            int[] selected = bandsPerSource[i];
-            if (selected != null && selected.length == 0) {
-                // Note that the source is allowed to be null in this particular case.
-                continue;
-            }
-            S source = sources[i];
-            ArgumentChecks.ensureNonNullElement("sources", i, source);
-            /*
-             * Get the number of bands, or optionally the bands themselves.
-             * This information is required before to validate arguments.
-             */
+next:   for (int i=0; i<sources.length; i++) {          // `sources.length` may change during the loop.
+            S source;
+            int[] selected;
             List<SampleDimension> sourceBands;
             int numSourceBands;
             RangeArgument range;
             do {
+                selected = bandsPerSource[i];
+                if (selected != null && selected.length == 0) {
+                    // Note that the source is allowed to be null in this particular case.
+                    continue next;
+                }
+                source = sources[i];
+                ArgumentChecks.ensureNonNullElement("sources", i, source);
                 if (getter != null) {
                     sourceBands = getter.apply(source);
                     numSourceBands = sourceBands.size();
@@ -228,9 +303,10 @@ public final class MultiSourceArgument<S> {
                  * 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.
                  */
-            } while (unwrapper != null && source != (source = unwrapper.apply(source, selected)));
+            } 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.
              */
             if (ranges != null) {
                 for (int b : selected) {
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 ea7641b54d..144510cee8 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
@@ -70,6 +70,13 @@ public final class BandAggregateImageTest extends TestCase {
         allowSharing = true;            // This is the default mode of `ImageProcessor`.
     }
 
+    /**
+     * Creates the band aggregate instance to test using current value of {@link #sourceImages}.
+     */
+    private RenderedImage createBandAggregate() {
+        return BandAggregateImage.create(sourceImages, null, null, false, allowSharing, false);
+    }
+
     /**
      * Tests the aggregation of two untiled images with forced copy of sample values.
      * This is the simplest case in this test class.
@@ -95,7 +102,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, false);
+        final RenderedImage result = createBandAggregate();
         assertNotNull(result);
         assertEquals(0, result.getMinTileX());
         assertEquals(0, result.getMinTileY());
@@ -186,9 +193,8 @@ public final class BandAggregateImageTest extends TestCase {
         final TiledImageMock im1 = new TiledImageMock(DataBuffer.TYPE_USHORT, 2, minX, minY, width, height, 3, 3, 1, 2, firstBanded);
         final TiledImageMock im2 = new TiledImageMock(DataBuffer.TYPE_USHORT, 2, minX, minY, width, height, 3, 3, 3, 4, secondBanded);
         initializeAllTiles(im1, im2);
-        sourceImages = new RenderedImage[] {im1, im2};
 
-        RenderedImage result = BandAggregateImage.create(sourceImages, null, null, allowSharing, false);
+        RenderedImage result = createBandAggregate();
         assertNotNull(result);
         assertEquals(minX,   result.getMinX());
         assertEquals(minY,   result.getMinY());
@@ -255,7 +261,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, false);
+        }, null, false, allowSharing, false);
         assertNotNull(result);
         assertEquals(minX,   result.getMinX());
         assertEquals(minY,   result.getMinY());
@@ -314,9 +320,8 @@ public final class BandAggregateImageTest extends TestCase {
         final TiledImageMock tiled4x1 = new TiledImageMock(DataBuffer.TYPE_FLOAT, 1, minX, minY, width, height, 4, 1, 3, 4, true);
         final TiledImageMock oneTile  = new TiledImageMock(DataBuffer.TYPE_FLOAT, 1, minX, minY, width, height, 8, 4, 5, 6, true);
         initializeAllTiles(tiled2x2, tiled4x1, oneTile);
-        sourceImages = new RenderedImage[] {tiled2x2, tiled4x1, oneTile};
 
-        final RenderedImage result = BandAggregateImage.create(sourceImages, null, null, allowSharing, false);
+        final RenderedImage result = createBandAggregate();
         assertNotNull(result);
         assertEquals(minX,   result.getMinX());
         assertEquals(minY,   result.getMinY());
@@ -382,9 +387,8 @@ public final class BandAggregateImageTest extends TestCase {
         final TiledImageMock tiled4x4 = new TiledImageMock(DataBuffer.TYPE_SHORT, 2, 4, 2,  8,  8,  4,  4, 0, 0, true);
         final TiledImageMock tiled6x6 = new TiledImageMock(DataBuffer.TYPE_SHORT, 1, 2, 0, 12,  6,  6,  6, 0, 0, true);
         initializeAllTiles(untiled, tiled2x2, tiled4x4, tiled6x6);
-        sourceImages = new RenderedImage[] {untiled, tiled2x2, tiled4x4, tiled6x6};
 
-        RenderedImage result = BandAggregateImage.create(sourceImages, null, null, allowSharing, prefetch);
+        RenderedImage result = BandAggregateImage.create(sourceImages, null, null, false, allowSharing, prefetch);
         assertNotNull(result);
         assertEquals(4, result.getMinX());
         assertEquals(2, result.getMinY());
@@ -418,6 +422,31 @@ public final class BandAggregateImageTest extends TestCase {
         }
     }
 
+    /**
+     * Tests aggregation of aggregated images. The result should be a flattened view.
+     * Opportunistically tests a "band select" operation after the aggregation.
+     */
+    @Test
+    public void testNestedAggregation() {
+        final int minX   =  7;
+        final int minY   = -5;
+        final int width  =  6;
+        final int height =  4;
+        final TiledImageMock im1 = new TiledImageMock(DataBuffer.TYPE_USHORT, 3, minX, minY, width, height, 3, 2, 1, 2, true);
+        final TiledImageMock im2 = new TiledImageMock(DataBuffer.TYPE_USHORT, 1, minX, minY, width, height, 3, 2, 3, 4, true);
+        final TiledImageMock im3 = new TiledImageMock(DataBuffer.TYPE_USHORT, 2, minX, minY, width, height, 3, 2, 2, 1, true);
+        initializeAllTiles(im1, im2, im3);
+
+        RenderedImage result;
+        result = BandAggregateImage.create(new RenderedImage[] {im2, im3},    null, null, false, allowSharing, false);
+        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));
+    }
+
     /**
      * Initializes all bands of all input images to testing values.
      * The testing values are defined by a "BTYX" pattern where:
@@ -429,7 +458,8 @@ 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>
      */
-    private static void initializeAllTiles(final TiledImageMock... images) {
+    private void initializeAllTiles(final TiledImageMock... images) {
+        sourceImages = images;
         int band = 0;
         for (final TiledImageMock image : images) {
             final int numBands = image.getSampleModel().getNumBands();
diff --git a/core/sis-feature/src/test/java/org/apache/sis/image/BandSelectImageTest.java b/core/sis-feature/src/test/java/org/apache/sis/image/BandSelectImageTest.java
index 2a1d0be7ae..60db8f4b7d 100644
--- a/core/sis-feature/src/test/java/org/apache/sis/image/BandSelectImageTest.java
+++ b/core/sis-feature/src/test/java/org/apache/sis/image/BandSelectImageTest.java
@@ -231,4 +231,18 @@ public final class BandSelectImageTest extends TestCase {
         writable.setData(data);
         assertValuesEqual(writable.getData(), 0, expectedSampleValues());
     }
+
+    /**
+     * Tests a band select on an image which is already a band select.
+     * The nested operations should be simplified to a single band select operation.
+     */
+    @Test
+    public void testNestedBandSelect() {
+        createImage(3, 2, true);
+        final ImageProcessor processor = new ImageProcessor();
+        RenderedImage test = processor.selectBands(image, 1, 2);
+        test = processor.selectBands(test, 1);
+        assertSame(image, ((BandSelectImage) test).getSource());
+        assertValuesEqual(test.getData(), 0, expectedSampleValues());
+    }
 }
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 4c072de282..ae973f5f6e 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
@@ -644,7 +644,7 @@ public final class ArraysExt extends Static {
         if (length == 0) {
             return array;               // May be null
         }
-        ArgumentChecks.ensureNonNull ("array",  array);
+        ArgumentChecks.ensureNonNull("array",  array);
         final int arrayLength = Array.getLength(array);
         ArgumentChecks.ensureBetween("first", 0, arrayLength, first);
         ArgumentChecks.ensurePositive("length", length);