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 2024/12/16 18:52:53 UTC

(sis) branch geoapi-4.0 updated (61589faccb -> 6a22d7e5e9)

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

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


    from 61589faccb Rename `MultiSourceLayout` and `BandAggregateLayout` for making clear that this is about band aggregate only rather than all multi-source image.
     new acdc884394 Add an image overlay operation in `ImageProcessor`.
     new 6a22d7e5e9 If an image does not fit the requirement of the TIFF specification, reformat the image before to write it in a TIFF file.

The 2 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.


Summary of changes:
 .../sis/coverage/privy/BandAggregateArgument.java  |   2 +-
 .../sis/coverage/privy/ColorModelBuilder.java      |   9 +-
 .../apache/sis/coverage/privy/ImageUtilities.java  |  14 +
 .../sis/coverage/privy/SampleModelFactory.java     |  63 +++-
 .../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 +++++++
 .../apache/sis/storage/geotiff/FormatModifier.java |  14 +-
 .../org/apache/sis/storage/geotiff/Writer.java     |  10 +-
 .../storage/geotiff/writer/ReformattedImage.java   |  58 +++-
 .../org/apache/sis/storage/geotiff/WriterTest.java |  24 +-
 .../org/apache/sis/storage/DataStoreRegistry.java  |  35 +-
 .../org/apache/sis/storage/esri/RasterStore.java   |   6 +-
 .../sis/util/resources/IndexedResourceBundle.java  |  19 ++
 31 files changed, 931 insertions(+), 127 deletions(-)
 create mode 100644 endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/ImageOverlay.java
 create mode 100644 endorsed/src/org.apache.sis.feature/test/org/apache/sis/image/ImageOverlayTest.java


(sis) 01/02: Add an image overlay operation in `ImageProcessor`.

Posted by de...@apache.org.
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 <ma...@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.


(sis) 02/02: If an image does not fit the requirement of the TIFF specification, reformat the image before to write it in a TIFF file.

Posted by de...@apache.org.
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 6a22d7e5e93ce0d60f1219b199fb2288052ef7b5
Author: Martin Desruisseaux <ma...@geomatys.com>
AuthorDate: Mon Dec 16 19:51:45 2024 +0100

    If an image does not fit the requirement of the TIFF specification,
    reformat the image before to write it in a TIFF file.
---
 .../sis/coverage/privy/SampleModelFactory.java     | 63 ++++++++++++++++++++--
 .../apache/sis/storage/geotiff/FormatModifier.java | 14 ++++-
 .../org/apache/sis/storage/geotiff/Writer.java     | 10 +++-
 .../storage/geotiff/writer/ReformattedImage.java   | 58 +++++++++++++++++---
 .../org/apache/sis/storage/geotiff/WriterTest.java | 12 +++--
 .../org/apache/sis/storage/DataStoreRegistry.java  | 35 ++++++------
 6 files changed, 157 insertions(+), 35 deletions(-)

