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 acdc88439434ec9195caa424c671018fd2d1355f
Author: Martin Desruisseaux <martin.desruisse...@geomatys.com>
AuthorDate: Mon Dec 16 00:29:40 2024 +0100

    Add an image overlay operation in `ImageProcessor`.
---
 .../sis/coverage/privy/BandAggregateArgument.java  |   2 +-
 .../sis/coverage/privy/ColorModelBuilder.java      |   9 +-
 .../apache/sis/coverage/privy/ImageUtilities.java  |  14 +
 .../org/apache/sis/feature/internal/Resources.java |   3 +-
 .../sis/feature/internal/Resources.properties      |   2 +-
 .../sis/feature/internal/Resources_fr.properties   |   2 +-
 .../org/apache/sis/image/BandAggregateImage.java   |   4 +-
 .../org/apache/sis/image/BandAggregateLayout.java  |   2 +-
 .../main/org/apache/sis/image/BandSelectImage.java |   6 +-
 .../apache/sis/image/BandedSampleConverter.java    |   9 +-
 .../main/org/apache/sis/image/Colorizer.java       |   2 +-
 .../main/org/apache/sis/image/ComputedImage.java   |  23 +-
 .../main/org/apache/sis/image/ImageAdapter.java    |   4 +-
 .../main/org/apache/sis/image/ImageOverlay.java    | 370 +++++++++++++++++++++
 .../main/org/apache/sis/image/ImageProcessor.java  | 106 +++++-
 .../org/apache/sis/image/MultiSourceImage.java     |  41 ++-
 .../main/org/apache/sis/image/PlanarImage.java     |  29 +-
 .../main/org/apache/sis/image/RecoloredImage.java  |  60 +++-
 .../main/org/apache/sis/image/ResampledImage.java  |   1 +
 .../org/apache/sis/image/StatisticsCalculator.java |  13 +-
 .../main/org/apache/sis/image/Visualization.java   |  11 +-
 .../apache/sis/image/WritableComputedImage.java    |   2 +-
 .../org/apache/sis/image/ImageOverlayTest.java     | 114 +++++++
 .../org/apache/sis/storage/geotiff/WriterTest.java |  12 +-
 .../org/apache/sis/storage/esri/RasterStore.java   |   6 +-
 .../sis/util/resources/IndexedResourceBundle.java  |  19 ++
 26 files changed, 774 insertions(+), 92 deletions(-)

diff --git 
a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/coverage/privy/BandAggregateArgument.java
 
b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/coverage/privy/BandAggregateArgument.java
index a4c9737149..f4a5efe523 100644
--- 
a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/coverage/privy/BandAggregateArgument.java
+++ 
b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/coverage/privy/BandAggregateArgument.java
@@ -345,7 +345,7 @@ public final class BandAggregateArgument<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[]> identityPool = new HashMap<>();
+        final var identityPool = new HashMap<Integer,int[]>();
         numBandsPerSource = new int[sources.length];
 next:   for (int i=0; i<sources.length; i++) {          // `sources.length` 
may change during the loop.
             S source;
diff --git 
a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/coverage/privy/ColorModelBuilder.java
 
b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/coverage/privy/ColorModelBuilder.java
index 6a9327bc71..1501c36951 100644
--- 
a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/coverage/privy/ColorModelBuilder.java
+++ 
b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/coverage/privy/ColorModelBuilder.java
@@ -271,13 +271,14 @@ public final class ColorModelBuilder {
     /**
      * Creates a <abbr>RGB</abbr> color model for the given sample model.
      * The sample model shall use integer type and have 3 or 4 bands.
-     * This method may return {@code null} if the color model cannot be 
created.
+     * If no <abbr>RGB</abbr> or <abbr>ARGB</abbr> color model can be created,
+     * this method default on a gray scale color model.
      *
      * @param  targetModel  the sample model for which to create a color model.
-     * @return the color model, or {@code null} if the given sample model is 
not supported.
+     * @return the <abbr>RGB</abbr> color model, or a gray scale color model 
as a fallback.
      * @throws IllegalArgumentException if any argument specified to the 
builder is invalid.
      */
-    public ColorModel create(final SampleModel targetModel) {
+    public ColorModel createRGB(final SampleModel targetModel) {
 check:  if (ImageUtilities.isIntegerType(targetModel)) {
             final int numBands = targetModel.getNumBands();
             switch (numBands) {
@@ -297,6 +298,6 @@ check:  if (ImageUtilities.isIntegerType(targetModel)) {
                 return createPackedRGB();
             }
         }
-        return null;
+        return ColorModelFactory.createGrayScale(targetModel, 
ColorModelFactory.DEFAULT_VISIBLE_BAND, null);
     }
 }
diff --git 
a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/coverage/privy/ImageUtilities.java
 
b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/coverage/privy/ImageUtilities.java
index 3595c187f7..123bfeff39 100644
--- 
a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/coverage/privy/ImageUtilities.java
+++ 
b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/coverage/privy/ImageUtilities.java
@@ -611,6 +611,20 @@ public final class ImageUtilities extends Static {
         return r;
     }
 
+    /**
+     * Converts tile indices from the specified source image to the specified 
target image.
+     *
+     * @param  source  image for which tile indices are given.
+     * @param  tartet  image for which tile indices are desired.
+     * @param  tiles   ranges of indices of tiles in the source image.
+     * @return ranges of indices of tiles in the target image.
+     */
+    public static Rectangle convertTileIndices(RenderedImage source, 
RenderedImage target, Rectangle tiles) {
+        Rectangle pixels = tilesToPixels(source, tiles);
+        clipBounds(target, pixels);
+        return pixelsToTiles(target, pixels);
+    }
+
     /**
      * If scale and shear coefficients are close to integers, replaces their 
current values by their rounded values.
      * The scale and shear coefficients are handled in a "all or nothing" way; 
either all of them or none are rounded.
diff --git 
a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/internal/Resources.java
 
b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/internal/Resources.java
index 9b33976f3e..3bdf50aeb5 100644
--- 
a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/internal/Resources.java
+++ 
b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/internal/Resources.java
@@ -285,8 +285,7 @@ public class Resources extends IndexedResourceBundle {
         public static final short IterationNotStarted = 39;
 
         /**
-         * Image number of bands {0,number} does not match the number of 
sample dimensions
-         * ({1,number}).
+         * The image has {0,number} bands while the coverage has {1,number} 
sample dimensions.
          */
         public static final short MismatchedBandCount_2 = 40;
 
diff --git 
a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/internal/Resources.properties
 
b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/internal/Resources.properties
index 9caf223d54..df2a468bba 100644
--- 
a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/internal/Resources.properties
+++ 
b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/internal/Resources.properties
@@ -64,7 +64,7 @@ InsufficientBufferCapacity_3      = Data buffer capacity is 
insufficient for a g
 IterationIsFinished               = Iteration is finished.
 IterationNotStarted               = Iteration did not started.
 InvalidSampleDimensionIndex_2     = Sample dimension index {1} is invalid. 
Expected an index from 0 to {0} inclusive.
-MismatchedBandCount_2             = Image number of bands {0,number} does not 
match the number of sample dimensions ({1,number}).
+MismatchedBandCount_2             = The image has {0,number} bands while the 
coverage has {1,number} sample dimensions.
 MismatchedBandSize                = The bands have different number of sample 
values.
 MismatchedDataType                = The bands store sample values using 
different data types.
 MismatchedGeometryLibrary_2       = Expected a geometry from {0} library but 
got {1}.
diff --git 
a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/internal/Resources_fr.properties
 
b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/internal/Resources_fr.properties
index 3d685e17ac..4dfb9f41af 100644
--- 
a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/internal/Resources_fr.properties
+++ 
b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/internal/Resources_fr.properties
@@ -69,7 +69,7 @@ InsufficientBufferCapacity_3      = La capacit\u00e9 du 
buffer est insuffisante
 IterationIsFinished               = L\u2019it\u00e9ration est termin\u00e9e.
 IterationNotStarted               = L\u2019it\u00e9ration n\u2019a pas 
commenc\u00e9e.
 InvalidSampleDimensionIndex_2     = L\u2019index de dimension 
d\u2019\u00e9chantillonnage {1} est invalide. On attendait un index de 0 \u00e0 
{0} inclusif.
-MismatchedBandCount_2             = Le nombre de bandes de l\u2019image 
({0,number}) ne correspond pas au nombre de dimensions 
d\u2019\u00e9chantillonnage ({1,number}).
+MismatchedBandCount_2             = L\u2019image a {0,number} bandes alors que 
la couverture a {1,number} dimensions d\u2019\u00e9chantillonnage.
 MismatchedBandSize                = Les bandes ont un nombre diff\u00e9rent de 
valeurs.
 MismatchedDataType                = Les bandes stockent leurs valeurs en 
utilisant des types de donn\u00e9es diff\u00e9rents.
 MismatchedGeometryLibrary_2       = Une g\u00e9om\u00e9tries {0} \u00e9tait 
attendue mais l\u2019objet re\u00e7u est de {1}.
diff --git 
a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/BandAggregateImage.java
 
b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/BandAggregateImage.java
index 47b8bdc130..94cba95aa4 100644
--- 
a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/BandAggregateImage.java
+++ 
b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/BandAggregateImage.java
@@ -168,11 +168,13 @@ class BandAggregateImage extends MultiSourceImage {
      *
      * @param  layout     pixel and tile coordinate spaces of this image, 
together with sample model.
      * @param  colorizer  provider of color model to use for this image, or 
{@code null} for automatic.
+     * @param  parallel   whether parallel computation is allowed.
      */
     private BandAggregateImage(final BandAggregateLayout layout, final 
Colorizer colorizer,
                                final boolean allowSharing, final boolean 
parallel)
     {
-        super(layout, colorizer, parallel);
+        super(layout.filteredSources, layout.domain, layout.getMinTile(),
+              layout.sampleModel, layout.createColorModel(colorizer), 
parallel);
         this.allowSharing = allowSharing;
     }
 
diff --git 
a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/BandAggregateLayout.java
 
b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/BandAggregateLayout.java
index 744c9d84df..963b538255 100644
--- 
a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/BandAggregateLayout.java
+++ 
b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/BandAggregateLayout.java
@@ -101,7 +101,7 @@ final class BandAggregateLayout extends ImageLayout {
      *
      * @see #getMinTile()
      */
-    final int minTileX, minTileY;
+    private final int minTileX, minTileY;
 
     /**
      * Whether to use the preferred tile size exactly as specified, without 
trying to compute a better size.
diff --git 
a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/BandSelectImage.java
 
b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/BandSelectImage.java
index 3dbde637a6..b9a3727cd2 100644
--- 
a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/BandSelectImage.java
+++ 
b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/BandSelectImage.java
@@ -47,7 +47,7 @@ import org.apache.sis.coverage.privy.ObservableImage;
  */
 class BandSelectImage extends SourceAlignedImage {
     /**
-     * Properties to inherit from the source image, after bands reduction if 
applicable.
+     * Properties to inherit from the source images, after bands reduction if 
applicable.
      *
      * @see #getProperty(String)
      */
@@ -80,7 +80,7 @@ class BandSelectImage extends SourceAlignedImage {
     private BandSelectImage(final RenderedImage source, final ColorModel cm, 
final int[] bands) {
         super(source, cm, 
source.getSampleModel().createSubsetSampleModel(bands));
         this.bands = bands;
-        ensureCompatible(cm);
+        ensureCompatible(sampleModel, cm);
     }
 
     /**
@@ -145,7 +145,7 @@ class BandSelectImage extends SourceAlignedImage {
         if (cm != null && source instanceof BufferedImage) {
             final BufferedImage bi = (BufferedImage) source;
             @SuppressWarnings("UseOfObsoleteCollectionType")
-            final Hashtable<String,Object> properties = new Hashtable<>(8);
+            final var properties = new Hashtable<String,Object>(8);
             for (final String key : INHERITED_PROPERTIES) {
                 final Object value = getProperty(bi, key, bands);
                 if (value != Image.UndefinedProperty) {
diff --git 
a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/BandedSampleConverter.java
 
b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/BandedSampleConverter.java
index 43153dfab3..d120f30c34 100644
--- 
a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/BandedSampleConverter.java
+++ 
b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/BandedSampleConverter.java
@@ -126,7 +126,7 @@ class BandedSampleConverter extends WritableComputedImage {
         this.colorModel = colorModel;
         this.converters = converters;
         this.sampleDimensions = sampleDimensions;
-        ensureCompatible(colorModel);
+        ensureCompatible(sampleModel, colorModel);
         /*
          * Get an estimation of the resolution, arbitrarily looking in the 
middle of the range of values.
          * If the converters are linear (which is the most common case), the 
middle value does not matter
@@ -403,12 +403,11 @@ class BandedSampleConverter extends WritableComputedImage 
{
      * forwards the notification to it. Otherwise default implementation does 
nothing.
      */
     @Override
-    protected Disposable prefetch(final Rectangle tiles) {
+    protected Disposable prefetch(Rectangle tiles) {
         final RenderedImage source = getSource();
         if (source instanceof PlanarImage) {
-            final Rectangle pixels = ImageUtilities.tilesToPixels(this, tiles);
-            ImageUtilities.clipBounds(source, pixels);
-            return ((PlanarImage) 
source).prefetch(ImageUtilities.pixelsToTiles(source, pixels));
+            tiles = ImageUtilities.convertTileIndices(this, source, tiles);
+            return ((PlanarImage) source).prefetch(tiles);
         } else {
             return super.prefetch(tiles);
         }
diff --git 
a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/Colorizer.java 
b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/Colorizer.java
index cc55809967..18a8f28754 100644
--- 
a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/Colorizer.java
+++ 
b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/Colorizer.java
@@ -161,7 +161,7 @@ public interface Colorizer extends 
Function<Colorizer.Target, Optional<ColorMode
      * The color model is <abbr>RGB</abbr> for image having 3 bands, or 
<abbr>ARGB</abbr> for images having 4 bands.
      * In the latter case, the color components are considered <em>not</em> 
premultiplied by the alpha value.
      */
-    Colorizer ARGB = (target) -> Optional.ofNullable(new 
ColorModelBuilder().create(target.getSampleModel()));
+    Colorizer ARGB = (target) -> Optional.ofNullable(new 
ColorModelBuilder().createRGB(target.getSampleModel()));
 
     /**
      * Creates a colorizer which will interpolate the given colors in the 
given range of values.
diff --git 
a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/ComputedImage.java
 
b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/ComputedImage.java
index 3e5fa5fa56..0f327285f5 100644
--- 
a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/ComputedImage.java
+++ 
b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/ComputedImage.java
@@ -29,13 +29,11 @@ import java.awt.image.Raster;
 import java.awt.image.WritableRaster;
 import java.awt.image.WritableRenderedImage;
 import java.awt.image.RenderedImage;
-import java.awt.image.ColorModel;
 import java.awt.image.SampleModel;
 import java.awt.image.TileObserver;
 import java.awt.image.ImagingOpException;
 import org.apache.sis.util.ArgumentChecks;
 import org.apache.sis.util.ArraysExt;
-import org.apache.sis.util.Classes;
 import org.apache.sis.util.Disposable;
 import org.apache.sis.util.Exceptions;
 import org.apache.sis.util.privy.Numerics;
@@ -262,24 +260,6 @@ public abstract class ComputedImage extends PlanarImage 
implements Disposable {
         reference = new ComputedTiles(this, ws);    // Create cleaner last 
after all arguments have been validated.
     }
 
-    /**
-     * Ensures that a user supplied color model is compatible with the sample 
model.
-     * This is a helper method for argument validation in sub-classes 
constructors.
-     *
-     * @param  colors  the color model to validate. Can be {@code null}.
-     * @throws IllegalArgumentException if the color model is incompatible.
-     */
-    final void ensureCompatible(final ColorModel colors) {
-        final String reason = verifyCompatibility(sampleModel, colors);
-        if (reason != null) {
-            String message = 
Resources.format(Resources.Keys.IncompatibleColorModel);
-            if (!reason.isEmpty()) {
-                message = message + ' ' + 
Errors.format(Errors.Keys.IllegalValueForProperty_2, 
Classes.getShortClassName(colors), reason);
-            }
-            throw new IllegalArgumentException(message);
-        }
-    }
-
     /**
      * Returns a weak reference to this image. Using weak reference instead of 
strong reference may help to
      * reduce memory usage when recomputing the image is cheap. This method 
should not be public because the
@@ -530,6 +510,7 @@ public abstract class ComputedImage extends PlanarImage 
implements Disposable {
                      * and `releaseWritableTile(…)` method calls.
                      */
                     int min;
+                    @SuppressWarnings("LocalVariableHidesMemberVariable")
                     final WritableRenderedImage destination = 
this.destination;     // Protect from change (paranoiac).
                     final boolean writeInDestination = (destination != null)
                             && (tileX >= (min = destination.getMinTileX()) && 
tileX < min + destination.getNumXTiles())
@@ -848,7 +829,7 @@ public abstract class ComputedImage extends PlanarImage 
implements Disposable {
      */
     final boolean equalsBase(final Object object) {
         if (object != null && getClass().equals(object.getClass())) {
-            final ComputedImage other = (ComputedImage) object;
+            final var other = (ComputedImage) object;
             return Arrays .equals(sources,     other.sources) &&
                    Objects.equals(destination, other.destination) &&
                    sampleModel.equals(other.sampleModel);
diff --git 
a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/ImageAdapter.java
 
b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/ImageAdapter.java
index a788831dee..ad308f8b47 100644
--- 
a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/ImageAdapter.java
+++ 
b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/ImageAdapter.java
@@ -69,7 +69,7 @@ abstract class ImageAdapter extends PlanarImage {
     @Override
     @SuppressWarnings("UseOfObsoleteCollectionType")
     public final Vector<RenderedImage> getSources() {
-        final Vector<RenderedImage> sources = new Vector<>(1);
+        final var sources = new Vector<RenderedImage>(1);
         sources.add(source);
         return sources;
     }
@@ -180,7 +180,7 @@ abstract class ImageAdapter extends PlanarImage {
      */
     @Override
     public String toString() {
-        final StringBuilder buffer = new StringBuilder(100);
+        final var buffer = new StringBuilder(100);
         final Class<?> subtype = appendStringContent(buffer.append('['));
         return buffer.insert(0, subtype.getSimpleName()).append(" on 
").append(source).append(']').toString();
     }
diff --git 
a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/ImageOverlay.java
 
b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/ImageOverlay.java
new file mode 100644
index 0000000000..2810bfd375
--- /dev/null
+++ 
b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/ImageOverlay.java
@@ -0,0 +1,370 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.sis.image;
+
+import java.awt.Image;
+import java.awt.Point;
+import java.awt.Dimension;
+import java.awt.Rectangle;
+import java.awt.image.Raster;
+import java.awt.image.ColorModel;
+import java.awt.image.SampleModel;
+import java.awt.image.RenderedImage;
+import java.awt.image.WritableRaster;
+import java.util.Objects;
+import java.util.LinkedHashMap;
+import java.util.function.Function;
+import java.util.function.BiConsumer;
+import javax.measure.Quantity;
+import javax.measure.UnconvertibleException;
+import org.apache.sis.util.ArraysExt;
+import org.apache.sis.util.logging.Logging;
+import org.apache.sis.math.Statistics;
+import org.apache.sis.measure.Quantities;
+import org.apache.sis.feature.internal.Resources;
+import org.apache.sis.coverage.privy.ImageLayout;
+import org.apache.sis.coverage.privy.ImageUtilities;
+
+
+/**
+ * An overlay of an arbitrary number of images. All images have the same pixel 
coordinate system,
+ * but potentially different bounding boxes, tile sizes and tile indices. 
Source images are drawn
+ * in reverse order: the last source image is drawn first, and the first 
source image is drawn last
+ * on top of all other images. The requirements are:
+ *
+ * <ul>
+ *   <li>All source images shall have the same pixel coordinate systems (but 
not necessarily the same tile matrix).</li>
+ *   <li>All source images shall have the same number of bands (but not 
necessarily the same sample model).</li>
+ *   <li>All source images should have equivalent color model, otherwise color 
consistency is not guaranteed.</li>
+ *   <li>At least one image shall intersect the given bounds.</li>
+ * </ul>
+ *
+ * This class can also be opportunistically used for reformatting a single 
image.
+ *
+ * @author  Martin Desruisseaux (Geomatys)
+ */
+final class ImageOverlay extends MultiSourceImage {
+    /**
+     * Creates a new image overlay or returns one of the given sources if 
equivalent.
+     * All source images shall have the same pixels coordinate system and the 
same number of bands.
+     * The returned image may have less sources than the specified ones if 
this method determines
+     * that some sources will never be drawn. This method may return {@code 
sources[0]} directly.
+     *
+     * @param  sources       the images to overlay. Null array elements are 
ignored.
+     * @param  bounds        range of pixel coordinates, or {@code null} for 
the union of all source images.
+     * @param  sampleModel   the sample model, of {@code null} for automatic.
+     * @param  colorModel    the color model, of {@code null} for automatic.
+     * @param  autoTileSize  whether this method is allowed to change the tile 
size.
+     * @param  parallel      whether parallel computation is allowed.
+     * @return the image overlay, or one of the given sources if only one is 
suitable.
+     * @throws IllegalArgumentException if there is an incompatibility between 
some source images
+     *         or if no image intersect the bounds.
+     */
+    static RenderedImage create(RenderedImage[] sources, Rectangle bounds, 
SampleModel sampleModel, ColorModel colorModel,
+                                final boolean autoTileSize, final boolean 
parallel)
+    {
+        /*
+         * Filter the source images for keeping only the ones that intersect 
the bounds.
+         * Check image compatibility (number of bands) and color model in the 
same loop.
+         * If there is only one image left after filtering, it may be returned 
directly.
+         */
+        int numBands=0, count=0;
+        final boolean computeUnion = (bounds == null);
+        final var sourceBounds = new Rectangle[sources.length];
+        sources = sources.clone();
+next:   for (final RenderedImage source : sources) {
+            final int n = ImageUtilities.getNumBands(source);
+            if (n == 0) continue;       // Skip null elements.
+            if (n != numBands) {
+                if (numBands != 0) {
+                    throw new 
IllegalArgumentException(Resources.format(Resources.Keys.UnexpectedNumberOfBands_2,
 numBands, n));
+                }
+                numBands = n;
+            }
+            /*
+             * If the current source does not intersect the specified area of 
interest, or if a previous source
+             * fully overlaps the current source, then the latter image will 
never be drawn and can be omitted.
+             */
+            Rectangle aoi = ImageUtilities.getBounds(source);
+            if (computeUnion) {
+                if (bounds == null) {
+                    bounds = new Rectangle(aoi);
+                } else {
+                    bounds.add(aoi);
+                }
+            } else {
+                aoi = aoi.intersection(bounds);
+                if (aoi.isEmpty()) continue;
+            }
+            for (int i=0; i<count; i++) {
+                if (sourceBounds[i].contains(aoi)) {
+                    continue next;
+                }
+            }
+            sourceBounds[count] = aoi;
+            sources[count++] = source;
+            /*
+             * The default sample model is selected after filtering because 
the choice of a sample model
+             * does not change the visual, while it has an incidence on 
performance: it is better if the
+             * tile matrix of this image matches the tile matrix of the main 
image. The choice of a color
+             * model may change the visual, but is kept together with the 
sample model for simplicity and
+             * for reducing the risk that an image is rendered with the wrong 
colors.
+             */
+            if (sampleModel == null) {
+                sampleModel = source.getSampleModel();      // Should never be 
null.
+            }
+            if (colorModel == null) {
+                final ColorModel candidate = source.getColorModel();
+                if (candidate != null && 
candidate.isCompatibleSampleModel(sampleModel)) {
+                    colorModel = candidate;
+                }
+            }
+        }
+        /*
+         * Except if there is no image, the sample model should be non-null at 
this point.
+         * However, the color model may still be null if none was specified in 
argument and
+         * no compatible color model was found in the source images. Leave 
thet color model
+         * to null (i.e., we don't invent colors when we don't know what they 
should be).
+         */
+        if (count == 0) {
+            throw new 
IllegalArgumentException(Resources.format(Resources.Keys.SourceImagesDoNotIntersect));
+        }
+        final RenderedImage main = sources[0];
+        if (count == 1 && sampleModel.equals(main.getSampleModel())) {
+            return (colorModel != null) ? RecoloredImage.apply(main, 
colorModel) : main;
+        }
+        sources = ArraysExt.resize(sources, count);
+        /*
+         * If the tile size is not a divisor of the image size, try to find a 
better tile size.
+         */
+        if (autoTileSize) {
+            var tileSize = new Dimension(sampleModel.getWidth(), 
sampleModel.getHeight());
+            if ((bounds.width % tileSize.width) != 0 || (bounds.height % 
tileSize.height) != 0) {
+                tileSize = new ImageLayout(tileSize, 
false).suggestTileSize(bounds.width, bounds.height, true);
+                sampleModel = 
sampleModel.createCompatibleSampleModel(tileSize.width, tileSize.height);
+            }
+        }
+        var minTile = new Point(ImageUtilities.pixelToTileX(main, bounds.x),
+                                ImageUtilities.pixelToTileY(main, bounds.y));
+        return ImageProcessor.unique(new ImageOverlay(sources, bounds, 
minTile, sampleModel, colorModel, parallel));
+    }
+
+    /**
+     * Creates a new image overlay.
+     */
+    private ImageOverlay(final RenderedImage[] sources, final Rectangle 
bounds, final Point minTile,
+                         final SampleModel sampleModel, final ColorModel 
colorModel,
+                         final boolean parallel)
+    {
+        super(sources, bounds, minTile, sampleModel, colorModel, parallel);
+    }
+
+    /**
+     * Returns the names of all recognized properties, or {@code null} if this 
image has no properties.
+     * The implementation iterates over all sources images on the assumption 
that there is not many of them.
+     * We do not cache the result for making sure that any change in the 
sources is reflected here.
+     */
+    @Override
+    public String[] getPropertyNames() {
+        final int n = getNumSources();
+        final var count = new LinkedHashMap<String,Integer>();
+        for (int i=0; i<n; i++) {
+            final String[] names = getSource(i).getPropertyNames();
+            if (names != null) {
+                for (String name : names) {
+                    /*
+                     * This switch shall contain the same cases as in the 
`getProperty(String)` method.
+                     * For properties considered present as soon as it is 
defined in at least one source,
+                     * we set the count directly to `n`. For properties that 
must be present in all sources,
+                     * we count their occurrences.
+                     */
+                    switch (name) {
+                        case GRID_GEOMETRY_KEY:
+                        case SAMPLE_DIMENSIONS_KEY:
+                        case POSITIONAL_ACCURACY_KEY: count.put(name, n); 
break;
+                        case SAMPLE_RESOLUTIONS_KEY:
+                        case STATISTICS_KEY: count.merge(name, 1, 
Math::addExact); break;
+                    }
+                }
+            }
+        }
+        count.values().removeIf((v) -> v != n);
+        return count.isEmpty() ? null : count.keySet().toArray(String[]::new);
+    }
+
+    /**
+     * Gets the property of the given name. Each property is derived from the 
source images in its own way.
+     * For example, {@link #STATISTICS_KEY} is computed by combining the 
statistics provided by each source,
+     * while {@link #SAMPLE_RESOLUTIONS_KEY} takes for each band the minimal 
values of all sources.
+     *
+     * <h4>Implementation note</h4>
+     * This method does not cache the property values on the assumption that 
there is not many sources,
+     * that these sources already have their own cache and that merging the 
values is efficient enough.
+     * This approach avoids the need to clone the cached values and to respond 
to events that may change
+     * the cached values.
+     *
+     * @param  name  name of the property to compute.
+     * @return property value (may be {@code null}), or {@link 
Image#UndefinedProperty} if none.
+     */
+    @Override
+    public Object getProperty(final String key) {
+        switch (key) {
+            case GRID_GEOMETRY_KEY:       // Fall through
+            case SAMPLE_DIMENSIONS_KEY:   return getConstantProperty(key);
+            case POSITIONAL_ACCURACY_KEY: return getCombinedProperty(key, 
Quantity[].class,   (q) -> q.clone(),            ImageOverlay::combine, false);
+            case SAMPLE_RESOLUTIONS_KEY:  return getCombinedProperty(key, 
double[].class,     double[]::clone,             ImageOverlay::combine, true);
+            case STATISTICS_KEY:          return getCombinedProperty(key, 
Statistics[].class, StatisticsCalculator::clone, ImageOverlay::combine, true);
+            default:                      return Image.UndefinedProperty;
+        }
+    }
+
+    /**
+     * Returns a property value which is expected to be constant in all source 
images, ignoring undefined values.
+     * If the property is not constant, then this method returns {@link 
Image#UndefinedProperty}.
+     *
+     * @param  key  name of the property to get.
+     * @return property value (may be {@code null}), or {@link 
Image#UndefinedProperty} if none.
+     */
+    private Object getConstantProperty(final String key) {
+        Object result = Image.UndefinedProperty;
+        final int n = getNumSources();
+        for (int i=0; i<n; i++) {
+            final Object c = getSource(i).getProperty(key);
+            if (c != Image.UndefinedProperty) {
+                if (result == Image.UndefinedProperty) {
+                    result = c;
+                } else if (!Objects.deepEquals(result, c)) {
+                    return Image.UndefinedProperty;
+                }
+            }
+        }
+        return result;
+    }
+
+    /**
+     * Returns a property value which is computed by combining the values from 
all source images.
+     * Undefined values are ignored if {@code required} is false. If {@code 
required} is true,
+     * then any missing value will cause this method to return {@link 
Image#UndefinedProperty}.
+     *
+     * @param  <V>       compile-time value of the {@code type} argument.
+     * @param  key       name of the property to get.
+     * @param  type      type of values to combine. Often an array type.
+     * @param  cloner    method creating a clone of the first value found.
+     * @param  combiner  method updating the clone with more values.
+     * @param  required  whether the property must be provided in all images 
for being considered defined.
+     * @return property value, or {@link Image#UndefinedProperty} if none.
+     */
+    private <V> Object getCombinedProperty(final String key, final Class<V> 
type,
+            final Function<V,V> cloner, final BiConsumer<V,V> combiner, final 
boolean required)
+    {
+        V result = null;
+        final int n = getNumSources();
+        for (int i=0; i<n; i++) {
+            final Object value = getSource(i).getProperty(key);
+            if (type.isInstance(value)) {
+                @SuppressWarnings("unchecked")
+                final V c = (V) value;
+                if (result == null) {
+                    result = cloner.apply(c);
+                } else try {
+                    combiner.accept(result, c);
+                } catch (UnconvertibleException e) {
+                    Logging.recoverableException(ImageUtilities.LOGGER, 
ImageOverlay.class, "getProperty", e);
+                    return Image.UndefinedProperty;
+                }
+            } else if (required) {
+                return Image.UndefinedProperty;
+            }
+        }
+        return (result != null) ? result : Image.UndefinedProperty;
+    }
+
+    /**
+     * Combines the statistics of previous source images with statistics of a 
new source image.
+     * This method is invoked for computing the {@value #STATISTICS_KEY} 
property.
+     *
+     * @param result  combination done so for.
+     * @param more    statistics of another source to combine.
+     */
+    private static void combine(final Statistics[] result, final Statistics[] 
more) {
+        for (int i = Math.min(result.length, more.length); --i >= 0;) {
+            result[i].combine(more[i]);
+        }
+    }
+
+    /**
+     * Combines the resolution of previous source images with resolution of a 
new source image.
+     * This method is invoked for computing the {@value 
#SAMPLE_RESOLUTIONS_KEY} property.
+     * The minimum value is retained because this property is about 
resolution, not accuracy.
+     * It is used for computing the number of fraction digits needed to 
distinguish the values of two cells.
+     *
+     * @param result  combination done so for.
+     * @param more    resolution of another source to combine.
+     */
+    private static void combine(final double[] result, final double[] more) {
+        for (int i = Math.min(result.length, more.length); --i >= 0;) {
+            final double value = more[i];
+            final double previous = result[i];
+            if (value < previous || Double.isNaN(previous)) {
+                result[i] = value;
+            }
+        }
+    }
+
+    /**
+     * Combines the positional accuracy of previous source images with 
accuracy of a new source image.
+     * This method is invoked for computing the {@value 
#POSITIONAL_ACCURACY_KEY} property.
+     *
+     * <p>This method signature is unsafe. However, Apache <abbr>SIS</abbr> 
implementation of
+     * the {@link Quantities#min(Quantity, Quantity)} method performs the 
required checks.</p>
+     *
+     * @param  result  combination done so for.
+     * @param  more    positional accuracy of another source to combine.
+     * @throws UnconvertibleException if the quantities are not comparable.
+     */
+    @SuppressWarnings({"rawtypes", "unchecked"})    // See method Javadoc.
+    private static void combine(final Quantity[] result, final Quantity[] 
more) {
+        for (int i = Math.min(result.length, more.length); --i >= 0;) {
+            result[i] = Quantities.max(result[i], more[i]);
+        }
+    }
+
+    /**
+     * Computes the tile at specified indices.
+     *
+     * @param  tileX   the column index of the tile to compute.
+     * @param  tileY   the row index of the tile to compute.
+     * @param  target  if the tile already exists but needs to be updated, the 
tile to update. Otherwise {@code null}.
+     * @return computed tile for the given indices (cannot be null).
+     */
+    @Override
+    protected Raster computeTile(final int tileX, final int tileY, 
WritableRaster target) {
+        if (target == null) {
+            target = createTile(tileX, tileY);
+        }
+        final int n = getNumSources();
+        for (int i=n; --i >= 0;) {
+            final RenderedImage source = getSource(i);
+            final Rectangle bounds = getBounds();
+            ImageUtilities.clipBounds(source, bounds);
+            if (!bounds.isEmpty()) {
+                copyData(bounds, source, target);
+            }
+        }
+        return target;
+    }
+}
diff --git 
a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/ImageProcessor.java
 
b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/ImageProcessor.java
index 6531270223..db2faaefa2 100644
--- 
a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/ImageProcessor.java
+++ 
b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/ImageProcessor.java
@@ -169,6 +169,15 @@ public class ImageProcessor implements Cloneable {
      */
     private ImageLayout layout;
 
+    /**
+     * Whether the processor is allowed to change the tile size. This 
configuration is relevant
+     * only for operations taking a {@link SampleModel} in argument, which 
implies a tile size.
+     *
+     * @see Resizing#CHANGE_TILING
+     * @see #setImageResizingPolicy(Resizing)
+     */
+    private boolean autoTileSize;
+
     /**
      * Whether {@code ImageProcessor} can produce an image of different size 
compared to requested size.
      * An image may be resized if the requested size cannot be subdivided into 
tiles of reasonable size.
@@ -183,12 +192,21 @@ public class ImageProcessor implements Cloneable {
      */
     public enum Resizing {
         /**
-         * Image size is unmodified; the requested value is used 
unconditionally.
+         * Image size is unmodified, the requested value is used 
unconditionally.
          * It may result in big tiles (potentially a single tile for the whole 
image)
          * if the image size is not divisible by a tile size.
          */
         NONE,
 
+        /**
+         * The tile size can be modified, but not the image size. This 
resizing policy can
+         * be used with operations where a {@link SampleModel} argument 
implies a tile size.
+         * For other operations, this resizing policy is equivalent to {@link 
#NONE}.
+         *
+         * @since 1.5
+         */
+        CHANGE_TILING,
+
         /**
          * Image size can be increased. {@code ImageProcessor} will try to 
increase
          * by the smallest number of pixels allowing the image to be 
subdivided in tiles.
@@ -398,7 +416,8 @@ public class ImageProcessor implements Cloneable {
      * @return the image resizing policy.
      */
     public synchronized Resizing getImageResizingPolicy() {
-        return layout.isBoundsAdjustmentAllowed ? Resizing.EXPAND : 
Resizing.NONE;
+        return layout.isBoundsAdjustmentAllowed ? Resizing.EXPAND :
+                autoTileSize ? Resizing.CHANGE_TILING : Resizing.NONE;
     }
 
     /**
@@ -410,6 +429,7 @@ public class ImageProcessor implements Cloneable {
         layout = (Objects.requireNonNull(policy) == Resizing.EXPAND)
                 ? ImageLayout.SIZE_ADJUST
                 : ImageLayout.DEFAULT;
+        autoTileSize = (policy == Resizing.CHANGE_TILING);
     }
 
     /**
@@ -933,6 +953,7 @@ public class ImageProcessor implements Cloneable {
      *
      * @since 1.4
      */
+    @SuppressWarnings("LocalVariableHidesMemberVariable")
     public RenderedImage aggregateBands(final RenderedImage[] sources, final 
int[][] bandsPerSource) {
         ArgumentChecks.ensureNonEmpty("sources", sources);
         final Colorizer colorizer;
@@ -944,6 +965,83 @@ public class ImageProcessor implements Cloneable {
         return BandAggregateImage.create(sources, bandsPerSource, colorizer, 
true, true, parallel);
     }
 
+    /**
+     * Creates a new image overlay or returns one of the given sources if 
equivalent.
+     * All source images shall have the same pixel coordinate system, but they 
may have different bounding boxes,
+     * tile sizes and tile indices. Images are drawn in reverse order: the 
last source image is drawn first, and
+     * the first source image is drawn last on top of all other images. The 
returned image may have less sources
+     * than the specified ones if this method determines that some sources 
will never be drawn.
+     * This method may return {@code sources[0]} directly.
+     *
+     * <p>All source images shall have the same number of bands (but not 
necessarily the same sample model).
+     * All source images should have equivalent color model, otherwise color 
consistency is not guaranteed.
+     * At least one image shall intersect the given bounds.</p>
+     *
+     * <h4>Properties used</h4>
+     * This operation uses the following properties in addition to method 
parameters:
+     * <ul>
+     *   <li>{@linkplain #getImageResizingPolicy() Image resizing policy} for 
specifying whether
+     *       this method is allowed to change the tile size implied by the 
given sample model.</li>
+     * </ul>
+     *
+     * @param  sources      the images to overlay. Null array elements are 
ignored.
+     * @param  bounds       range of pixel coordinates, or {@code null} for 
the union of all source images.
+     * @param  sampleModel  the sample model, of {@code null} for automatic.
+     * @param  colorModel   the color model, of {@code null} for automatic.
+     * @return the image overlay, or one of the given sources if only one is 
suitable.
+     * @throws IllegalArgumentException if there is an incompatibility between 
some source images
+     *         or if no image intersect the given bounds.
+     *
+     * @since 1.5
+     */
+    @SuppressWarnings("LocalVariableHidesMemberVariable")
+    public RenderedImage overlay(final RenderedImage[] sources, final 
Rectangle bounds,
+                                 final SampleModel sampleModel, final 
ColorModel colorModel)
+    {
+        ArgumentChecks.ensureNonEmpty("sources", sources);
+        final boolean parallel;
+        final boolean autoTileSize;
+        synchronized (this) {
+            autoTileSize = this.autoTileSize;
+            parallel = executionMode != Mode.SEQUENTIAL;
+        }
+        return ImageOverlay.create(sources, bounds, sampleModel, colorModel, 
autoTileSize | (bounds != null), parallel);
+    }
+
+    /**
+     * Reformats the given image with a different sample model.
+     * This operation <em>copies</em> the pixel values in a new image.
+     * Despite the copies being done on a tile-by-tile basis when each tile is 
 first requested,
+     * this is still a relatively costly operation compared to the usual 
Apache <abbr>SIS</abbr>
+     * approach of creating views as much as possible. Therefore, this method 
should be used only
+     * when necessary.
+     *
+     * <h4>Properties used</h4>
+     * This operation uses the following properties in addition to method 
parameters:
+     * <ul>
+     *   <li>{@linkplain #getImageResizingPolicy() Image resizing policy} for 
specifying whether
+     *       this method is allowed to change the tile size implied by the 
given sample model.</li>
+     * </ul>
+     *
+     * @param  source       the images to reformat.
+     * @param  sampleModel  the desired sample model.
+     * @return the reformatted image.
+     *
+     * @since 1.5
+     */
+    @SuppressWarnings("LocalVariableHidesMemberVariable")
+    public RenderedImage reformat(final RenderedImage source, final 
SampleModel sampleModel) {
+        ArgumentChecks.ensureNonNull("source", source);
+        ArgumentChecks.ensureNonNull("sampleModel", sampleModel);
+        final boolean parallel;
+        final boolean autoTileSize;
+        synchronized (this) {
+            autoTileSize = this.autoTileSize;
+            parallel = executionMode != Mode.SEQUENTIAL;
+        }
+        return ImageOverlay.create(new RenderedImage[] {source}, null, 
sampleModel, null, autoTileSize, parallel);
+    }
+
     /**
      * Applies a mask defined by a geometric shape. If {@code maskInside} is 
{@code true},
      * then all pixels inside the given shape are set to the {@linkplain 
#getFillValues() fill values}.
@@ -965,6 +1063,7 @@ public class ImageProcessor implements Cloneable {
      *
      * @since 1.2
      */
+    @SuppressWarnings("LocalVariableHidesMemberVariable")
     public RenderedImage mask(final RenderedImage source, final Shape mask, 
final boolean maskInside) {
         ArgumentChecks.ensureNonNull("source", source);
         ArgumentChecks.ensureNonNull("mask",   mask);
@@ -1017,6 +1116,7 @@ public class ImageProcessor implements Cloneable {
      *
      * @since 1.4
      */
+    @SuppressWarnings("LocalVariableHidesMemberVariable")
     public RenderedImage convert(final RenderedImage source, final 
NumberRange<?>[] sourceRanges,
                                  MathTransform1D[] converters, final DataType 
targetType)
     {
@@ -1084,6 +1184,7 @@ public class ImageProcessor implements Cloneable {
      *
      * @see GridCoverageProcessor#resample(GridCoverage, GridGeometry)
      */
+    @SuppressWarnings("LocalVariableHidesMemberVariable")
     public RenderedImage resample(RenderedImage source, final Rectangle 
bounds, MathTransform toSource) {
         ArgumentChecks.ensureNonNull("source",   source);
         ArgumentChecks.ensureNonNull("bounds",   bounds);
@@ -1389,6 +1490,7 @@ public class ImageProcessor implements Cloneable {
      * @return whether the other object is an image processor of the same 
class with the same configuration.
      */
     @Override
+    @SuppressWarnings("LocalVariableHidesMemberVariable")
     public boolean equals(final Object object) {
         if (object != null && object.getClass() == getClass()) {
             final ImageProcessor other = (ImageProcessor) object;
diff --git 
a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/MultiSourceImage.java
 
b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/MultiSourceImage.java
index 1cb416dd13..957646ca0b 100644
--- 
a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/MultiSourceImage.java
+++ 
b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/MultiSourceImage.java
@@ -20,6 +20,8 @@ import java.awt.Point;
 import java.awt.Rectangle;
 import java.util.Objects;
 import java.awt.image.ColorModel;
+import java.awt.image.SampleModel;
+import java.awt.image.RenderedImage;
 import java.awt.image.WritableRenderedImage;
 import org.apache.sis.coverage.privy.ImageUtilities;
 import org.apache.sis.util.Disposable;
@@ -39,6 +41,7 @@ import org.apache.sis.util.Disposable;
 abstract class MultiSourceImage extends WritableComputedImage {
     /**
      * Color model of this image.
+     * A null value is allowed but not recommended.
      *
      * @see #getColorModel()
      */
@@ -66,22 +69,28 @@ abstract class MultiSourceImage extends 
WritableComputedImage {
     /**
      * Creates a new multi-sources image.
      *
-     * @param  layout     pixel and tile coordinate spaces of this image, 
together with sample model.
-     * @param  colorizer  provider of color model to use for this image, or 
{@code null} for automatic.
-     * @param  parallel   whether parallel computation is allowed.
+     * @param  sources      sources of this image.
+     * @param  bounds       range of pixel coordinates of this image.
+     * @param  minTile      indices of the first tile in this image.
+     * @param  sampleModel  the sample model shared by all tiles in this image.
+     * @param  colorModel   the color model of the image, or {@code null} if 
none.
+     * @param  parallel     whether parallel computation is allowed.
+     * @throws IllegalArgumentException if the color model is incompatible 
with the sample model.
      */
-    MultiSourceImage(final BandAggregateLayout layout, final Colorizer 
colorizer, final boolean parallel) {
-        super(layout.sampleModel, layout.filteredSources);
-        final Rectangle r = layout.domain;
-        minX            = r.x;
-        minY            = r.y;
-        width           = r.width;
-        height          = r.height;
-        minTileX        = layout.minTileX;
-        minTileY        = layout.minTileY;
-        colorModel      = layout.createColorModel(colorizer);
-        ensureCompatible(colorModel);
-        this.parallel = parallel;
+    MultiSourceImage(final RenderedImage[] sources, final Rectangle bounds, 
final Point minTile,
+                     final SampleModel sampleModel, final ColorModel 
colorModel,
+                     final boolean parallel)
+    {
+        super(sampleModel, sources);
+        this.colorModel = colorModel;
+        this.minX       = bounds.x;
+        this.minY       = bounds.y;
+        this.width      = bounds.width;
+        this.height     = bounds.height;
+        this.minTileX   = minTile.x;
+        this.minTileY   = minTile.y;
+        this.parallel   = parallel;
+        ensureCompatible(sampleModel, colorModel);
     }
 
     /** Returns the information inferred at construction time. */
@@ -134,7 +143,7 @@ abstract class MultiSourceImage extends 
WritableComputedImage {
     @Override
     public boolean equals(final Object object) {
         if (equalsBase(object)) {
-            final MultiSourceImage other = (MultiSourceImage) object;
+            final var other = (MultiSourceImage) object;
             return parallel == other.parallel &&
                    minTileX == other.minTileX &&
                    minTileY == other.minTileY &&
diff --git 
a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/PlanarImage.java
 
b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/PlanarImage.java
index cc3018309a..6fc40e2987 100644
--- 
a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/PlanarImage.java
+++ 
b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/PlanarImage.java
@@ -38,6 +38,7 @@ import org.apache.sis.coverage.grid.GridGeometry;       // 
For javadoc
 import org.apache.sis.coverage.privy.ImageUtilities;
 import org.apache.sis.coverage.privy.TileOpExecutor;
 import org.apache.sis.coverage.privy.ColorModelFactory;
+import org.apache.sis.feature.internal.Resources;
 import org.apache.sis.pending.jdk.JDK18;
 
 
@@ -161,6 +162,9 @@ public abstract class PlanarImage implements RenderedImage {
      * This information can be used for choosing the number of fraction digits 
to show when writing sample values
      * in text format.
      *
+     * <p><em>Resolution is not accuracy.</em>
+     * There is no guarantee that the data accuracy is as good as the 
resolution given by this property.</p>
+     *
      * <p>Values should be instances of {@code double[]}.
      * The array length should be the number of bands. This property may be 
computed automatically during
      * {@linkplain 
org.apache.sis.coverage.grid.GridCoverage#forConvertedValues(boolean) 
conversions from
@@ -463,7 +467,7 @@ public abstract class PlanarImage implements RenderedImage {
      * Copies an arbitrary rectangular region of this image to the supplied 
writable raster.
      * The region to be copied is determined from the bounds of the supplied 
raster.
      * The supplied raster must have a {@link SampleModel} that is compatible 
with this image.
-     * If the raster is {@code null}, an raster is created by this method.
+     * If the given raster is {@code null}, a new raster is created by this 
method.
      *
      * @param  raster  the raster to hold a copy of this image, or {@code 
null}.
      * @return the given raster if it was not-null, or a new raster otherwise.
@@ -527,6 +531,27 @@ public abstract class PlanarImage implements RenderedImage 
{
         return null;
     }
 
+    /**
+     * Ensures that a user supplied color model is compatible with the sample 
model.
+     * This is a helper method for argument validation in sub-classes 
constructors.
+     *
+     * @param  sampleModel the sample model of this image.
+     * @param  colors  the color model to validate. Can be {@code null}.
+     * @throws IllegalArgumentException if the color model is incompatible.
+     */
+    static void ensureCompatible(final SampleModel sampleModel, final 
ColorModel colors) {
+        final String erroneous = verifyCompatibility(sampleModel, colors);
+        if (erroneous != null) {
+            String message = 
Resources.format(Resources.Keys.IncompatibleColorModel);
+            if (!erroneous.isEmpty()) {
+                String complement = Classes.getShortClassName(colors);
+                complement = 
Errors.format(Errors.Keys.IllegalValueForProperty_2, complement, erroneous);
+                message = Resources.concatenate(message, complement);
+            }
+            throw new IllegalArgumentException(message);
+        }
+    }
+
     /**
      * Verifies if the color model is compatible with the sample model.
      * If the color model is incompatible, then this method returns the name 
of the mismatched property.
@@ -537,7 +562,7 @@ public abstract class PlanarImage implements RenderedImage {
      * @return name of mismatched property (an empty string if unidentified),
      *         or {@code null} if the color model is null or is compatible.
      */
-    static String verifyCompatibility(final SampleModel sm, final ColorModel 
cm) {
+    private static String verifyCompatibility(final SampleModel sm, final 
ColorModel cm) {
         if (cm == null || cm.isCompatibleSampleModel(sm))  return null;
         if (cm.getTransferType()  != sm.getTransferType()) return 
"transferType";
         if (cm.getNumComponents() != sm.getNumBands())     return 
"numComponents";
diff --git 
a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/RecoloredImage.java
 
b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/RecoloredImage.java
index 62e5fb1761..ac3569851d 100644
--- 
a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/RecoloredImage.java
+++ 
b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/RecoloredImage.java
@@ -54,7 +54,7 @@ final class RecoloredImage extends ImageAdapter {
     private final ColorModel colors;
 
     /**
-     * The minimum and maximum values used for computing the color model.
+     * The minimum and maximum values used for computing the color model, or 
NaN if unknown.
      * This is used for preserving color ramp stretching when a new color ramp 
is applied.
      *
      * <p>Current implementation can only describes a uniform stretching 
between a minimum and maximum value.
@@ -76,6 +76,48 @@ final class RecoloredImage extends ImageAdapter {
         this.colors  = colors;
         this.minimum = minimum;
         this.maximum = maximum;
+        ensureCompatible(getSampleModel(), colors);
+    }
+
+    /**
+     * Creates a new recolored image with the given colors and the same 
minimum/maximum values as the given parent.
+     *
+     * @param  source   the image to wrap.
+     * @param  colors   the new color model.
+     * @param  parent   the parent from which to inherit min/max, or {@code 
null} if none.
+     */
+    private RecoloredImage(final RenderedImage source, final ColorModel 
colors, final RecoloredImage parent) {
+        super(source);
+        this.colors = colors;
+        if (parent != null) {
+            minimum = parent.minimum;
+            maximum = parent.maximum;
+        } else {
+            minimum = maximum = Double.NaN;
+        }
+        ensureCompatible(getSampleModel(), colors);
+    }
+
+    /**
+     * Returns the given image with the given colors.
+     *
+     * @param  source  the image to wrap.
+     * @param  colors  the new color model.
+     * @return image with the given color model. May be a source returned 
directly.
+     */
+    static RenderedImage apply(RenderedImage source, final ColorModel colors) {
+        RecoloredImage parent = null;
+        for (;;) {
+            if (colors.equals(source.getColorModel())) {
+                return source;
+            }
+            if (source instanceof RecoloredImage) {
+                parent = (RecoloredImage) source;
+                source = parent.source;
+            } else {
+                return ImageProcessor.unique(new RecoloredImage(source, 
colors, parent));
+            }
+        }
     }
 
     /**
@@ -110,7 +152,7 @@ final class RecoloredImage extends ImageAdapter {
         for (;;) {
             if (colors.equals(source.getColorModel())) {
                 if (expected != null && source instanceof RecoloredImage) {
-                    final RecoloredImage actual = (RecoloredImage) source;
+                    final var actual = (RecoloredImage) source;
                     if (!(Numerics.equals(expected.minimum, actual.minimum) &&
                           Numerics.equals(expected.maximum, actual.maximum)))
                     {
@@ -129,13 +171,7 @@ final class RecoloredImage extends ImageAdapter {
          * At this point we found no existing image with the desired color 
model,
          * or the minimum/maximum information would be lost. Create a new 
image.
          */
-        final RecoloredImage image;
-        if (expected != null) {
-            image = new RecoloredImage(source, colors, expected.minimum, 
expected.maximum);
-        } else {
-            image = new RecoloredImage(source, colors, Double.NaN, Double.NaN);
-        }
-        return ImageProcessor.unique(image);
+        return ImageProcessor.unique(new RecoloredImage(source, colors, 
expected));
     }
 
     /**
@@ -267,7 +303,7 @@ final class RecoloredImage extends ImageAdapter {
              * But if there is 2 or more, then we select the one having 
largest intersection
              * with the [minimum … maximum] range.
              */
-            final IndexColorModel icm = (IndexColorModel) 
source.getColorModel();
+            final var icm = (IndexColorModel) source.getColorModel();
             final int size = icm.getMapSize();
             int validMin = 0;
             int validMax = size - 1;        // Inclusive.
@@ -321,7 +357,7 @@ final class RecoloredImage extends ImageAdapter {
         for (;;) {
             if (cm.equals(source.getColorModel())) {
                 if (source instanceof RecoloredImage) {
-                    final RecoloredImage colored = (RecoloredImage) source;
+                    final var colored = (RecoloredImage) source;
                     if (colored.minimum != minimum || colored.maximum != 
maximum) {
                         continue;
                     }
@@ -382,7 +418,7 @@ final class RecoloredImage extends ImageAdapter {
     @Override
     public boolean equals(final Object object) {
         if (super.equals(object)) {
-            final RecoloredImage other = (RecoloredImage) object;
+            final var other = (RecoloredImage) object;
             return Numerics.equals(minimum, other.minimum) &&
                    Numerics.equals(maximum, other.maximum) &&
                    colors.equals(other.colors);
diff --git 
a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/ResampledImage.java
 
b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/ResampledImage.java
index afaeacf6d9..fe9393d9ea 100644
--- 
a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/ResampledImage.java
+++ 
b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/ResampledImage.java
@@ -524,6 +524,7 @@ public class ResampledImage extends ComputedImage {
      * @return names of all recognized properties, or {@code null} if none.
      */
     @Override
+    @SuppressWarnings("StringEquality")
     public String[] getPropertyNames() {
         final String[] inherited = getSource().getPropertyNames();
         final String[] names = {
diff --git 
a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/StatisticsCalculator.java
 
b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/StatisticsCalculator.java
index fcf3577f25..7a95a28c9a 100644
--- 
a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/StatisticsCalculator.java
+++ 
b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/StatisticsCalculator.java
@@ -92,7 +92,7 @@ final class StatisticsCalculator extends AnnotatedImage {
      * This is used for both sequential and parallel executions.
      */
     private static Statistics[] createAccumulator(final int numBands) {
-        final Statistics[] stats = new Statistics[numBands];
+        final var stats = new Statistics[numBands];
         for (int i=0; i<numBands; i++) {
             stats[i] = new 
Statistics(Vocabulary.formatInternational(Vocabulary.Keys.Band_1, i));
         }
@@ -110,7 +110,7 @@ final class StatisticsCalculator extends AnnotatedImage {
         if (sampleFilters == null) {
             return accumulator;
         }
-        final DoubleConsumer[] filtered = new 
DoubleConsumer[accumulator.length];
+        final var filtered = new DoubleConsumer[accumulator.length];
         for (int i=0; i<filtered.length; i++) {
             final DoubleConsumer c = accumulator[i];
             final DoubleUnaryOperator f = sampleFilters[i];
@@ -161,7 +161,14 @@ final class StatisticsCalculator extends AnnotatedImage {
      */
     @Override
     protected Object cloneProperty(final String name, final Object value) {
-        final Statistics[] result = ((Statistics[]) value).clone();
+        return clone(((Statistics[]) value));
+    }
+
+    /**
+     * Clones the given array and all values in the array.
+     */
+    static Statistics[] clone(Statistics[] result) {
+        result = result.clone();
         for (int i=0; i<result.length; i++) {
             result[i] = result[i].clone();
         }
diff --git 
a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/Visualization.java
 
b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/Visualization.java
index ab61c53dfd..2789a30e86 100644
--- 
a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/Visualization.java
+++ 
b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/Visualization.java
@@ -314,10 +314,13 @@ final class Visualization extends ResampledImage {
                     initialized = builder.initialize(sourceCM);
                     if (!initialized) {
                         if (coloredSource instanceof RecoloredImage) {
-                            final RecoloredImage colored = (RecoloredImage) 
coloredSource;
-                            builder.initialize(colored.minimum, 
colored.maximum, sourceSM.getDataType());
-                            initialized = true;
-                        } else {
+                            final var colored = (RecoloredImage) coloredSource;
+                            if (colored.minimum < colored.maximum) {    // Do 
not execute if values are NaN.
+                                builder.initialize(colored.minimum, 
colored.maximum, sourceSM.getDataType());
+                                initialized = true;
+                            }
+                        }
+                        if (!initialized) {
                             initialized = builder.initialize(sourceSM, 
visibleBand);
                         }
                     }
diff --git 
a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/WritableComputedImage.java
 
b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/WritableComputedImage.java
index 235d55a012..e6bf82c9ea 100644
--- 
a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/WritableComputedImage.java
+++ 
b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/WritableComputedImage.java
@@ -142,7 +142,7 @@ abstract class WritableComputedImage extends ComputedImage {
      * @return the specified tile as a writable tile.
      */
     public WritableRaster getWritableTile(final int tileX, final int tileY) {
-        final WritableRaster tile = (WritableRaster) getTile(tileX, tileY);
+        final var tile = (WritableRaster) getTile(tileX, tileY);
         markTileWritable(tileX, tileY, true);
         return tile;
     }
diff --git 
a/endorsed/src/org.apache.sis.feature/test/org/apache/sis/image/ImageOverlayTest.java
 
b/endorsed/src/org.apache.sis.feature/test/org/apache/sis/image/ImageOverlayTest.java
new file mode 100644
index 0000000000..1a597f7c27
--- /dev/null
+++ 
b/endorsed/src/org.apache.sis.feature/test/org/apache/sis/image/ImageOverlayTest.java
@@ -0,0 +1,114 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.sis.image;
+
+import java.util.Hashtable;
+import java.awt.Rectangle;
+import java.awt.Transparency;
+import java.awt.color.ColorSpace;
+import java.awt.image.ComponentColorModel;
+import java.awt.image.BandedSampleModel;
+import java.awt.image.DataBuffer;
+import java.awt.image.RenderedImage;
+import java.awt.image.BufferedImage;
+import java.awt.image.WritableRaster;
+
+// Test dependencies
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.TestInstance;
+import static org.junit.jupiter.api.Assertions.*;
+import org.apache.sis.test.TestCase;
+import static org.apache.sis.feature.Assertions.assertValuesEqual;
+
+
+/**
+ * Tests {@link ImageOverlay}.
+ *
+ * @author  Martin Desruisseaux (Geomatys)
+ */
+@TestInstance(TestInstance.Lifecycle.PER_CLASS)
+public final class ImageOverlayTest extends TestCase {
+    /**
+     * The image to use at the sources for the test.
+     * Should not be modified.
+     */
+    private final BufferedImage[] sources;
+
+    /**
+     * Creates a new test case.
+     */
+    public ImageOverlayTest() {
+        final var properties = new Hashtable<String,Object>();
+        final var cm = new 
ComponentColorModel(ColorSpace.getInstance(ColorSpace.CS_GRAY), false, false, 
Transparency.OPAQUE, DataBuffer.TYPE_BYTE);
+        sources = new BufferedImage[3];
+
+        properties.put("ShouldBeIgnored", "Dummy");
+        properties.put(PlanarImage.SAMPLE_RESOLUTIONS_KEY, new double[] {3, 6, 
1});
+        sources[0] = new BufferedImage(cm, data(7, 3, 100), false, properties);
+
+        properties.put(PlanarImage.SAMPLE_RESOLUTIONS_KEY, new double[] {2, 5, 
3});
+        sources[2] = new BufferedImage(cm, data(3, 5, 200), false, properties);
+    }
+
+    /**
+     * Creates a raster for a source image.
+     */
+    private static WritableRaster data(final int width, final int height, int 
value) {
+        final var sm = new BandedSampleModel(DataBuffer.TYPE_BYTE, width, 
height, 1);
+        final WritableRaster raster = WritableRaster.createWritableRaster(sm, 
null);
+        for (int y=0; y<height; y++) {
+            for (int x=0; x<width; x++) {
+                raster.setSample(x, y, 0, value++);
+            }
+        }
+        return raster;
+    }
+
+    /**
+     * Tests an image created with the default argument values.
+     */
+    @Test
+    public void testDefault() {
+        final RenderedImage image = ImageOverlay.create(sources, null, null, 
null, true, false);
+        assertEquals(2, image.getSources().size());
+        assertEquals(7, image.getWidth());
+        assertEquals(5, image.getHeight());
+        assertEquals(7, image.getTileWidth());
+        assertEquals(5, image.getTileHeight());
+        assertEquals(1, image.getNumXTiles());
+        assertEquals(1, image.getNumYTiles());
+        assertArrayEquals(new String[] {PlanarImage.SAMPLE_RESOLUTIONS_KEY}, 
image.getPropertyNames());
+        assertArrayEquals(new double[] {2, 5, 1}, (double[]) 
image.getProperty(PlanarImage.SAMPLE_RESOLUTIONS_KEY));
+        assertValuesEqual(image.getData(), 0, new int[][] {
+            {100, 101, 102, 103, 104, 105, 106},
+            {107, 108, 109, 110, 111, 112, 113},
+            {114, 115, 116, 117, 118, 119, 120},
+            {209, 210, 211,   0,   0,   0,   0},
+            {212, 213, 214,   0,   0,   0,   0}
+        });
+    }
+
+    /**
+     * Tests with a subregion fully covered by the first image.
+     * The code should return the first image directly.
+     */
+    @Test
+    public void testSubRegion() {
+        RenderedImage image = ImageOverlay.create(sources, new Rectangle(7, 
3), null, null, true, false);
+        assertSame(sources[0], image);
+    }
+}
diff --git 
a/endorsed/src/org.apache.sis.storage.geotiff/test/org/apache/sis/storage/geotiff/WriterTest.java
 
b/endorsed/src/org.apache.sis.storage.geotiff/test/org/apache/sis/storage/geotiff/WriterTest.java
index 16971a8f4e..0f33859697 100644
--- 
a/endorsed/src/org.apache.sis.storage.geotiff/test/org/apache/sis/storage/geotiff/WriterTest.java
+++ 
b/endorsed/src/org.apache.sis.storage.geotiff/test/org/apache/sis/storage/geotiff/WriterTest.java
@@ -237,7 +237,7 @@ public final class WriterTest extends TestCase {
     @Test
     public void testUntiledRGB() throws IOException, DataStoreException {
         initialize(DataType.BYTE, ByteOrder.LITTLE_ENDIAN, false, 3, 1, 1);
-        image.setColorModel(new 
ColorModelBuilder().create(image.getSampleModel()));
+        image.setColorModel(new 
ColorModelBuilder().createRGB(image.getSampleModel()));
         writeImage();
         verifyHeader(false, IOBase.LITTLE_ENDIAN);
         verifyImageFileDirectory(Writer.COMMON_NUMBER_OF_TAGS - 1,          // 
One less tag because stripped layout.
@@ -338,11 +338,11 @@ public final class WriterTest extends TestCase {
          */
         short previousTag = 0;
         while (--tagCount >= 0) {
-            short   tag   = data.getShort();
-            short   type  = data.getShort();
-            long    count = isBigTIFF ? data.getLong() : data.getInt();
-            long    value = isBigTIFF ? data.getLong() : data.getInt();
-            Object  expected;       // The Number class will define the 
expected type.
+            short  tag   = data.getShort();
+            short  type  = data.getShort();
+            long   count = isBigTIFF ? data.getLong() : data.getInt();
+            long   value = isBigTIFF ? data.getLong() : data.getInt();
+            Object expected;       // The Number class will define the 
expected type.
             assertTrue(Short.toUnsignedInt(tag) > 
Short.toUnsignedInt(previousTag),
                        "Tags shall be sorted in increasing order.");
             expectedTags.remove(Integer.valueOf(tag));
diff --git 
a/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/esri/RasterStore.java
 
b/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/esri/RasterStore.java
index e222f4711c..31d0accdbd 100644
--- 
a/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/esri/RasterStore.java
+++ 
b/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/esri/RasterStore.java
@@ -343,7 +343,7 @@ abstract class RasterStore extends PRJDataStore implements 
GridCoverageResource
         final boolean isInteger  = ImageUtilities.isIntegerType(dataType);
         final boolean isUnsigned = isInteger && 
ImageUtilities.isUnsignedType(sm);
         final boolean isRGB      = isInteger && (bands.length == 3 || 
bands.length == 4);
-        final SampleDimension.Builder builder = new SampleDimension.Builder();
+        final var     builder    = new SampleDimension.Builder();
         for (int band=0; band < bands.length; band++) {
             double minimum = Double.NaN;
             double maximum = Double.NaN;
@@ -398,7 +398,7 @@ abstract class RasterStore extends PRJDataStore implements 
GridCoverageResource
             if (band == VISIBLE_BAND) {
                 try {
                     if (isRGB) {
-                        colorModel = new ColorModelBuilder().create(sm);
+                        colorModel = new ColorModelBuilder().createRGB(sm);
                     } else {
                         colorModel = readColorMap(dataType, (int) (maximum + 
1), bands.length);
                     }
@@ -451,7 +451,7 @@ abstract class RasterStore extends PRJDataStore implements 
GridCoverageResource
         final SampleDimension[] bands = range.select(sampleDimensions);
         Hashtable<String,Object> properties = null;
         if (stats != null) {
-            final Statistics[] as = new Statistics[range.getNumBands()];
+            final var as = new Statistics[range.getNumBands()];
             Arrays.fill(as, stats);
             properties = new Hashtable<>();
             properties.put(PlanarImage.STATISTICS_KEY, as);
diff --git 
a/endorsed/src/org.apache.sis.util/main/org/apache/sis/util/resources/IndexedResourceBundle.java
 
b/endorsed/src/org.apache.sis.util/main/org/apache/sis/util/resources/IndexedResourceBundle.java
index 71eda54802..1f5cd80eae 100644
--- 
a/endorsed/src/org.apache.sis.util/main/org/apache/sis/util/resources/IndexedResourceBundle.java
+++ 
b/endorsed/src/org.apache.sis.util/main/org/apache/sis/util/resources/IndexedResourceBundle.java
@@ -239,6 +239,7 @@ public abstract class IndexedResourceBundle extends 
ResourceBundle implements Lo
             }
         }
         final String lineSeparator = System.lineSeparator();
+        @SuppressWarnings("LocalVariableHidesMemberVariable")
         final String[] values = ensureLoaded(null);
         for (int i=0; i < values.length; i++) {
             final String key   = keys  [i];
@@ -268,6 +269,7 @@ public abstract class IndexedResourceBundle extends 
ResourceBundle implements Lo
      * @throws MissingResourceException if this method failed to load 
resources.
      */
     private String[] ensureLoaded(final String key) throws 
MissingResourceException {
+        @SuppressWarnings("LocalVariableHidesMemberVariable")
         String[] values = this.values;
         if (values == null) synchronized (this) {
             values = this.values;
@@ -353,6 +355,7 @@ public abstract class IndexedResourceBundle extends 
ResourceBundle implements Lo
         /*
          * Note: Synchronization is performed by 'ensureLoaded'
          */
+        @SuppressWarnings("LocalVariableHidesMemberVariable")
         final String[] values = ensureLoaded(key);
         int keyID;
         try {
@@ -766,6 +769,22 @@ public abstract class IndexedResourceBundle extends 
ResourceBundle implements Lo
         return null;
     }
 
+    /**
+     * Concatenates two sentences. The concatenation order is locale-sensitive.
+     * Current implementation ignores the locale and always concatenate the 
sentence from left to right.
+     * This method is defined for centralizing the places where such 
concatenations are done, for making
+     * easier to change this order if a future Apache SIS version supports 
right to left writing systems.
+     *
+     * @param  first   the first sentence, or {@code null} or empty.
+     * @param  second  the second sentence, or {@code null} or empty.
+     * @return the concatenated sentence.
+     */
+    public static String concatenate(final String first, final String second) {
+        if (first  == null ||  first.isBlank()) return second;
+        if (second == null || second.isBlank()) return first;
+        return first + ' ' + second;
+    }
+
     /**
      * Returns a string representation of this object.
      * This method is for debugging purposes only.

Reply via email to