diff --git a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/coverage/privy/SampleModelFactory.java b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/coverage/privy/SampleModelFactory.java
index b3af1f0cc7..b842cc5e11 100644
--- a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/coverage/privy/SampleModelFactory.java
+++ b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/coverage/privy/SampleModelFactory.java
@@ -76,6 +76,7 @@ public final class SampleModelFactory {
 
     /**
      * The bit masks for all bands, or {@code null} if not applicable.
+     * This field should be non-null only with {@link SinglePixelPackedSampleModel}.
      */
     private int[] bitMasks;
 
@@ -85,8 +86,8 @@ public final class SampleModelFactory {
     private int dataBitOffset;
 
     /**
-     * Number of bits per pixel, or 0 if not applicable.
-     * Also known as "pixel bit stride".
+     * Number of bits per pixel, or 0 if not applicable. Also known as "pixel bit stride".
+     * This field should be non-zero only with {@link MultiPixelPackedSampleModel}.
      */
     private int numberOfBits;
 
@@ -162,7 +163,7 @@ public final class SampleModelFactory {
         numBands = model.getNumBands();
         dataType = model.getDataType();
         if (model instanceof ComponentSampleModel) {
-            final ComponentSampleModel cm = (ComponentSampleModel) model;
+            final var cm = (ComponentSampleModel) model;
             bankIndices    = cm.getBankIndices();
             bandOffsets    = cm.getBandOffsets();
             scanlineStride = cm.getScanlineStride();
@@ -172,12 +173,12 @@ public final class SampleModelFactory {
             }
             bankIndices = null;     // PixelInterleavedSampleModel
         } else if (model instanceof SinglePixelPackedSampleModel) {
-            final SinglePixelPackedSampleModel cm = (SinglePixelPackedSampleModel) model;
+            final var cm = (SinglePixelPackedSampleModel) model;
             bitMasks       = cm.getBitMasks();
             scanlineStride = cm.getScanlineStride();
             pixelStride    = 1;
         } else if (model instanceof MultiPixelPackedSampleModel) {
-            final MultiPixelPackedSampleModel cm = (MultiPixelPackedSampleModel) model;
+            final var cm = (MultiPixelPackedSampleModel) model;
             numberOfBits   = cm.getPixelBitStride();
             dataBitOffset  = cm.getDataBitOffset();
             scanlineStride = cm.getScanlineStride();
@@ -267,6 +268,58 @@ public final class SampleModelFactory {
         return indices;
     }
 
+    /**
+     * Replaces the sample model with a variant where all components are stored in separated data elements.
+     * This method replaces {@link SinglePixelPackedSampleModel} and {@link MultiPixelPackedSampleModel} by
+     * a {@link ComponentSampleModel}.
+     *
+     * @param  banded  whether a {@link BandedSampleModel} is preferred. This hint may be ignored.
+     * @return whether the sample model changed as a result of this method call.
+     */
+    public boolean unpack(final boolean banded) {
+        if (bitMasks != null) {
+            /*
+             * SinglePixelPackedSampleModel: find the number of bits needed by the widest component.
+             * The `numberOfBits` field is temporarily set for computation purpose and cleared later.
+             */
+            int max = 0;
+            for (int i=0; i<numBands; i++) {
+                int mask = bitMasks[i];
+                mask >>>= Integer.numberOfTrailingZeros(mask);
+                if (mask > max) max = mask;
+            }
+            numberOfBits = Integer.SIZE - Integer.numberOfLeadingZeros(max);
+            bitMasks = null;
+        } else if (numberOfBits == 0) {
+            // Already a `ComponentSampleModel`.
+            return false;
+        }
+        /*
+         * Find the smallest data type capable to store the component values after unpacking.
+         * Then, reinitialize the strides and offsets to the simplest values they would have
+         * for a new component sample model.
+         */
+        if (numberOfBits <= Byte.SIZE) {
+            dataType = DataBuffer.TYPE_BYTE;
+        } else if (numberOfBits > Short.SIZE) {
+            dataType = DataBuffer.TYPE_INT;
+        } else if (dataType > DataBuffer.TYPE_SHORT) {    // Do not change the sign of current data type.
+            dataType = DataBuffer.TYPE_USHORT;
+        }
+        numberOfBits   = 0;
+        pixelStride    = numBands;
+        scanlineStride = Math.multiplyExact(width, numBands);
+        bandOffsets    = ArraysExt.range(0, numBands);
+        bankIndices    = null;
+        if (banded) {
+            pixelStride    = 1;
+            scanlineStride = width;
+            bankIndices    = bandOffsets;
+            bandOffsets    = new int[numBands];
+        }
+        return true;
+    }
+
     /**
      * Builds a sample model based on current factory configuration.
      * The factory is still valid after this method call.
diff --git a/endorsed/src/org.apache.sis.storage.geotiff/main/org/apache/sis/storage/geotiff/FormatModifier.java b/endorsed/src/org.apache.sis.storage.geotiff/main/org/apache/sis/storage/geotiff/FormatModifier.java
index dc3aafcafa..8a6b93a2e2 100644
--- a/endorsed/src/org.apache.sis.storage.geotiff/main/org/apache/sis/storage/geotiff/FormatModifier.java
+++ b/endorsed/src/org.apache.sis.storage.geotiff/main/org/apache/sis/storage/geotiff/FormatModifier.java
@@ -53,10 +53,22 @@ public enum FormatModifier {
      * When the {@code BIG_TIFF} modifier is present, the addressable space of 64-bits integers is used.
      * The BigTIFF format is non-standard and files written with this option may not be read by all TIFF readers.
      */
-    BIG_TIFF;
+    BIG_TIFF,
 
     // TODO: COG, SPARSE.
 
+    /**
+     * Whether to allow the writing of tiles of any size.
+     * The TIFF specification requires tile sizes to be multiples of 16 pixels.
+     * At reading time, Apache <abbr>SIS</abbr> always accept tiles of any size and this option is ignored.
+     * At writing time, by default Apache <abbr>SIS</abbr> checks the tile size and, if not compliant with
+     * <abbr>TIFF</abbr> requirement, reorganizes the pixel values in a new tiling before to write image.
+     * This reorganization may have a performance cost and consumes more disk space than needed.
+     * This option allows to disable this reorganization,
+     * which may result in smaller but non-standard TIFF files.
+     */
+    ANY_TILE_SIZE;
+
     /**
      * The key for declaring GeoTIFF format modifiers at store creation time.
      * See class Javadoc for usage example.
diff --git a/endorsed/src/org.apache.sis.storage.geotiff/main/org/apache/sis/storage/geotiff/Writer.java b/endorsed/src/org.apache.sis.storage.geotiff/main/org/apache/sis/storage/geotiff/Writer.java
index ac69b69b37..43f7aa3d52 100644
--- a/endorsed/src/org.apache.sis.storage.geotiff/main/org/apache/sis/storage/geotiff/Writer.java
+++ b/endorsed/src/org.apache.sis.storage.geotiff/main/org/apache/sis/storage/geotiff/Writer.java
@@ -134,6 +134,11 @@ final class Writer extends IOBase implements Flushable {
      */
     private final boolean isBigTIFF;
 
+    /**
+     * Whether to disable the <abbr>TIFF</abbr> requirement that tile sizes are multiple of 16 pixels.
+     */
+    private final boolean anyTileSize;
+
     /**
      * Index of the image to write. This information is not needed by the writer, but is
      * needed by {@link WritableStore} for determining the "effectively added resource".
@@ -182,6 +187,7 @@ final class Writer extends IOBase implements Flushable {
         super(store);
         this.output = output;
         isBigTIFF   = ArraysExt.contains(options, FormatModifier.BIG_TIFF);
+        anyTileSize = ArraysExt.contains(options, FormatModifier.ANY_TILE_SIZE);
         /*
          * Write the TIFF file header before first IFD. Stream position matter and must start at zero.
          * Note that it does not necessarily mean that the stream has no bytes before current position.
@@ -212,6 +218,7 @@ final class Writer extends IOBase implements Flushable {
     Writer(final Reader reader) throws IOException, DataStoreException {
         super(reader.store);
         isBigTIFF = (reader.intSizeExpansion != 0);
+        anyTileSize = false;
         try {
             output = new ChannelDataOutput(reader.input);
         } catch (ClassCastException e) {
@@ -287,9 +294,10 @@ final class Writer extends IOBase implements Flushable {
     public final long append(final RenderedImage image, final GridGeometry grid, final Metadata metadata)
             throws IOException, DataStoreException
     {
+        final var exportable = new ReformattedImage(image, this::processor, anyTileSize);
         final TileMatrix tiles;
         try {
-            tiles = writeImageFileDirectory(new ReformattedImage(image, this::processor), grid, metadata, false);
+            tiles = writeImageFileDirectory(exportable, grid, metadata, false);
         } finally {
             largeTagData.clear();       // For making sure that there is no memory retention.
         }
diff --git a/endorsed/src/org.apache.sis.storage.geotiff/main/org/apache/sis/storage/geotiff/writer/ReformattedImage.java b/endorsed/src/org.apache.sis.storage.geotiff/main/org/apache/sis/storage/geotiff/writer/ReformattedImage.java
index c9cbff5c26..2e48d5d57e 100644
--- a/endorsed/src/org.apache.sis.storage.geotiff/main/org/apache/sis/storage/geotiff/writer/ReformattedImage.java
+++ b/endorsed/src/org.apache.sis.storage.geotiff/main/org/apache/sis/storage/geotiff/writer/ReformattedImage.java
@@ -17,6 +17,7 @@
 package org.apache.sis.storage.geotiff.writer;
 
 import java.util.function.Supplier;
+import java.awt.Dimension;
 import java.awt.color.ColorSpace;
 import java.awt.image.ColorModel;
 import java.awt.image.IndexColorModel;
@@ -25,9 +26,12 @@ import java.awt.image.SampleModel;
 import static javax.imageio.plugins.tiff.BaselineTIFFTagSet.*;
 import org.apache.sis.util.ArraysExt;
 import org.apache.sis.math.Statistics;
+import org.apache.sis.pending.jdk.JDK18;
 import org.apache.sis.image.PlanarImage;
 import org.apache.sis.image.ImageProcessor;
+import org.apache.sis.coverage.privy.ImageLayout;
 import org.apache.sis.coverage.privy.ImageUtilities;
+import org.apache.sis.coverage.privy.SampleModelFactory;
 import org.apache.sis.io.stream.HyperRectangleWriter;
 
 
@@ -35,11 +39,16 @@ import org.apache.sis.io.stream.HyperRectangleWriter;
  * An image prepared for writing in a TIFF file. The TIFF specification puts some restrictions on tile size
  * (must be a multiple of 16), and {@code HyperRectangleWriter} has some other restrictions on data layout.
  *
- * @todo Force tile size to multiple of 16.
- *
  * @author  Martin Desruisseaux (Geomatys)
  */
 public final class ReformattedImage {
+    /**
+     * Divisor of tile sizes mandated by the TIFF specification.
+     * All tile sizes must be a multiple of this value.
+     * This constant must be a power of 2.
+     */
+    private static final int TILE_DIVISOR = 16;
+
     /**
      * Number of color bands before the extra bands. This number does not include the alpha channel,
      * which is considered an extra band in TIFF. This number does not apply to gray scale images,
@@ -71,14 +80,16 @@ public final class ReformattedImage {
      * The visible image will have at most 3 bands and should have no alpha channel.
      * If no change is needed, then the given image is used unchanged.
      *
-     * @param  image      the image to separate into visible, alpha and extra bands.
-     * @param  processor  supplier of the image processor to use if the image must be transformed.
+     * @param  image        the image to separate into visible, alpha and extra bands.
+     * @param  processor    supplier of the image processor to use if the image must be transformed.
+     * @param  anyTileSize  whether to disable the <abbr>TIFF</abbr> requirement that tile sizes are multiple of 16 pixels.
      */
-    public ReformattedImage(RenderedImage image, final Supplier<ImageProcessor> processor) {
+    public ReformattedImage(RenderedImage image, final Supplier<ImageProcessor> processor, final boolean anyTileSize) {
         int alphaBand = -1;
         boolean isAlphaPremultiplied = false;
         final int numBands = ImageUtilities.getNumBands(image);
         final int visibleBand = ImageUtilities.getVisibleBand(image);
+        final boolean banded;   // Whether to prefer `BandedSampleModel`.
         if (visibleBand >= 0) {
             /*
              * Indexed color model or gray scale image. This is conceptually a single band.
@@ -86,6 +97,7 @@ public final class ReformattedImage {
              * where only one band is shown. We need to order the visible band first.
              * All other bands will be extra samples.
              */
+            banded = true;
             singleBand = true;
             if (visibleBand != 0) {
                 final int[] bands = ArraysExt.range(0, numBands);
@@ -107,7 +119,9 @@ public final class ReformattedImage {
                     alphaBand = -1;
                 }
             }
-            singleBand = numBands < (alphaBand >= 0 ? 1 + NUM_COLOR_BANDS : NUM_COLOR_BANDS);
+            final int expected = (alphaBand < 0 ? NUM_COLOR_BANDS : NUM_COLOR_BANDS + 1);
+            banded = (numBands != expected);
+            singleBand = numBands < expected;
         }
         /*
          * Check if there is any extra samples. If yes, prepare an `extraSamples` array with all
@@ -126,8 +140,36 @@ public final class ReformattedImage {
          * If the image cannot be written directly, reformat. Because this operation
          * forces the copy of pixel values, it should be executed in last resort.
          */
-        if (!HyperRectangleWriter.Builder.isSupported(image.getSampleModel())) {
-            // TODO: reformat the image here.
+        boolean reformat = false;
+        SampleModel sm = image.getSampleModel();
+        if (!HyperRectangleWriter.Builder.isSupported(sm)) {
+            final var factory = new SampleModelFactory(sm);
+            if (factory.unpack(banded)) {
+                sm = factory.build();
+                reformat = true;
+            }
+        }
+        /*
+         * Force the image tile size to multiple of 16 pixels. This is a requirement of the TIFF specification.
+         * We can ignore this requirement when the image is untiled on the X axis, because in such case the SIS
+         * writer will write the image as strips instead of as tiles.
+         */
+        if (!anyTileSize && image.getNumXTiles() != 1) {
+            var tileSize = new Dimension(image.getTileWidth(), image.getTileHeight());
+            if (((tileSize.width | tileSize.height) & (TILE_DIVISOR - 1)) != 0) {
+                final int width  = JDK18.ceilDiv(image.getWidth(),  TILE_DIVISOR);
+                final int height = JDK18.ceilDiv(image.getHeight(), TILE_DIVISOR);
+                tileSize.width   = JDK18.ceilDiv(tileSize.width,    TILE_DIVISOR);
+                tileSize.height  = JDK18.ceilDiv(tileSize.height,   TILE_DIVISOR);
+                tileSize = new ImageLayout(tileSize, false).suggestTileSize(width, height, true);
+                tileSize.width  *= TILE_DIVISOR;
+                tileSize.height *= TILE_DIVISOR;
+                sm = sm.createCompatibleSampleModel(tileSize.width, tileSize.height);
+                reformat = true;
+            }
+        }
+        if (reformat) {
+            image = processor.get().reformat(image, sm);
         }
         exportable = 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 0f33859697..0a1f93a1ff 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
@@ -181,7 +181,8 @@ public final class WriterTest extends TestCase {
      */
     @Test
     public void testUntiledGrayScale() throws IOException, DataStoreException {
-        initialize(DataType.BYTE, ByteOrder.BIG_ENDIAN, false, 1, 1, 1);
+        initialize(DataType.BYTE, ByteOrder.BIG_ENDIAN, false, 1, 1, 1,
+                   FormatModifier.ANY_TILE_SIZE);
         writeImage();
         verifyHeader(false, IOBase.BIG_ENDIAN);
         verifyImageFileDirectory(Writer.COMMON_NUMBER_OF_TAGS - 1,              // One less tag because stripped layout.
@@ -199,7 +200,8 @@ public final class WriterTest extends TestCase {
      */
     @Test
     public void testUntiledBigTIFF() throws IOException, DataStoreException {
-        initialize(DataType.BYTE, ByteOrder.LITTLE_ENDIAN, false, 1, 1, 1, FormatModifier.BIG_TIFF);
+        initialize(DataType.BYTE, ByteOrder.LITTLE_ENDIAN, false, 1, 1, 1,
+                   FormatModifier.ANY_TILE_SIZE, FormatModifier.BIG_TIFF);
         writeImage();
         verifyHeader(true, IOBase.LITTLE_ENDIAN);
         verifyImageFileDirectory(Writer.COMMON_NUMBER_OF_TAGS - 1,          // One less tag because stripped layout.
@@ -218,7 +220,7 @@ public final class WriterTest extends TestCase {
      */
     @Test
     public void testTiledGrayScale() throws IOException, DataStoreException {
-        initialize(DataType.BYTE, ByteOrder.LITTLE_ENDIAN, false, 1, 3, 4);
+        initialize(DataType.BYTE, ByteOrder.LITTLE_ENDIAN, false, 1, 3, 4, FormatModifier.ANY_TILE_SIZE);
         writeImage();
         verifyHeader(false, IOBase.LITTLE_ENDIAN);
         verifyImageFileDirectory(Writer.COMMON_NUMBER_OF_TAGS,
@@ -236,7 +238,7 @@ public final class WriterTest extends TestCase {
      */
     @Test
     public void testUntiledRGB() throws IOException, DataStoreException {
-        initialize(DataType.BYTE, ByteOrder.LITTLE_ENDIAN, false, 3, 1, 1);
+        initialize(DataType.BYTE, ByteOrder.LITTLE_ENDIAN, false, 3, 1, 1, FormatModifier.ANY_TILE_SIZE);
         image.setColorModel(new ColorModelBuilder().createRGB(image.getSampleModel()));
         writeImage();
         verifyHeader(false, IOBase.LITTLE_ENDIAN);
@@ -255,7 +257,7 @@ public final class WriterTest extends TestCase {
      */
     @Test
     public void testGeoTIFF() throws IOException, DataStoreException {
-        initialize(DataType.BYTE, ByteOrder.LITTLE_ENDIAN, false, 1, 1, 1);
+        initialize(DataType.BYTE, ByteOrder.LITTLE_ENDIAN, false, 1, 1, 1, FormatModifier.ANY_TILE_SIZE);
         createGridGeometry();
         writeImage();
         verifyHeader(false, IOBase.LITTLE_ENDIAN);
diff --git a/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/DataStoreRegistry.java b/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/DataStoreRegistry.java
index cd117b737b..38d856b488 100644
--- a/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/DataStoreRegistry.java
+++ b/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/DataStoreRegistry.java
@@ -190,7 +190,7 @@ final class DataStoreRegistry extends LazySet<DataStoreProvider> {
     }
 
     /**
-     * Implementation of {@link #probeContentType(Object)} and {@link #open(Object, Capability, String)}.
+     * Implementation of {@link #probeContentType(Object)} and {@link #open(Object, Capability, Function)}.
      *
      * @param  storage     the input/output object as a URL, file, image input stream, <i>etc.</i>.
      * @param  capability  the capability that the data store must have (read, write, create).
@@ -208,26 +208,31 @@ final class DataStoreRegistry extends LazySet<DataStoreProvider> {
             Predicate<DataStoreProvider> preferred, final boolean open)
             throws DataStoreException
     {
+        final boolean writable;
         StorageConnector connector;                 // Will be reset to `null` if it shall not be closed.
         if (storage instanceof StorageConnector) {
             connector = (StorageConnector) storage;
-            final var p = connector.getOption(InternalOptionKey.PREFERRED_PROVIDERS);
-            if (preferred == null) {
-                preferred = p;
-            } else {
-                preferred = preferred.and(p);
-                connector.setOption(InternalOptionKey.PREFERRED_PROVIDERS, preferred);
+            writable = (capability == Capability.WRITE) && connector.getOption(OptionKey.OPEN_OPTIONS) == null;
+            final var filter = connector.getOption(InternalOptionKey.PREFERRED_PROVIDERS);
+            if (filter != null) {
+                preferred = (preferred != null) ? preferred.and(filter) : filter;
             }
         } else {
             connector = new StorageConnector(storage);
-            if (capability == Capability.WRITE) {
-                connector.setOption(OptionKey.OPEN_OPTIONS, new StandardOpenOption[] {
-                    StandardOpenOption.CREATE, StandardOpenOption.WRITE
-                });
-            }
-            if (preferred != null) {
-                connector.setOption(InternalOptionKey.PREFERRED_PROVIDERS, preferred);
-            }
+            writable = (capability == Capability.WRITE);
+        }
+        /*
+         * If this method is invoked by `DataStores.openWritable(…)`, add NIO open options if not already present.
+         * Note that this code may modify a user provided storage connector. It should be okay considering that
+         * each `StorageConnector` instance should be short-lived and used only once.
+         */
+        if (writable) {
+            connector.setOption(OptionKey.OPEN_OPTIONS, new StandardOpenOption[] {
+                StandardOpenOption.CREATE, StandardOpenOption.WRITE
+            });
+        }
+        if (preferred != null) {
+            connector.setOption(InternalOptionKey.PREFERRED_PROVIDERS, preferred);
         }
         /*
          * If we can get a filename extension from the given storage (file, URL, etc.), we may perform two times