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

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


The following commit(s) were added to refs/heads/geoapi-4.0 by this push:
     new f691d87e35 Store sample dimensions in a `RenderedImage` property. Use 
that property instead of argument value in `ImageProcessor`.
f691d87e35 is described below

commit f691d87e35689303ff1dbcd9995f85f42673eb6d
Author: Martin Desruisseaux <martin.desruisse...@geomatys.com>
AuthorDate: Wed Mar 29 20:31:06 2023 +0200

    Store sample dimensions in a `RenderedImage` property.
    Use that property instead of argument value in `ImageProcessor`.
    
    https://issues.apache.org/jira/browse/SIS-577
---
 .../org/apache/sis/coverage/grid/GridCoverage.java |  37 +++---
 .../apache/sis/coverage/grid/GridCoverage2D.java   |   2 +
 .../sis/coverage/grid/GridCoverageBuilder.java     |  13 ++-
 .../sis/coverage/grid/GridCoverageProcessor.java   | 118 +++++++++++++------
 .../apache/sis/coverage/grid/ImageRenderer.java    |  77 ++++++++-----
 .../sis/coverage/grid/ResampledGridCoverage.java   |   3 +-
 .../java/org/apache/sis/image/AnnotatedImage.java  |  12 +-
 .../java/org/apache/sis/image/BandSelectImage.java |  14 ++-
 .../apache/sis/image/BandedSampleConverter.java    |  89 +++++++++++----
 .../main/java/org/apache/sis/image/Colorizer.java  |  28 +++--
 .../java/org/apache/sis/image/ImageAdapter.java    |   6 +-
 .../java/org/apache/sis/image/ImageProcessor.java  | 126 +++++++++++++++++----
 .../java/org/apache/sis/image/PlanarImage.java     |  17 ++-
 .../java/org/apache/sis/image/RecoloredImage.java  |  44 ++++---
 .../java/org/apache/sis/image/ResampledImage.java  |  10 +-
 .../java/org/apache/sis/image/UserProperties.java  | 124 ++++++++++++++++++++
 .../java/org/apache/sis/image/Visualization.java   |  56 +++++----
 .../sis/internal/coverage/SampleDimensions.java    |  37 ++++--
 .../coverage/grid/ConvertedGridCoverageTest.java   |  24 +++-
 .../org/apache/sis/image/ImageProcessorTest.java   |  31 ++++-
 .../apache/sis/image/StatisticsCalculatorTest.java |   2 +-
 .../sis/internal/map/coverage/RenderingData.java   |  34 ++++--
 .../sis/internal/storage/esri/RasterStore.java     |   8 +-
 23 files changed, 696 insertions(+), 216 deletions(-)

diff --git 
a/core/sis-feature/src/main/java/org/apache/sis/coverage/grid/GridCoverage.java 
b/core/sis-feature/src/main/java/org/apache/sis/coverage/grid/GridCoverage.java
index d1f6a544a8..fbea3ecdb7 100644
--- 
a/core/sis-feature/src/main/java/org/apache/sis/coverage/grid/GridCoverage.java
+++ 
b/core/sis-feature/src/main/java/org/apache/sis/coverage/grid/GridCoverage.java
@@ -20,7 +20,6 @@ import java.util.Map;
 import java.util.List;
 import java.util.Locale;
 import java.util.Optional;
-import java.awt.image.ColorModel;
 import java.awt.image.RenderedImage;
 import org.opengis.geometry.Envelope;
 import org.opengis.geometry.DirectPosition;
@@ -36,8 +35,7 @@ import org.apache.sis.coverage.SampleDimension;
 import org.apache.sis.coverage.SubspaceNotSpecifiedException;
 import org.apache.sis.image.DataType;
 import org.apache.sis.image.ImageProcessor;
-import org.apache.sis.internal.coverage.j2d.ImageUtilities;
-import org.apache.sis.internal.coverage.j2d.ColorModelBuilder;
+import org.apache.sis.internal.coverage.SampleDimensions;
 import org.apache.sis.util.collection.DefaultTreeTable;
 import org.apache.sis.util.collection.TableColumn;
 import org.apache.sis.util.collection.TreeTable;
@@ -58,7 +56,7 @@ import org.opengis.coverage.CannotEvaluateException;
  *
  * @author  Martin Desruisseaux (IRD, Geomatys)
  * @author  Johann Sorel (Geomatys)
- * @version 1.3
+ * @version 1.4
  * @since   1.0
  */
 public abstract class GridCoverage extends BandedCoverage {
@@ -206,6 +204,19 @@ public abstract class GridCoverage extends BandedCoverage {
         return ranges;
     }
 
+    /**
+     * Returns the background value of each sample dimension.
+     * The array length is the number of sample dimensions (bands).
+     * Some array element may be {@code null} if the corresponding band has no 
background value.
+     *
+     * @return background value of each sample dimension.
+     *
+     * @see SampleDimension#getBackground()
+     */
+    final Number[] getBackground() {
+        return SampleDimensions.backgrounds(sampleDimensions);
+    }
+
     /**
      * Returns the data type identifying the primitive type used for storing 
sample values in each band.
      * We assume no packed sample model (e.g. no packing of 4 byte ARGB values 
in a single 32-bits integer).
@@ -289,6 +300,9 @@ public abstract class GridCoverage extends BandedCoverage {
 
     /**
      * Creates a new image of the given data type which will compute values 
using the given converters.
+     * The {@link #sampleDimensions} declared in this {@code GridCoverage} 
instances shall be applicable
+     * to the returned image, as it will be assigned to the image property
+     * {@value org.apache.sis.image.PlanarImage#SAMPLE_DIMENSIONS_KEY}.
      *
      * @param  source      the image for which to convert sample values.
      * @param  bandType    the type of data in the bands resulting from 
conversion of given image.
@@ -299,17 +313,12 @@ public abstract class GridCoverage extends BandedCoverage 
{
     final RenderedImage convert(final RenderedImage source, final DataType 
bandType,
             final MathTransform1D[] converters, final ImageProcessor processor)
     {
-        final int visibleBand = Math.max(0, 
ImageUtilities.getVisibleBand(source));
-        final ColorModelBuilder colorizer = new 
ColorModelBuilder(ColorModelBuilder.GRAYSCALE);
-        final ColorModel colors;
-        if (colorizer.initialize(source.getSampleModel(), 
sampleDimensions[visibleBand]) ||
-            colorizer.initialize(source.getColorModel()))
-        {
-            colors = colorizer.createColorModel(bandType.toDataBufferType(), 
sampleDimensions.length, visibleBand);
-        } else {
-            colors = ColorModelBuilder.NULL_COLOR_MODEL;
+        try {
+            SampleDimensions.CONVERTED_BANDS.set(sampleDimensions);
+            return processor.convert(source, getRanges(), converters, 
bandType);
+        } finally {
+            SampleDimensions.CONVERTED_BANDS.remove();
         }
-        return processor.convert(source, getRanges(), converters, bandType, 
colors);
     }
 
     /**
diff --git 
a/core/sis-feature/src/main/java/org/apache/sis/coverage/grid/GridCoverage2D.java
 
b/core/sis-feature/src/main/java/org/apache/sis/coverage/grid/GridCoverage2D.java
index d48f05c83e..7bbcd934b1 100644
--- 
a/core/sis-feature/src/main/java/org/apache/sis/coverage/grid/GridCoverage2D.java
+++ 
b/core/sis-feature/src/main/java/org/apache/sis/coverage/grid/GridCoverage2D.java
@@ -471,6 +471,8 @@ public class GridCoverage2D extends GridCoverage {
     /**
      * Creates a grid coverage that contains real values or sample values,
      * depending if {@code converted} is {@code true} or {@code false} 
respectively.
+     * This method is invoked by the default implementation of {@link 
#forConvertedValues(boolean)}
+     * when first needed.
      *
      * @param  converted  {@code true} for a coverage containing converted 
values,
      *                    or {@code false} for a coverage containing packed 
values.
diff --git 
a/core/sis-feature/src/main/java/org/apache/sis/coverage/grid/GridCoverageBuilder.java
 
b/core/sis-feature/src/main/java/org/apache/sis/coverage/grid/GridCoverageBuilder.java
index c437c4ea81..ca04e9a09e 100644
--- 
a/core/sis-feature/src/main/java/org/apache/sis/coverage/grid/GridCoverageBuilder.java
+++ 
b/core/sis-feature/src/main/java/org/apache/sis/coverage/grid/GridCoverageBuilder.java
@@ -33,6 +33,7 @@ import java.awt.image.SampleModel;
 import java.awt.image.WritableRaster;
 import org.opengis.geometry.Envelope;
 import org.opengis.referencing.operation.TransformException;
+import org.apache.sis.image.PlanarImage;
 import org.apache.sis.coverage.SampleDimension;
 import org.apache.sis.internal.coverage.j2d.ColorModelBuilder;
 import org.apache.sis.internal.coverage.j2d.ImageUtilities;
@@ -87,7 +88,7 @@ import org.apache.sis.util.resources.Errors;
  *
  * @author  Johann Sorel (Geomatys)
  * @author  Martin Desruisseaux (Geomatys)
- * @version 1.2
+ * @version 1.4
  *
  * @see GridCoverage2D
  * @see SampleDimension.Builder
@@ -169,12 +170,13 @@ public class GridCoverageBuilder {
      * @see #addImageProperty(String, Object)
      */
     @SuppressWarnings("UseOfObsoleteCollectionType")
-    private Hashtable<String,Object> properties;
+    private final Hashtable<String,Object> properties;
 
     /**
      * Creates an initially empty builder.
      */
     public GridCoverageBuilder() {
+        properties = new Hashtable<>();
     }
 
     /**
@@ -419,9 +421,6 @@ public class GridCoverageBuilder {
     public GridCoverageBuilder addImageProperty(final String key, final Object 
value) {
         ArgumentChecks.ensureNonNull("key",   key);
         ArgumentChecks.ensureNonNull("value", value);
-        if (properties == null) {
-            properties = new Hashtable<>();
-        }
         if (properties.putIfAbsent(key, value) != null) {
             throw new 
IllegalArgumentException(Errors.format(Errors.Keys.ElementAlreadyPresent_1, 
key));
         }
@@ -482,6 +481,9 @@ public class GridCoverageBuilder {
                  * Create an image from the raster. We favor BufferedImage 
instance when possible,
                  * and fallback on TiledImage only if the BufferedImage cannot 
be created.
                  */
+                if (bands != null) {
+                    properties.put(PlanarImage.SAMPLE_DIMENSIONS_KEY, 
bands.toArray(SampleDimension[]::new));
+                }
                 if (raster instanceof WritableRaster) {
                     final WritableRaster wr = (WritableRaster) raster;
                     if (colors != null && (wr.getMinX() | wr.getMinY()) == 0) {
@@ -492,6 +494,7 @@ public class GridCoverageBuilder {
                 } else {
                     image = new TiledImage(properties, colors, 
raster.getWidth(), raster.getHeight(), 0, 0, raster);
                 }
+                properties.remove(PlanarImage.SAMPLE_DIMENSIONS_KEY);
             }
             /*
              * At this point `image` shall be non-null but `bands` may still 
be null (it is okay).
diff --git 
a/core/sis-feature/src/main/java/org/apache/sis/coverage/grid/GridCoverageProcessor.java
 
b/core/sis-feature/src/main/java/org/apache/sis/coverage/grid/GridCoverageProcessor.java
index 31465949a6..b915d36752 100644
--- 
a/core/sis-feature/src/main/java/org/apache/sis/coverage/grid/GridCoverageProcessor.java
+++ 
b/core/sis-feature/src/main/java/org/apache/sis/coverage/grid/GridCoverageProcessor.java
@@ -36,6 +36,7 @@ import org.apache.sis.coverage.RegionOfInterest;
 import org.apache.sis.coverage.SampleDimension;
 import org.apache.sis.coverage.SubspaceNotSpecifiedException;
 import org.apache.sis.image.DataType;
+import org.apache.sis.image.Colorizer;
 import org.apache.sis.image.ImageProcessor;
 import org.apache.sis.image.Interpolation;
 import org.apache.sis.internal.coverage.MultiSourcesArgument;
@@ -57,6 +58,8 @@ import org.apache.sis.measure.NumberRange;
  *   </li><li>
  *     {@linkplain #setFillValues(Number...) Fill values} to use for cells 
that cannot be computed.
  *   </li><li>
+ *     {@linkplain #setColorizer(Colorizer) Colorization algorithm} to apply 
for colorizing a computed image.
+ *   </li><li>
  *     {@linkplain #setPositionalAccuracyHints(Quantity...) Positional 
accuracy hints}
  *     for enabling the use of faster algorithm when a lower accuracy is 
acceptable.
  *   </li><li>
@@ -86,11 +89,27 @@ public class GridCoverageProcessor implements Cloneable {
     private static final WeakHashSet<ImageProcessor> PROCESSORS = new 
WeakHashSet<>(ImageProcessor.class);
 
     /**
-     * Returns an unique instance of the given processor. Both the given and 
the returned processors
-     * shall be unmodified, because they may be shared by many {@link 
GridCoverage} instances.
+     * Returns a unique instance of the given processor. Both the given and 
the returned processors shall not
+     * be modified after this method call, because they may be shared by many 
{@link GridCoverage} instances.
+     * It implies that the given processor shall <em>not</em> be {@link 
#imageProcessor}. It must be a clone.
+     *
+     * @param  clone  a clone of {@link #imageProcessor} for which to return a 
unique instance.
+     * @return a unique instance of the given clone. Shall not be modified by 
the caller.
      */
-    static ImageProcessor unique(final ImageProcessor image) {
-        return PROCESSORS.unique(image);
+    static ImageProcessor unique(final ImageProcessor clone) {
+        return PROCESSORS.unique(clone);
+    }
+
+    /**
+     * Returns a unique instance of the current state of {@link 
#imageProcessor}.
+     * Callers shall not modify the returned object because it may be shared 
by many {@link GridCoverage} instances.
+     */
+    private ImageProcessor snapshot() {
+        ImageProcessor shared = PROCESSORS.get(imageProcessor);
+        if (shared == null) {
+            shared = unique(imageProcessor.clone());
+        }
+        return shared;
     }
 
     /**
@@ -150,6 +169,64 @@ public class GridCoverageProcessor implements Cloneable {
         imageProcessor.setInterpolation(method);
     }
 
+    /**
+     * Returns the values to use for pixels that cannot be computed.
+     * The default implementation delegates to the image processor.
+     *
+     * @return fill values to use for pixels that cannot be computed, or 
{@code null} for the defaults.
+     *
+     * @see ImageProcessor#getFillValues()
+     *
+     * @since 1.2
+     */
+    public Number[] getFillValues() {
+        return imageProcessor.getFillValues();
+    }
+
+    /**
+     * Sets the values to use for pixels that cannot be computed.
+     * The default implementation delegates to the image processor.
+     *
+     * @param  values  fill values to use for pixels that cannot be computed, 
or {@code null} for the defaults.
+     *
+     * @see ImageProcessor#setFillValues(Number...)
+     *
+     * @since 1.2
+     */
+    public void setFillValues(final Number... values) {
+        imageProcessor.setFillValues(values);
+    }
+
+    /**
+     * Returns the colorization algorithm to apply on computed images.
+     * The default implementation delegates to the image processor.
+     *
+     * @return colorization algorithm to apply on computed image, or {@code 
null} for default.
+     *
+     * @see ImageProcessor#getColorizer()
+     *
+     * @since 1.4
+     */
+    public Colorizer getColorizer() {
+        return imageProcessor.getColorizer();
+    }
+
+    /**
+     * Sets the colorization algorithm to apply on computed images.
+     * The colorizer is used by {@link #convert(GridCoverage, 
MathTransform1D[], Function) convert(…)}
+     * and {@link #aggregateRanges(GridCoverage...) aggregateRanges(…)} 
operations among others.
+     * The default implementation delegates to the image processor.
+     *
+     * @param colorizer colorization algorithm to apply on computed image, or 
{@code null} for default.
+     *
+     * @see ImageProcessor#setColorizer(Colorizer)
+     *
+     * @since 1.4
+     */
+    public void setColorizer(final Colorizer colorizer) {
+        imageProcessor.setColorizer(colorizer);
+    }
+
     /**
      * Returns hints about the desired positional accuracy, in "real world" 
units or in pixel units.
      * The default implementation delegates to the image processor.
@@ -245,34 +322,6 @@ public class GridCoverageProcessor implements Cloneable {
         optimizations.addAll(enabled);
     }
 
-    /**
-     * Returns the values to use for pixels that cannot be computed.
-     * The default implementation delegates to the image processor.
-     *
-     * @return fill values to use for pixels that cannot be computed, or 
{@code null} for the defaults.
-     *
-     * @see ImageProcessor#getFillValues()
-     *
-     * @since 1.2
-     */
-    public Number[] getFillValues() {
-        return imageProcessor.getFillValues();
-    }
-
-    /**
-     * Sets the values to use for pixels that cannot be computed.
-     * The default implementation delegates to the image processor.
-     *
-     * @param  values  fill values to use for pixels that cannot be computed, 
or {@code null} for the defaults.
-     *
-     * @see ImageProcessor#setFillValues(Number...)
-     *
-     * @since 1.2
-     */
-    public void setFillValues(final Number... values) {
-        imageProcessor.setFillValues(values);
-    }
-
     /**
      * Applies a mask defined by a region of interest (ROI). If {@code 
maskInside} is {@code true},
      * then all pixels inside the given ROI are set to the {@linkplain 
#getFillValues() fill values}.
@@ -359,7 +408,7 @@ public class GridCoverageProcessor implements Cloneable {
             builder.clear();
         }
         return new ConvertedGridCoverage(source, 
UnmodifiableArrayList.wrap(targetBands),
-                                         converters, true, 
unique(imageProcessor), true);
+                                         converters, true, snapshot(), true);
     }
 
     /**
@@ -484,6 +533,7 @@ public class GridCoverageProcessor implements Cloneable {
         }
         final GridCoverage resampled;
         try {
+            // `ResampledGridCoverage` will create itself a clone of 
`imageProcessor`.
             resampled = ResampledGridCoverage.create(source, target, 
imageProcessor, allowOperationReplacement);
         } catch (IllegalGridGeometryException e) {
             final Throwable cause = e.getCause();
@@ -689,7 +739,7 @@ public class GridCoverageProcessor implements Cloneable {
         if (aggregate.isIdentity()) {
             return aggregate.sources()[0];
         }
-        return new BandAggregateGridCoverage(aggregate, imageProcessor);
+        return new BandAggregateGridCoverage(aggregate, snapshot());
     }
 
     /**
diff --git 
a/core/sis-feature/src/main/java/org/apache/sis/coverage/grid/ImageRenderer.java
 
b/core/sis-feature/src/main/java/org/apache/sis/coverage/grid/ImageRenderer.java
index 79eddf520c..9fde21c79b 100644
--- 
a/core/sis-feature/src/main/java/org/apache/sis/coverage/grid/ImageRenderer.java
+++ 
b/core/sis-feature/src/main/java/org/apache/sis/coverage/grid/ImageRenderer.java
@@ -59,6 +59,7 @@ import static java.lang.Math.multiplyExact;
 import static java.lang.Math.incrementExact;
 import static java.lang.Math.toIntExact;
 import static org.apache.sis.image.PlanarImage.GRID_GEOMETRY_KEY;
+import static org.apache.sis.image.PlanarImage.SAMPLE_DIMENSIONS_KEY;
 
 
 /**
@@ -98,7 +99,7 @@ import static 
org.apache.sis.image.PlanarImage.GRID_GEOMETRY_KEY;
  * Support for tiled images will be added in a future version.
  *
  * @author  Martin Desruisseaux (Geomatys)
- * @version 1.3
+ * @version 1.4
  *
  * @see GridCoverage#render(GridExtent)
  *
@@ -146,9 +147,10 @@ public class ImageRenderer {
      * Location of the first image pixel relative to the grid coverage extent. 
The (0,0) offset means that the first pixel
      * in the {@code sliceExtent} (specified at construction time) is the 
first pixel in the whole {@link GridCoverage}.
      *
-     * <div class="note"><b>Note:</b> if those offsets exceed 32 bits integer 
capacity, then it may not be possible to build
-     * an image for given {@code sliceExtent} from a single {@link 
DataBuffer}, because accessing sample values would exceed
-     * the capacity of index in Java arrays. In those cases the image needs to 
be tiled.</div>
+     * <h4>Implementation note</h4>
+     * If those offsets exceed 32 bits integer capacity, then it may not be 
possible to build an image
+     * for given {@code sliceExtent} from a single {@link DataBuffer}, because 
accessing sample values
+     * would exceed the capacity of index in Java arrays. In those cases the 
image needs to be tiled.
      */
     private final long offsetX, offsetY;
 
@@ -461,9 +463,14 @@ public class ImageRenderer {
     }
 
     /**
-     * Returns the value associated to the given property. By default the only 
property is
-     * {@value org.apache.sis.image.PlanarImage#GRID_GEOMETRY_KEY}, but more 
properties can
-     * be added by calls to {@link #addProperty(String, Object)}.
+     * Returns the value associated to the given property.
+     * The properties recognized by current implementation are:
+     *
+     * <ul>
+     *   <li>{@value org.apache.sis.image.PlanarImage#GRID_GEOMETRY_KEY}.</li>
+     *   <li>{@value 
org.apache.sis.image.PlanarImage#SAMPLE_DIMENSIONS_KEY}.</li>
+     *   <li>Any property added by calls to {@link #addProperty(String, 
Object)}.</li>
+     * </ul>
      *
      * @param  key  the property for which to get a value.
      * @return value associated to the given property, or {@code null} if none.
@@ -471,8 +478,9 @@ public class ImageRenderer {
      * @since 1.1
      */
     public Object getProperty(final String key) {
-        if (GRID_GEOMETRY_KEY.equals(key)) {
-            return getImageGeometry(GridCoverage2D.BIDIMENSIONAL);
+        switch (key) {
+            case GRID_GEOMETRY_KEY:     return 
getImageGeometry(GridCoverage2D.BIDIMENSIONAL);
+            case SAMPLE_DIMENSIONS_KEY: return bands.clone();
         }
         return (properties != null) ? properties.get(key) : null;
     }
@@ -491,7 +499,7 @@ public class ImageRenderer {
     public void addProperty(final String key, final Object value) {
         ArgumentChecks.ensureNonNull("key",   key);
         ArgumentChecks.ensureNonNull("value", value);
-        if (!GRID_GEOMETRY_KEY.equals(key)) {
+        if (!(GRID_GEOMETRY_KEY.equals(key) || 
SAMPLE_DIMENSIONS_KEY.equals(key))) {
             if (properties == null) {
                 properties = new Hashtable<>();
             }
@@ -635,13 +643,15 @@ public class ImageRenderer {
      * All other bands, if any, will exist in the raster but be ignored at 
display time.
      * The default value is 0, the first (and often only) band.
      *
-     * <div class="note"><b>Implementation note:</b>
-     * an {@link java.awt.image.IndexColorModel} will be used for displaying 
the image.</div>
+     * <h4>Implementation note</h4>
+     * An {@link java.awt.image.IndexColorModel} will be used for displaying 
the image.
      *
      * @param  band  the band to use for display purpose.
      * @throws IllegalArgumentException if the given band is not between 0 
(inclusive)
      *         and {@link #getNumBands()} (exclusive).
      *
+     * @see org.apache.sis.image.Colorizer.Target#getVisibleBand()
+     *
      * @since 1.2
      */
     public void setVisibleBand(final int band) {
@@ -725,10 +735,12 @@ public class ImageRenderer {
      * The image upper-left corner is located at the position given by {@link 
#getBounds()}.
      * The two-dimensional {@linkplain #getImageGeometry(int) image geometry} 
is stored as
      * a property associated to the {@value 
org.apache.sis.image.PlanarImage#GRID_GEOMETRY_KEY} key.
+     * The sample dimensions are stored as a property associated to the
+     * {@value org.apache.sis.image.PlanarImage#SAMPLE_DIMENSIONS_KEY} key.
      *
      * <p>The default implementation returns an instance of {@link 
java.awt.image.WritableRenderedImage}
-     * if the {@link #createRaster()} return value is an instance of {@link 
WritableRaster}, or a read-only
-     * {@link RenderedImage} otherwise.</p>
+     * if the {@link #createRaster()} return value is an instance of {@link 
WritableRaster},
+     * or a read-only {@link RenderedImage} otherwise.</p>
      *
      * @return the image.
      * @throws IllegalStateException if no {@code setData(…)} method has been 
invoked before this method call.
@@ -758,12 +770,13 @@ public class ImageRenderer {
         }
         final WritableRaster wr = (raster instanceof WritableRaster) ? 
(WritableRaster) raster : null;
         if (wr != null && colors != null && (imageX | imageY) == 0) {
-            return new Untiled(colors, wr, properties, imageGeometry, 
supplier);
+            return new Untiled(colors, wr, properties, imageGeometry, 
supplier, bands);
         }
         if (properties == null) {
             properties = new Hashtable<>();
         }
         properties.putIfAbsent(GRID_GEOMETRY_KEY, (supplier != null) ? new 
DeferredProperty(supplier) : imageGeometry);
+        properties.putIfAbsent(SAMPLE_DIMENSIONS_KEY, bands);
         if (wr != null) {
             return new WritableTiledImage(properties, colors, width, height, 
0, 0, wr);
         } else {
@@ -791,16 +804,22 @@ public class ImageRenderer {
          */
         private SliceGeometry supplier;
 
+        /**
+         * The value associated to the {@value 
org.apache.sis.image.PlanarImage#SAMPLE_DIMENSIONS_KEY} key.
+         */
+        private final SampleDimension[] bands;
+
         /**
          * Creates a new buffered image wrapping the given raster.
          */
         @SuppressWarnings("UseOfObsoleteCollectionType")
         Untiled(final ColorModel colors, final WritableRaster raster, final 
Hashtable<?,?> properties,
-                final GridGeometry geometry, final SliceGeometry supplier)
+                final GridGeometry geometry, final SliceGeometry supplier, 
final SampleDimension[] bands)
         {
             super(colors, raster, false, properties);
             this.geometry = geometry;
             this.supplier = supplier;
+            this.bands    = bands;
         }
 
         /**
@@ -808,7 +827,8 @@ public class ImageRenderer {
          */
         @Override
         public String[] getPropertyNames() {
-            return ArraysExt.concatenate(super.getPropertyNames(), new 
String[] {GRID_GEOMETRY_KEY});
+            return ArraysExt.concatenate(super.getPropertyNames(),
+                    new String[] {GRID_GEOMETRY_KEY, SAMPLE_DIMENSIONS_KEY});
         }
 
         /**
@@ -820,19 +840,22 @@ public class ImageRenderer {
          */
         @Override
         public Object getProperty(final String key) {
-            if (!GRID_GEOMETRY_KEY.equals(key)) {
-                return super.getProperty(key);
-            }
-            synchronized (this) {
-                if (geometry == null) {
-                    final SliceGeometry s = supplier;
-                    if (s != null) {
-                        supplier = null;                // Let GC do its work.
-                        geometry = s.apply(this);
+            switch (key) {
+                default: return super.getProperty(key);
+                case SAMPLE_DIMENSIONS_KEY: return bands.clone();
+                case GRID_GEOMETRY_KEY: {
+                    synchronized (this) {
+                        if (geometry == null) {
+                            final SliceGeometry s = supplier;
+                            if (s != null) {
+                                supplier = null;                // Let GC do 
its work.
+                                geometry = s.apply(this);
+                            }
+                        }
+                        return geometry;
                     }
                 }
             }
-            return geometry;
         }
     }
 }
diff --git 
a/core/sis-feature/src/main/java/org/apache/sis/coverage/grid/ResampledGridCoverage.java
 
b/core/sis-feature/src/main/java/org/apache/sis/coverage/grid/ResampledGridCoverage.java
index 00d9b52850..997b357e3b 100644
--- 
a/core/sis-feature/src/main/java/org/apache/sis/coverage/grid/ResampledGridCoverage.java
+++ 
b/core/sis-feature/src/main/java/org/apache/sis/coverage/grid/ResampledGridCoverage.java
@@ -32,7 +32,6 @@ import org.apache.sis.image.ImageProcessor;
 import org.apache.sis.geometry.GeneralEnvelope;
 import org.apache.sis.internal.feature.Resources;
 import org.apache.sis.internal.util.DoubleDouble;
-import org.apache.sis.internal.coverage.SampleDimensions;
 import org.apache.sis.internal.referencing.DirectPositionView;
 import org.apache.sis.internal.referencing.ExtendedPrecisionMatrix;
 import org.apache.sis.referencing.operation.transform.LinearTransform;
@@ -113,7 +112,7 @@ final class ResampledGridCoverage extends 
DerivedGridCoverage {
          * NaN for floating point values.
          */
         processor = processor.clone();
-        
processor.setFillValues(SampleDimensions.backgrounds(getSampleDimensions()));
+        processor.setFillValues(getBackground());
         changeOfCRS.setAccuracyOf(processor);
         imageProcessor = GridCoverageProcessor.unique(processor);
         final Dimension s = imageProcessor.getInterpolation().getSupportSize();
diff --git 
a/core/sis-feature/src/main/java/org/apache/sis/image/AnnotatedImage.java 
b/core/sis-feature/src/main/java/org/apache/sis/image/AnnotatedImage.java
index 4a661a3e9a..577ca19dfc 100644
--- a/core/sis-feature/src/main/java/org/apache/sis/image/AnnotatedImage.java
+++ b/core/sis-feature/src/main/java/org/apache/sis/image/AnnotatedImage.java
@@ -52,9 +52,9 @@ import org.apache.sis.internal.util.Strings;
  * <p>The computation results are cached by this class. The cache strategy 
assumes that the
  * property value depend only on sample values, not on properties of the 
source image.</p>
  *
- * <div class="note"><b>Design note:</b>
- * most non-abstract methods are final because {@link PixelIterator} (among 
others) relies
- * on the fact that it can unwrap this image and still get the same pixel 
values.</div>
+ * <h2>Design note</h2>
+ * Most non-abstract methods are final because {@link PixelIterator} (among 
others) relies
+ * on the fact that it can unwrap this image and still get the same pixel 
values.
  *
  * @author  Martin Desruisseaux (Geomatys)
  * @version 1.2
@@ -258,9 +258,9 @@ abstract class AnnotatedImage extends ImageAdapter {
      * i.e. for distinguishing between two {@code AnnotatedImage} instances 
that are identical
      * except for subclass-defined parameters.
      *
-     * <div class="note"><b>API note:</b>
-     * the return value is an array because there is typically one parameter 
value per band.
-     * This method will not modify the returned array.</div>
+     * <h4>API note</h4>
+     * The return value is an array because there is typically one parameter 
value per band.
+     * This method will not modify the returned array.
      *
      * @return subclass specific extra parameter, or {@code null} if none.
      */
diff --git 
a/core/sis-feature/src/main/java/org/apache/sis/image/BandSelectImage.java 
b/core/sis-feature/src/main/java/org/apache/sis/image/BandSelectImage.java
index 712c910420..43ade8f563 100644
--- a/core/sis-feature/src/main/java/org/apache/sis/image/BandSelectImage.java
+++ b/core/sis-feature/src/main/java/org/apache/sis/image/BandSelectImage.java
@@ -50,8 +50,16 @@ final class BandSelectImage extends SourceAlignedImage {
      * @see #getProperty(String)
      */
     private static final Set<String> INHERITED_PROPERTIES = Set.of(
-            GRID_GEOMETRY_KEY, POSITIONAL_ACCURACY_KEY,         // Properties 
to forward as-is.
-            SAMPLE_RESOLUTIONS_KEY, STATISTICS_KEY);            // Properties 
to forward after band reduction.
+            GRID_GEOMETRY_KEY, POSITIONAL_ACCURACY_KEY,                        
 // Properties to forward as-is.
+            SAMPLE_DIMENSIONS_KEY, SAMPLE_RESOLUTIONS_KEY, STATISTICS_KEY);    
 // Properties to forward after band reduction.
+
+    /**
+     * Inherited properties that require band reduction.
+     * Shall be a subset of {@link #INHERITED_PROPERTIES}.
+     * All values must be arrays.
+     */
+    private static final Set<String> REDUCED_PROPERTIES = Set.of(
+            SAMPLE_DIMENSIONS_KEY, SAMPLE_RESOLUTIONS_KEY, STATISTICS_KEY);
 
     /**
      * The selected bands.
@@ -144,7 +152,7 @@ final class BandSelectImage extends SourceAlignedImage {
      */
     private static Object getProperty(final RenderedImage source, final String 
key, final int[] bands) {
         final Object value = source.getProperty(key);
-        if (value != null && (key.equals(SAMPLE_RESOLUTIONS_KEY) || 
key.equals(STATISTICS_KEY))) {
+        if (value != null && REDUCED_PROPERTIES.contains(key)) {
             final Class<?> componentType = value.getClass().getComponentType();
             if (componentType != null) {
                 final Object reduced = Array.newInstance(componentType, 
bands.length);
diff --git 
a/core/sis-feature/src/main/java/org/apache/sis/image/BandedSampleConverter.java
 
b/core/sis-feature/src/main/java/org/apache/sis/image/BandedSampleConverter.java
index ab8ee294a0..baab75fe89 100644
--- 
a/core/sis-feature/src/main/java/org/apache/sis/image/BandedSampleConverter.java
+++ 
b/core/sis-feature/src/main/java/org/apache/sis/image/BandedSampleConverter.java
@@ -32,16 +32,19 @@ import java.lang.reflect.Array;
 import org.opengis.referencing.operation.MathTransform1D;
 import org.opengis.referencing.operation.TransformException;
 import org.opengis.referencing.operation.NoninvertibleTransformException;
-import org.apache.sis.internal.coverage.j2d.ColorModelFactory;
+import org.apache.sis.internal.coverage.j2d.ColorModelBuilder;
 import org.apache.sis.internal.coverage.j2d.ImageLayout;
 import org.apache.sis.internal.coverage.j2d.ImageUtilities;
 import org.apache.sis.internal.coverage.j2d.TileOpExecutor;
 import org.apache.sis.internal.coverage.j2d.WriteSupport;
+import org.apache.sis.internal.coverage.SampleDimensions;
+import org.apache.sis.internal.util.UnmodifiableArrayList;
 import org.apache.sis.util.Numbers;
 import org.apache.sis.util.Disposable;
 import org.apache.sis.util.logging.Logging;
 import org.apache.sis.math.DecimalFunctions;
 import org.apache.sis.measure.NumberRange;
+import org.apache.sis.coverage.SampleDimension;
 
 import static org.apache.sis.internal.coverage.j2d.ImageUtilities.LOGGER;
 
@@ -81,7 +84,7 @@ class BandedSampleConverter extends ComputedImage {
      *
      * @see #getPropertyNames()
      */
-    private static final String[] ADDED_PROPERTIES = {SAMPLE_RESOLUTIONS_KEY};
+    private static final String[] ADDED_PROPERTIES = {SAMPLE_DIMENSIONS_KEY, 
SAMPLE_RESOLUTIONS_KEY};
 
     /**
      * The transfer functions to apply on each band of the source image.
@@ -93,6 +96,16 @@ class BandedSampleConverter extends ComputedImage {
      */
     private final ColorModel colorModel;
 
+    /**
+     * Description of bands, or {@code null} if unknown.
+     * Not used by this class, but provided as a {@value 
#SAMPLE_DIMENSIONS_KEY} property.
+     * The value is fetched from {@link SampleDimensions#CONVERTED_BANDS} for 
avoiding to
+     * expose a {@code SampleDimension[]} argument in public {@link 
ImageProcessor} API.
+     *
+     * @see #getProperty(String)
+     */
+    private final SampleDimension[] sampleDimensions;
+
     /**
      * The sample resolutions, or {@code null} if unknown.
      */
@@ -107,14 +120,17 @@ class BandedSampleConverter extends ComputedImage {
      * @param  ranges       the expected range of values for each band, or 
{@code null} if unknown.
      * @param  converters   the transfer functions to apply on each band of 
the source image.
      *                      If this array was a user-provided parameter, 
should be cloned by caller.
+     * @param  sampleDimensions  description of conversion result, or {@code 
null} if unknown.
      */
     private BandedSampleConverter(final RenderedImage source,  final 
BandedSampleModel sampleModel,
                                   final ColorModel colorModel, final 
NumberRange<?>[] ranges,
-                                  final MathTransform1D[] converters)
+                                  final MathTransform1D[] converters,
+                                  final SampleDimension[] sampleDimensions)
     {
         super(sampleModel, source);
         this.colorModel = colorModel;
         this.converters = converters;
+        this.sampleDimensions = sampleDimensions;
         /*
          * 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
@@ -189,7 +205,7 @@ class BandedSampleConverter extends ComputedImage {
      * @param  colorizer     provider of color model for the expected range of 
values, or {@code null}.
      * @return the image which compute converted values from the given source.
      *
-     * @see ImageProcessor#convert(RenderedImage, NumberRange[], 
MathTransform1D[], DataType, ColorModel)
+     * @see ImageProcessor#convert(RenderedImage, NumberRange[], 
MathTransform1D[], DataType)
      */
     static BandedSampleConverter create(RenderedImage source, final 
ImageLayout layout,
             final NumberRange<?>[] sourceRanges, final MathTransform1D[] 
converters,
@@ -197,23 +213,40 @@ class BandedSampleConverter extends ComputedImage {
     {
         /*
          * Since this operation applies its own ColorModel anyway, skip 
operation that was doing nothing else
-         * than changing the color model.
+         * than changing the color model. The new color model may be specified 
by the user if (s)he provided
+         * a `Colorizer` instance. Otherwise a default color model will be 
inferred.
          */
         if (source instanceof RecoloredImage) {
             source = ((RecoloredImage) source).source;
         }
         final int numBands = converters.length;
         final BandedSampleModel sampleModel = 
layout.createBandedSampleModel(targetType, numBands, source, null);
+        final SampleDimension[] sampleDimensions = 
SampleDimensions.CONVERTED_BANDS.get();
         final int visibleBand = ImageUtilities.getVisibleBand(source);
-        ColorModel colorModel = null;
+        ColorModel colorModel = ColorModelBuilder.NULL_COLOR_MODEL;
         if (colorizer != null) {
-            colorModel = colorizer.apply(new Colorizer.Target(sampleModel, 
null, visibleBand)).orElse(null);
+            var target = new Colorizer.Target(sampleModel, 
UnmodifiableArrayList.wrap(sampleDimensions), visibleBand);
+            colorModel = colorizer.apply(target).orElse(null);
         }
         if (colorModel == null) {
-            colorModel = ColorModelFactory.createGrayScale(sampleModel, 
visibleBand, null);
+            /*
+             * If no color model was specified or inferred from a colorizer,
+             * default to grayscale for a range inferred from the sample 
dimension.
+             * If no sample dimension is specified, infer value range from 
data type.
+             */
+            SampleDimension sd = null;
+            if (sampleDimensions != null && visibleBand >= 0 && visibleBand < 
sampleDimensions.length) {
+                sd = sampleDimensions[visibleBand];
+            }
+            final var builder = new 
ColorModelBuilder(ColorModelBuilder.GRAYSCALE);
+            if (builder.initialize(source.getSampleModel(), sd) ||
+                builder.initialize(source.getColorModel()))
+            {
+                colorModel = builder.createColorModel(targetType, numBands, 
Math.max(visibleBand, 0));
+            }
         }
         /*
-         * If the source image is writable, then changes in the converted 
image may be retro-propagated
+         * If the source image is writable, then change in the converted image 
may be retro-propagated
          * to that source image. If we fail to compute the required inverse 
transforms, log a notice at
          * a low level because this is not a serious problem; writable 
BandedSampleConverter is a plus
          * but not a requirement.
@@ -223,25 +256,42 @@ class BandedSampleConverter extends ComputedImage {
             for (int i=0; i<numBands; i++) {
                 inverses[i] = converters[i].inverse();
             }
-            return new Writable((WritableRenderedImage) source, sampleModel, 
colorModel, sourceRanges, converters, inverses);
+            return new Writable((WritableRenderedImage) source, sampleModel, 
colorModel, sourceRanges, converters, inverses, sampleDimensions);
         } catch (NoninvertibleTransformException e) {
             Logging.recoverableException(LOGGER, ImageProcessor.class, 
"convert", e);
         }
-        return new BandedSampleConverter(source, sampleModel, colorModel, 
sourceRanges, converters);
+        return new BandedSampleConverter(source, sampleModel, colorModel, 
sourceRanges, converters, sampleDimensions);
     }
 
     /**
      * Gets a property from this image. Current implementation recognizes:
-     * {@value #SAMPLE_RESOLUTIONS_KEY}.
+     * <ul>
+     *   <li>{@value #SAMPLE_RESOLUTIONS_KEY}, computed by this class.</li>
+     *   <li>{@value #SAMPLE_DIMENSIONS_KEY}, provided to the constructor.</li>
+     *   <li>All positional properties, forwarded to source image.</li>
+     * </ul>
      */
     @Override
     public Object getProperty(final String key) {
-        if (SAMPLE_RESOLUTIONS_KEY.equals(key)) {
-            if (sampleResolutions != null) {
-                return sampleResolutions.clone();
+        switch (key) {
+            case SAMPLE_DIMENSIONS_KEY: {
+                if (sampleDimensions != null) {
+                    return sampleDimensions.clone();
+                }
+                break;
+            }
+            case SAMPLE_RESOLUTIONS_KEY: {
+                if (sampleResolutions != null) {
+                    return sampleResolutions.clone();
+                }
+                break;
+            }
+            default: {
+                if (SourceAlignedImage.POSITIONAL_PROPERTIES.contains(key)) {
+                    return getSource().getProperty(key);
+                }
+                break;
             }
-        } else if (SourceAlignedImage.POSITIONAL_PROPERTIES.contains(key)) {
-            return getSource().getProperty(key);
         }
         return super.getProperty(key);
     }
@@ -394,9 +444,10 @@ class BandedSampleConverter extends ComputedImage {
          */
         Writable(final WritableRenderedImage source,  final BandedSampleModel 
sampleModel,
                  final ColorModel colorModel, final NumberRange<?>[] ranges,
-                 final MathTransform1D[] converters, final MathTransform1D[] 
inverses)
+                 final MathTransform1D[] converters, final MathTransform1D[] 
inverses,
+                 final SampleDimension[] sampleDimensions)
         {
-            super(source, sampleModel, colorModel, ranges, converters);
+            super(source, sampleModel, colorModel, ranges, converters, 
sampleDimensions);
             this.inverses = inverses;
         }
 
diff --git a/core/sis-feature/src/main/java/org/apache/sis/image/Colorizer.java 
b/core/sis-feature/src/main/java/org/apache/sis/image/Colorizer.java
index 330e7170e4..d21f3d2895 100644
--- a/core/sis-feature/src/main/java/org/apache/sis/image/Colorizer.java
+++ b/core/sis-feature/src/main/java/org/apache/sis/image/Colorizer.java
@@ -118,9 +118,12 @@ public interface Colorizer extends 
Function<Colorizer.Target, Optional<ColorMode
 
         /**
          * Returns a description of the bands of the image to colorize.
-         * This is typically obtained by {@link 
org.apache.sis.coverage.grid.GridCoverage#getSampleDimensions()}.
+         * This information may be present if the image operation is invoked 
by a
+         * {@link org.apache.sis.coverage.grid.GridCoverageProcessor} 
operation,
+         * or if the source image contains the {@value 
PlanarImage#SAMPLE_DIMENSIONS_KEY} property
          *
          * @return description of the bands of the image to colorize.
+         * @see org.apache.sis.coverage.grid.GridCoverage#getSampleDimensions()
          */
         public Optional<List<SampleDimension>> getRanges() {
             return Optional.ofNullable(ranges);
@@ -132,6 +135,7 @@ public interface Colorizer extends 
Function<Colorizer.Target, Optional<ColorMode
          * This information is ignored if the colorization uses many bands 
(e.g. {@link #ARGB}).
          *
          * @return the band to colorize if the colorization algorithm uses 
only one band.
+         * @see org.apache.sis.coverage.grid.ImageRenderer#setVisibleBand(int)
          */
         public OptionalInt getVisibleBand() {
             return (visibleBand >= 0) ? OptionalInt.of(visibleBand) : 
OptionalInt.empty();
@@ -188,7 +192,7 @@ public interface Colorizer extends 
Function<Colorizer.Target, Optional<ColorMode
      * @param  colors  the colors to use for the specified range of sample 
values.
      * @return a colorizer which will interpolate the given colors in the 
given range of values.
      *
-     * @see ImageProcessor#visualize(RenderedImage, List)
+     * @see ImageProcessor#visualize(RenderedImage)
      */
     public static Colorizer forRanges(final Map<NumberRange<?>,Color[]> 
colors) {
         ArgumentChecks.ensureNonEmpty("colors", colors.entrySet());
@@ -209,19 +213,26 @@ public interface Colorizer extends 
Function<Colorizer.Target, Optional<ColorMode
     }
 
     /**
-     * Creates a colorizer which will associate colors to coverage categories.
+     * Creates a colorizer which will interpolate colors in ranges identified 
by categories.
+     * This colorizer is similar to {@link #forRanges(Map)} (with the same 
limitations) except that instead of mapping
+     * colors to predefined ranges of pixel values, it maps colors to 
{@linkplain Category#getName() category names},
+     * {@linkplain org.apache.sis.measure.MeasurementRange#unit() units of 
measurement} or other properties.
      * The given function provides a way to colorize images without knowing in 
advance the numerical values of pixels.
      * For example, instead of specifying <cite>"pixel value 0 is blue, 1 is 
green, 2 is yellow"</cite>,
      * the given function allows to specify <cite>"Lakes are blue, Forests are 
green, Sand is yellow"</cite>.
+     * The function can return {@code null} or empty color arrays for some 
categories,
+     * which are interpreted as fully transparent pixels.
      *
      * <p>This colorizer is used when {@link Target#getRanges()} provides a 
non-empty value.
-     * The given function can return {@code null} or empty arrays for some 
categories,
-     * which are interpreted as fully transparent pixels.</p>
+     * That value is typically fetched from the {@value 
PlanarImage#SAMPLE_DIMENSIONS_KEY} image property,
+     * which is itself typically fetched from {@link 
org.apache.sis.coverage.grid.GridCoverage#getSampleDimensions()}.
+     * If no sample dimension information is available, then this colorizer do 
not build a color model.
+     * A fallback can be specified with {@link #orElse(Colorizer)}.</p>
      *
      * @param  colors  colors to use for arbitrary categories of sample values.
      * @return a colorizer which will apply colors determined by the {@link 
Category} of sample values.
      *
-     * @see ImageProcessor#visualize(RenderedImage, List)
+     * @see ImageProcessor#visualize(RenderedImage)
      */
     public static Colorizer forCategories(final Function<Category,Color[]> 
colors) {
         ArgumentChecks.ensureNonNull("colors", colors);
@@ -235,8 +246,9 @@ public interface Colorizer extends 
Function<Colorizer.Target, Optional<ColorMode
                     if (visibleBand < ranges.size()) {
                         final SampleModel model = target.getSampleModel();
                         final var c = new ColorModelBuilder(colors);
-                        c.initialize(model, ranges.get(visibleBand));
-                        return 
Optional.ofNullable(c.createColorModel(model.getDataType(), 
model.getNumBands(), visibleBand));
+                        if (c.initialize(model, ranges.get(visibleBand))) {
+                            return 
Optional.ofNullable(c.createColorModel(model.getDataType(), 
model.getNumBands(), visibleBand));
+                        }
                     }
                 }
             }
diff --git 
a/core/sis-feature/src/main/java/org/apache/sis/image/ImageAdapter.java 
b/core/sis-feature/src/main/java/org/apache/sis/image/ImageAdapter.java
index 932afd66a6..0d0dd534bb 100644
--- a/core/sis-feature/src/main/java/org/apache/sis/image/ImageAdapter.java
+++ b/core/sis-feature/src/main/java/org/apache/sis/image/ImageAdapter.java
@@ -34,12 +34,12 @@ import org.apache.sis.util.Disposable;
  * indices), and all methods fetching tiles, delegate to the wrapped image.
  *
  * <h2>Design note</h2>
- * most non-abstract methods are final because {@link PixelIterator} (among 
others) relies
+ * Most non-abstract methods are final because {@link PixelIterator} (among 
others) relies
  * on the fact that it can unwrap this image and still get the same pixel 
values.
  *
  * <h2>Relationship with other classes</h2>
  * This class is similar to {@link SourceAlignedImage} except that it does not 
extend {@link ComputedImage}
- * and forward {@link #getTile(int, int)}, {@link #getData()} and other data 
methods to the source image.
+ * and forwards {@link #getTile(int, int)}, {@link #getData()} and other data 
methods to the source image.
  *
  * <h2>Requirements for subclasses</h2>
  * All subclasses shall override {@link #equals(Object)} and {@link 
#hashCode()}.
@@ -80,7 +80,7 @@ abstract class ImageAdapter extends PlanarImage {
     /**
      * Returns the names of properties of wrapped image.
      *
-     * @return all recognized property names.
+     * @return all recognized property names, or {@code null} if none.
      */
     @Override
     public String[] getPropertyNames() {
diff --git 
a/core/sis-feature/src/main/java/org/apache/sis/image/ImageProcessor.java 
b/core/sis-feature/src/main/java/org/apache/sis/image/ImageProcessor.java
index f1f30fd2d4..e0ca82b6cc 100644
--- a/core/sis-feature/src/main/java/org/apache/sis/image/ImageProcessor.java
+++ b/core/sis-feature/src/main/java/org/apache/sis/image/ImageProcessor.java
@@ -618,7 +618,7 @@ public class ImageProcessor implements Cloneable {
      *
      * @since 1.2
      */
-    public DoubleUnaryOperator filterNodataValues(final Number... values) {
+    public static DoubleUnaryOperator filterNodataValues(final Number... 
values) {
         return (values != null) ? 
StatisticsCalculator.filterNodataValues(values) : null;
     }
 
@@ -820,10 +820,13 @@ public class ImageProcessor implements Cloneable {
      *   </tr><tr>
      *     <td>{@code "sampleDimensions"}</td>
      *     <td>Meaning of pixel values.</td>
-     *     <td>{@link SampleDimension}</td>
+     *     <td>{@link SampleDimension} or {@code SampleDimension[]}</td>
      *   </tr>
      * </table>
      *
+     * <b>Note:</b> if no value is associated to the {@code 
"sampleDimensions"} key, then the default
+     * value will be the {@value PlanarImage#SAMPLE_DIMENSIONS_KEY} image 
property value if defined.
+     *
      * <h4>Properties used</h4>
      * This operation uses the following properties in addition to method 
parameters:
      * <ul>
@@ -846,6 +849,44 @@ public class ImageProcessor implements Cloneable {
         return RecoloredImage.stretchColorRamp(this, source, modifiers);
     }
 
+    /**
+     * Returns an image augmented with user-defined property values.
+     * The specified properties overwrite any property that may be defined by 
the source image.
+     * When an {@linkplain RenderedImage#getProperty(String) image property 
value is requested}, the steps are:
+     *
+     * <ol>
+     *   <li>If the {@code properties} map has an entry for the property name, 
returns the associated value.
+     *       It may be {@code null}.</li>
+     *   <li>Otherwise if the property is defined by the source image, returns 
its value.
+     *       It may be {@code null}.</li>
+     *   <li>Otherwise returns {@link java.awt.Image#UndefinedProperty}.</li>
+     * </ol>
+     *
+     * The given {@code properties} map is retained by reference in the 
returned image.
+     * The {@code Map} is <em>not</em> copied in order to allow
+     * the use of custom implementations doing deferred calculations.
+     * If the caller intends to modify the map content after this method call,
+     * (s)he should use a {@link java.util.concurrent.ConcurrentMap}.
+     *
+     * <p>The returned image is "live": changes in {@code source} image 
properties or in
+     * {@code properties} map entries are immediately reflected in the 
returned image.</p>
+     *
+     * <p>Null are valid image property values. An entry associated with the 
{@code null}
+     * value in the {@code properties} map is not the same as an absence of 
entry.</p>
+     *
+     * @param  source       the source image to augment with user-specified 
property values.
+     * @param  properties   properties overwriting or completing {@code 
source} properties.
+     * @return an image augmented with the specified properties.
+     *
+     * @see RenderedImage#getPropertyNames()
+     * @see RenderedImage#getProperty(String)
+     *
+     * @since 1.4
+     */
+    public RenderedImage addUserProperties(final RenderedImage source, final 
Map<String,Object> properties) {
+        return unique(new UserProperties(source, properties));
+    }
+
     /**
      * Selects a subset of bands in the given image. This method can also be 
used for changing band order
      * or repeating the same band from the source image. If the specified 
{@code bands} are the same than
@@ -937,7 +978,7 @@ public class ImageProcessor implements Cloneable {
      *
      * @since 1.4
      */
-    public RenderedImage aggregateBands(RenderedImage[] sources, int[][] 
bandsPerSource) {
+    public RenderedImage aggregateBands(final RenderedImage[] sources, final 
int[][] bandsPerSource) {
         ArgumentChecks.ensureNonEmpty("sources", sources);
         final Colorizer colorizer;
         synchronized (this) {
@@ -1214,8 +1255,7 @@ public class ImageProcessor implements Cloneable {
      *
      * @param  source  the image to recolor for visualization purposes.
      * @param  colors  colors to use for each range of values in the source 
image.
-     * @deprecated Replaced by {@link #visualize(RenderedImage, List)} with 
{@code null} list argument
-     *             and colors map inferred from the {@link Colorizer}.
+     * @deprecated Replaced by {@link #visualize(RenderedImage)} with colors 
map inferred from the {@link Colorizer}.
      */
     @Deprecated(since="1.4", forRemoval=true)
     public synchronized RenderedImage visualize(final RenderedImage source, 
final Map<NumberRange<?>,Color[]> colors) {
@@ -1234,6 +1274,19 @@ public class ImageProcessor implements Cloneable {
         }
     }
 
+    /**
+     * @deprecated Replaced by {@link #visualize(RenderedImage)} with sample 
dimensions
+     *             read from the {@value PlanarImage#SAMPLE_DIMENSIONS_KEY} 
property.
+     *
+     * @param  ranges  description of {@code source} bands, or {@code null} if 
none. This is typically
+     *                 obtained by {@link 
org.apache.sis.coverage.grid.GridCoverage#getSampleDimensions()}.
+     */
+    @Deprecated(since="1.4", forRemoval=true)
+    public RenderedImage visualize(final RenderedImage source, final 
List<SampleDimension> ranges) {
+        ArgumentChecks.ensureNonNull("source", source);
+        return visualize(new Visualization.Builder(null, source, null, 
ranges));
+    }
+
     /**
      * Returns an image where all sample values are indices of colors in an 
{@link IndexColorModel}.
      * If the given image stores sample values as unsigned bytes or short 
integers, then those values
@@ -1244,7 +1297,26 @@ public class ImageProcessor implements Cloneable {
      * There is no guarantee about the number of bands in returned image or 
about which formula is used for converting
      * floating point values to integer values.</p>
      *
-     * <h4>Specifying colors for ranges of pixel values</h4>
+     * <h4>How to specify colors</h4>
+     * The image colors can be controlled by the {@link Colorizer} set on this 
image processor.
+     * It is possible to {@linkplain Colorizer#forInstance(ColorModel) specify 
explicitely} the
+     * {@link ColorModel} to use, but this approach is unsafe because it 
depends on the pixel values
+     * <em>after</em> their conversion to the visualization image, which is 
implementation dependent.
+     * A safer approach is to define colors relative to pixel values 
<em>before</em> their conversions.
+     * It can be done in two ways, depending on whether the {@value 
PlanarImage#SAMPLE_DIMENSIONS_KEY}
+     * image property is defined or not.
+     * Those two ways are described in next sections and can be combined in a 
chain of fallbacks.
+     * For example the following colorizer will choose colors based on sample 
dimensions if available,
+     * or fallback on predefined ranges of pixel values otherwise:
+     *
+     * {@snippet lang="java" :
+     *     Function<Category,Color[]>  flexible   = ...;
+     *     Map<NumberRange<?>,Color[]> predefined = ...;
+     *     processor.setColorizer(Colorizer.forCategories(flexible)     // 
Preferred way.
+     *                    .orElse(Colorizer.forRanges(predefined)));    // 
Fallback.
+     *     }
+     *
+     * <h5>Specifying colors for ranges of pixel values</h5>
      * When no {@link SampleDimension} information is available, the 
recommended way to specify colors is like below.
      * In this example, <var>min</var> and <var>max</var> are minimum and 
maximum values
      * (inclusive in this example, but they could be exclusive as well) in the 
<em>source</em> image.
@@ -1263,7 +1335,7 @@ public class ImageProcessor implements Cloneable {
      * The {@link Color} arrays may have any length; colors will be 
interpolated as needed for fitting
      * the ranges of values in the destination image.
      *
-     * <h4>Specifying colors for sample dimension categories</h4>
+     * <h5>Specifying colors for sample dimension categories</h5>
      * If {@link SampleDimension} information is available, a more flexible 
way to specify colors
      * is to associate colors to category names instead of predetermined 
ranges of pixel values.
      * The ranges will be inferred indirectly, {@linkplain 
Category#getSampleRange() from the categories}
@@ -1287,15 +1359,6 @@ public class ImageProcessor implements Cloneable {
      * The {@link Color} arrays may have any length; colors will be 
interpolated as needed for fitting
      * the ranges of values in the destination image.
      *
-     * <p>The two approaches can be combined. For example the following 
colorizer will choose colors based
-     * on sample dimensions if available, or fallback on predefined ranges of 
pixel values otherwise:</p>
-     *
-     * {@snippet lang="java" :
-     *     Function<Category,Color[]>  flexible   = ...;
-     *     Map<NumberRange<?>,Color[]> predefined = ...;
-     *     
processor.setColorizer(Colorizer.forCategories(flexible).orElse(Colorizer.forRanges(predefined)));
-     *     }
-     *
      * <h4>Properties used</h4>
      * This operation uses the following properties in addition to method 
parameters:
      * <ul>
@@ -1303,16 +1366,17 @@ public class ImageProcessor implements Cloneable {
      * </ul>
      *
      * @param  source  the image to recolor for visualization purposes.
-     * @param  ranges  description of {@code source} bands, or {@code null} if 
none. This is typically
-     *                 obtained by {@link 
org.apache.sis.coverage.grid.GridCoverage#getSampleDimensions()}.
      * @return recolored image for visualization purposes only.
      *
      * @see Colorizer#forRanges(Map)
      * @see Colorizer#forCategories(Function)
+     * @see PlanarImage#SAMPLE_DIMENSIONS_KEY
+     *
+     * @since 1.4
      */
-    public RenderedImage visualize(final RenderedImage source, final 
List<SampleDimension> ranges) {
+    public RenderedImage visualize(final RenderedImage source) {
         ArgumentChecks.ensureNonNull("source", source);
-        return visualize(new Visualization.Builder(null, source, null, 
ranges));
+        return visualize(new Visualization.Builder(null, source, null));
     }
 
     /**
@@ -1322,7 +1386,7 @@ public class ImageProcessor implements Cloneable {
      *
      * <ol>
      *   <li><code>{@linkplain #resample(RenderedImage, Rectangle, 
MathTransform) resample}(source, bounds, toSource)</code></li>
-     *   <li><code>{@linkplain #visualize(RenderedImage, List) 
visualize}(resampled, ranges)</code></li>
+     *   <li><code>{@linkplain #visualize(RenderedImage) 
visualize}(resampled)</code></li>
      * </ol>
      *
      * Combining above steps may be advantageous when the {@code resample(…)} 
result is not needed for anything
@@ -1349,10 +1413,26 @@ public class ImageProcessor implements Cloneable {
      * @param  bounds    domain of pixel coordinates of resampled image to 
create.
      *                   Updated by this method if {@link Resizing#EXPAND} 
policy is applied.
      * @param  toSource  conversion of pixel coordinates from resampled image 
to {@code source} image.
-     * @param  ranges    description of {@code source} bands, or {@code null} 
if none. This is typically
-     *                   obtained by {@link 
org.apache.sis.coverage.grid.GridCoverage#getSampleDimensions()}.
      * @return resampled and recolored image for visualization purposes only.
+     *
+     * @since 1.4
+     */
+    public RenderedImage visualize(final RenderedImage source, final Rectangle 
bounds, final MathTransform toSource) {
+        ArgumentChecks.ensureNonNull("source",   source);
+        ArgumentChecks.ensureNonNull("bounds",   bounds);
+        ArgumentChecks.ensureNonNull("toSource", toSource);
+        ensureNonEmpty(bounds);
+        return visualize(new Visualization.Builder(bounds, source, toSource));
+    }
+
+    /**
+     * @deprecated Replaced by {@link #visualize(RenderedImage, Rectangle, 
MathTransform)} with
+     *             sample dimensions read from the {@value 
PlanarImage#SAMPLE_DIMENSIONS_KEY} property.
+     *
+     * @param  ranges  description of {@code source} bands, or {@code null} if 
none. This is typically
+     *                 obtained by {@link 
org.apache.sis.coverage.grid.GridCoverage#getSampleDimensions()}.
      */
+    @Deprecated(since="1.4", forRemoval=true)
     public RenderedImage visualize(final RenderedImage source, final Rectangle 
bounds, final MathTransform toSource,
                                    final List<SampleDimension> ranges)
     {
diff --git 
a/core/sis-feature/src/main/java/org/apache/sis/image/PlanarImage.java 
b/core/sis-feature/src/main/java/org/apache/sis/image/PlanarImage.java
index 5fb2e07651..de86487842 100644
--- a/core/sis-feature/src/main/java/org/apache/sis/image/PlanarImage.java
+++ b/core/sis-feature/src/main/java/org/apache/sis/image/PlanarImage.java
@@ -37,6 +37,7 @@ import org.apache.sis.internal.coverage.j2d.ImageUtilities;
 import org.apache.sis.internal.coverage.j2d.TileOpExecutor;
 import org.apache.sis.internal.coverage.j2d.ColorModelFactory;
 import org.apache.sis.coverage.grid.GridGeometry;       // For javadoc
+import org.apache.sis.coverage.SampleDimension;
 
 import static java.lang.Math.multiplyFull;
 
@@ -143,6 +144,17 @@ public abstract class PlanarImage implements RenderedImage 
{
      */
     public static final String POSITIONAL_ACCURACY_KEY = 
"org.apache.sis.PositionalAccuracy";
 
+    /**
+     * Key for a property defining a conversion from pixel values to the units 
of measurement.
+     * The value should be an array of {@link SampleDimension} instances.
+     * The array length should be the number of bands.
+     *
+     * @see org.apache.sis.coverage.grid.GridCoverage#getSampleDimensions()
+     *
+     * @since 1.4
+     */
+    public static final String SAMPLE_DIMENSIONS_KEY = 
"org.apache.sis.SampleDimensions";
+
     /**
      * Key of a property defining the resolutions of sample values in each 
band. This property is recommended
      * for images having sample values as floating point numbers. For example 
if sample values were computed by
@@ -155,7 +167,7 @@ public abstract class PlanarImage implements RenderedImage {
      * {@linkplain 
org.apache.sis.coverage.grid.GridCoverage#forConvertedValues(boolean) 
conversions from
      * integer values to floating point values}.</p>
      */
-    public static final String SAMPLE_RESOLUTIONS_KEY = 
"org.apache.sis.SampleResolution";
+    public static final String SAMPLE_RESOLUTIONS_KEY = 
"org.apache.sis.SampleResolutions";
 
     /**
      * Key of property providing statistics on sample values in each band. 
Providing a value for this key
@@ -231,6 +243,9 @@ public abstract class PlanarImage implements RenderedImage {
      *     <td>{@value #POSITIONAL_ACCURACY_KEY}</td>
      *     <td>Estimation of positional accuracy, typically in metres or pixel 
units.</td>
      *   </tr><tr>
+     *     <td>{@value #SAMPLE_DIMENSIONS_KEY}</td>
+     *     <td>Conversions from pixel values to the units of measurement for 
each band.</td>
+     *   </tr><tr>
      *     <td>{@value #SAMPLE_RESOLUTIONS_KEY}</td>
      *     <td>Resolutions of sample values in each band.</td>
      *   </tr><tr>
diff --git 
a/core/sis-feature/src/main/java/org/apache/sis/image/RecoloredImage.java 
b/core/sis-feature/src/main/java/org/apache/sis/image/RecoloredImage.java
index 42d535f1d2..ce24f13234 100644
--- a/core/sis-feature/src/main/java/org/apache/sis/image/RecoloredImage.java
+++ b/core/sis-feature/src/main/java/org/apache/sis/image/RecoloredImage.java
@@ -44,7 +44,7 @@ import org.apache.sis.measure.NumberRange;
  * for {@link ImageProcessor}, defined here for reducing {@link 
ImageProcessor} size.
  *
  * @author  Martin Desruisseaux (Geomatys)
- * @version 1.2
+ * @version 1.4
  * @since   1.1
  */
 final class RecoloredImage extends ImageAdapter {
@@ -226,18 +226,9 @@ final class RecoloredImage extends ImageAdapter {
             }
             value = modifiers.get("sampleDimensions");
             if (value != null) {
-                if (value instanceof List<?>) {
-                    final List<?> ranges = (List<?>) value;
-                    if (visibleBand < ranges.size()) {
-                        value = ranges.get(visibleBand);
-                    }
-                }
-                if (value != null) {
-                    if (value instanceof SampleDimension) {
-                        range = (SampleDimension) value;
-                    } else {
-                        throw illegalPropertyType(modifiers, 
"sampleDimensions", value);
-                    }
+                range = getSampleDimension(value, visibleBand);
+                if (range == null) {
+                    throw illegalPropertyType(modifiers, "sampleDimensions", 
value);
                 }
             }
         }
@@ -249,7 +240,7 @@ final class RecoloredImage extends ImageAdapter {
             if (statistics == null) {
                 if (statsAllBands == null) {
                     final DoubleUnaryOperator[] sampleFilters = new 
DoubleUnaryOperator[visibleBand + 1];
-                    sampleFilters[visibleBand] = 
processor.filterNodataValues(nodataValues);
+                    sampleFilters[visibleBand] = 
ImageProcessor.filterNodataValues(nodataValues);
                     statsAllBands = processor.valueOfStatistics(statsSource, 
areaOfInterest, sampleFilters);
                 }
                 if (statsAllBands != null && visibleBand < 
statsAllBands.length) {
@@ -282,6 +273,9 @@ final class RecoloredImage extends ImageAdapter {
             final int size = icm.getMapSize();
             int validMin = 0;
             int validMax = size - 1;        // Inclusive.
+            if (range == null) {
+                range = 
getSampleDimension(source.getProperty(PlanarImage.SAMPLE_DIMENSIONS_KEY), 
visibleBand);
+            }
             if (range != null) {
                 double span = 0;
                 for (final Category category : range.getCategories()) {
@@ -345,6 +339,28 @@ final class RecoloredImage extends ImageAdapter {
         return ImageProcessor.unique(new RecoloredImage(source, cm, minimum, 
maximum));
     }
 
+    /**
+     * Gets the sample dimension from the given property value.
+     *
+     * @param  value        the property value.
+     * @param  visibleBand  index of the element to fetch if the property is a 
list or an array.
+     * @return the sample dimension at the given visible band index, or {@code 
null} if none.
+     */
+    private static SampleDimension getSampleDimension(Object value, final int 
visibleBand) {
+        if (value instanceof SampleDimension[]) {
+            final var ranges = (SampleDimension[]) value;
+            if (visibleBand < ranges.length) {
+                return ranges[visibleBand];
+            }
+        } else if (value instanceof List<?>) {
+            final var ranges = (List<?>) value;
+            if (visibleBand < ranges.size()) {
+                value = ranges.get(visibleBand);
+            }
+        }
+        return (value instanceof SampleDimension) ? (SampleDimension) value : 
null;
+    }
+
     /**
      * Returns the exception to be thrown when a property is of illegal type.
      */
diff --git 
a/core/sis-feature/src/main/java/org/apache/sis/image/ResampledImage.java 
b/core/sis-feature/src/main/java/org/apache/sis/image/ResampledImage.java
index f9cc28d46f..0aa72e1f55 100644
--- a/core/sis-feature/src/main/java/org/apache/sis/image/ResampledImage.java
+++ b/core/sis-feature/src/main/java/org/apache/sis/image/ResampledImage.java
@@ -479,24 +479,25 @@ public class ResampledImage extends ComputedImage {
 
     /**
      * Gets a property from this image. Current default implementation 
supports the following keys
-     * (more properties may be added to this list in future Apache SIS 
versions):
+     * (more properties may be added to this list in any future Apache SIS 
versions):
      *
      * <ul>
      *   <li>{@value #POSITIONAL_ACCURACY_KEY}</li>
      *   <li>{@value #POSITIONAL_CONSISTENCY_KEY}</li>
+     *   <li>{@value #SAMPLE_DIMENSIONS_KEY}  (forwarded to the source 
image)</li>
      *   <li>{@value #SAMPLE_RESOLUTIONS_KEY} (forwarded to the source 
image)</li>
      *   <li>{@value #MASK_KEY} if the image uses floating point numbers.</li>
      * </ul>
      *
-     * <div class="note"><b>Note:</b>
-     * the sample resolutions are retained because they should have 
approximately the same values before and after
+     * <h4>Note on sample values</h4>
+     * The sample resolutions are retained because they should have 
approximately the same values before and after
      * resampling. {@linkplain #STATISTICS_KEY Statistics} are not in this 
list because, while minimum and maximum
      * values should stay approximately the same, the average value and 
standard deviation may be quite different.
-     * </div>
      */
     @Override
     public Object getProperty(final String key) {
         switch (key) {
+            case SAMPLE_DIMENSIONS_KEY:
             case SAMPLE_RESOLUTIONS_KEY: {
                 return getSource().getProperty(key);
             }
@@ -527,6 +528,7 @@ public class ResampledImage extends ComputedImage {
     public String[] getPropertyNames() {
         final String[] inherited = getSource().getPropertyNames();
         final String[] names = {
+            SAMPLE_DIMENSIONS_KEY,
             SAMPLE_RESOLUTIONS_KEY,
             POSITIONAL_ACCURACY_KEY,
             POSITIONAL_CONSISTENCY_KEY,
diff --git 
a/core/sis-feature/src/main/java/org/apache/sis/image/UserProperties.java 
b/core/sis-feature/src/main/java/org/apache/sis/image/UserProperties.java
new file mode 100644
index 0000000000..a26f9718de
--- /dev/null
+++ b/core/sis-feature/src/main/java/org/apache/sis/image/UserProperties.java
@@ -0,0 +1,124 @@
+/*
+ * 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.Arrays;
+import java.util.Map;
+import java.util.Set;
+import java.util.HashSet;
+import java.awt.Image;
+import java.awt.image.RenderedImage;
+import org.apache.sis.util.ArgumentChecks;
+
+
+/**
+ * An image with some properties overwritten by user-specified properties.
+ * The property calculations may be deferred, and {@code null} is a valid 
property value.
+ *
+ * @author  Martin Desruisseaux (Geomatys)
+ * @version 1.4
+ * @since   1.4
+ */
+final class UserProperties extends ImageAdapter {
+    /**
+     * The user-specified properties which may overwrite source image 
properties.
+     * This is a reference to the map specified at construction time, not a 
copy.
+     * No copy is done for allowing the use of instances doing deferred 
computation.
+     * It is legal to have {@code null} value associated to keys: the meaning 
is not
+     * the same as "undefined properties".
+     *
+     * <p>This {@code UserProperties} class shall not modify the content of 
this map.</p>
+     */
+    private final Map<String,Object> properties;
+
+    /**
+     * Creates a new wrapper for the given image.
+     *
+     * @param  source  the image to wrap. The map is retained directly (not 
cloned).
+     */
+    @SuppressWarnings("AssignmentToCollectionOrArrayFieldFromParameter")
+    UserProperties(final RenderedImage source, final Map<String,Object> 
properties) {
+        super(source);
+        ArgumentChecks.ensureNonNull("properties", properties);
+        this.properties = properties;
+    }
+
+    /**
+     * Returns the names of all supported properties, including in wrapped 
image.
+     * The property names are computed on each invocation for allowing dynamic 
changes
+     * in source image properties or in {@link #properties} map.
+     *
+     * @return all recognized property names, or {@code null} if none.
+     */
+    @Override
+    public String[] getPropertyNames() {
+        String[] names = super.getPropertyNames();
+        if (!properties.isEmpty()) {
+            final Set<String> union;
+            if (names != null) {
+                union = new HashSet<>(Arrays.asList(names));
+                union.addAll(properties.keySet());
+            } else {
+                union = properties.keySet();
+            }
+            names = union.toArray(String[]::new);
+        }
+        return (names.length != 0) ? names : null;
+    }
+
+    /**
+     * Gets a property from this image or from its source.
+     *
+     * @param  name  name of the property to get.
+     * @return the property for the given name ({@code null} is a valid 
result),
+     *         or {@link Image#UndefinedProperty} if the given name is not a 
recognized property name.
+     */
+    @Override
+    public Object getProperty(final String name) {
+        Object value = properties.getOrDefault(name, Image.UndefinedProperty);
+        if (value == Image.UndefinedProperty) {
+            value = super.getProperty(name);
+        }
+        return value;
+    }
+
+    /**
+     * Compares the given object with this image for equality. This method 
should be quick and compare
+     * how images compute their values from their sources; it should not 
compare the actual pixel values.
+     */
+    @Override
+    public boolean equals(final Object object) {
+        return super.equals(object) && properties.equals(((UserProperties) 
object).properties);
+    }
+
+    /**
+     * Returns a hash code value for this image. This method should be quick.
+     */
+    @Override
+    public int hashCode() {
+        return super.hashCode() + 71 * properties.hashCode();
+    }
+
+    /**
+     * Appends a content to show in the {@link #toString()} representation.
+     */
+    @Override
+    Class<? extends ImageAdapter> appendStringContent(final StringBuilder 
buffer) {
+        buffer.append(properties.keySet());
+        return UserProperties.class;
+    }
+}
diff --git 
a/core/sis-feature/src/main/java/org/apache/sis/image/Visualization.java 
b/core/sis-feature/src/main/java/org/apache/sis/image/Visualization.java
index 4b8f38804f..35229898d1 100644
--- a/core/sis-feature/src/main/java/org/apache/sis/image/Visualization.java
+++ b/core/sis-feature/src/main/java/org/apache/sis/image/Visualization.java
@@ -41,7 +41,6 @@ import 
org.apache.sis.referencing.operation.transform.MathTransforms;
 import org.apache.sis.internal.coverage.SampleDimensions;
 import org.apache.sis.internal.coverage.CompoundTransform;
 import org.apache.sis.internal.coverage.j2d.ColorModelBuilder;
-import org.apache.sis.internal.coverage.j2d.ColorModelFactory;
 import org.apache.sis.internal.coverage.j2d.ImageLayout;
 import org.apache.sis.internal.coverage.j2d.ImageUtilities;
 import org.apache.sis.internal.feature.Resources;
@@ -118,7 +117,7 @@ final class Visualization extends ResampledImage {
      *
      * <p>This builder accepts two kinds of input:</p>
      * <ul>
-     *   <li>Non-null {@link #sourceBands} and {@link 
Target#categoryColors}.</li>
+     *   <li>Non-null {@link #sampleDimensions} and {@link 
Target#categoryColors}.</li>
      *   <li>Non-null {@link Target#rangeColors}.</li>
      * </ul>
      *
@@ -136,8 +135,8 @@ final class Visualization extends ResampledImage {
         /** Number of bands of the image to create. */
         private static final int NUM_BANDS = 1;
 
-        /** Band to make visible. */
-        private static final int VISIBLE_BAND = 
ColorModelFactory.DEFAULT_VISIBLE_BAND;
+        /** Band to make visible among the remaining {@value #NUM_BANDS} 
bands. */
+        private static final int VISIBLE_BAND = 0;
 
         ////  ┌─────────────────────────────────────┐
         ////  │ Arguments given by user             │
@@ -153,7 +152,7 @@ final class Visualization extends ResampledImage {
         private MathTransform toSource;
 
         /** Description of {@link #source} bands, or {@code null} if none. */
-        private List<SampleDimension> sourceBands;
+        private SampleDimension[] sampleDimensions;
 
         ////  ┌─────────────────────────────────────┐
         ////  │ Given by ImageProcesor.configure(…) │
@@ -190,18 +189,30 @@ final class Visualization extends ResampledImage {
         /**
          * Creates a builder for a visualization image with colors inferred 
from sample dimensions.
          *
-         * @param bounds       desired domain of pixel coordinates, or {@code 
null} if same as {@code source} image.
-         * @param source       the image for which to replace the color model.
-         * @param toSource     pixel coordinates conversion to {@code source} 
image, or {@code null} if none.
-         * @param sourceBands  description of {@code source} bands.
+         * @param bounds    desired domain of pixel coordinates, or {@code 
null} if same as {@code source} image.
+         * @param source    the image for which to replace the color model.
+         * @param toSource  pixel coordinates conversion to {@code source} 
image, or {@code null} if none.
          */
+        Builder(final Rectangle bounds, final RenderedImage source, final 
MathTransform toSource) {
+            this.bounds   = bounds;
+            this.source   = source;
+            this.toSource = toSource;
+            Object ranges = source.getProperty(SAMPLE_DIMENSIONS_KEY);
+            if (ranges instanceof SampleDimension[]) {
+                sampleDimensions = (SampleDimension[]) ranges;
+            }
+        }
+
+        @Deprecated(since="1.4", forRemoval=true)
         Builder(final Rectangle bounds, final RenderedImage source, final 
MathTransform toSource,
-                final List<SampleDimension> sourceBands)
+                final List<SampleDimension> sampleDimensions)
         {
-            this.bounds      = bounds;
-            this.source      = source;
-            this.toSource    = toSource;
-            this.sourceBands = sourceBands;
+            this.bounds   = bounds;
+            this.source   = source;
+            this.toSource = toSource;
+            if (sampleDimensions != null) {
+                this.sampleDimensions = 
sampleDimensions.toArray(SampleDimension[]::new);
+            }
         }
 
         /**
@@ -232,13 +243,16 @@ final class Visualization extends ResampledImage {
             }
             /*
              * Skip any previous `RecoloredImage` since we will replace the 
`ColorModel` by a new one.
+             * Discards image properties such as statistics because this image 
is not for computation.
              * Keep only the band to make visible in order to reduce the 
amount of calculation during
              * resampling and for saving memory.
              */
-            while (source instanceof RecoloredImage) {
-                source = ((RecoloredImage) source).source;
+            while (source instanceof ImageAdapter) {
+                source = ((ImageAdapter) source).source;
             }
             source = BandSelectImage.create(source, new int[] {visibleBand});
+            final SampleDimension visibleSD = (sampleDimensions != null && 
visibleBand < sampleDimensions.length)
+                                            ? sampleDimensions[visibleBand] : 
null;
             /*
              * If there is no conversion of pixel coordinates, there is no 
need for interpolations.
              * In such case the `Visualization.computeTile(…)` implementation 
takes a shortcut which
@@ -258,7 +272,7 @@ final class Visualization extends ResampledImage {
              * which must be done before to build the color model.
              */
             sampleModel = 
layout.createBandedSampleModel(ColorModelBuilder.TYPE_COMPACT, NUM_BANDS, 
source, bounds);
-            final Target target = new Target(sampleModel, visibleBand, 
sourceBands != null);
+            final Target target = new Target(sampleModel, VISIBLE_BAND, 
visibleSD != null);
             if (colorizer != null) {
                 colorModel = colorizer.apply(target).orElse(null);
             }
@@ -267,8 +281,8 @@ final class Visualization extends ResampledImage {
              * There is different ways to setup the builder, depending on 
which `Colorizer` is used.
              * In precedence order:
              *
-             *    - rangeColors  : Map<NumberRange<?>,Color[]>
-             *    - sourceBands  : List<SampleDimension>
+             *    - rangeColors      : Map<NumberRange<?>,Color[]>
+             *    - sampleDimensions : SampleDimension[]
              *    - statistics
              */
             boolean initialized;
@@ -282,7 +296,7 @@ final class Visualization extends ResampledImage {
                  * in various ways: sample dimensions, scaled color model, or 
image statistics in last resort.
                  */
                 builder = new ColorModelBuilder(target.categoryColors);
-                initialized = (sourceBands != null) && 
builder.initialize(coloredSource.getSampleModel(), 
sourceBands.get(visibleBand));
+                initialized = 
builder.initialize(coloredSource.getSampleModel(), visibleSD);
                 if (initialized) {
                     /*
                      * If we have been able to configure ColorModelBuilder 
using SampleDimension, apply an adjustment
@@ -314,7 +328,7 @@ final class Visualization extends ResampledImage {
                  * If none of above `ColorModelBuilder` configurations worked, 
use statistics in last resort.
                  * We do that after we reduced the image to a single band in 
order to reduce the amount of calculations.
                  */
-                final DoubleUnaryOperator[] sampleFilters = 
SampleDimensions.toSampleFilters(processor, sourceBands);
+                final DoubleUnaryOperator[] sampleFilters = 
SampleDimensions.toSampleFilters(visibleSD);
                 final Statistics statistics = 
processor.valueOfStatistics(source, null, sampleFilters)[VISIBLE_BAND];
                 builder.initialize(statistics.minimum(), statistics.maximum());
             }
diff --git 
a/core/sis-feature/src/main/java/org/apache/sis/internal/coverage/SampleDimensions.java
 
b/core/sis-feature/src/main/java/org/apache/sis/internal/coverage/SampleDimensions.java
index 349c4e6d6d..d896643025 100644
--- 
a/core/sis-feature/src/main/java/org/apache/sis/internal/coverage/SampleDimensions.java
+++ 
b/core/sis-feature/src/main/java/org/apache/sis/internal/coverage/SampleDimensions.java
@@ -19,6 +19,8 @@ package org.apache.sis.internal.coverage;
 import java.util.List;
 import java.util.Optional;
 import java.util.function.DoubleUnaryOperator;
+import java.awt.Shape;
+import java.awt.image.RenderedImage;
 import org.apache.sis.coverage.SampleDimension;
 import org.apache.sis.coverage.Category;
 import org.apache.sis.image.ImageProcessor;
@@ -30,10 +32,20 @@ import org.apache.sis.util.Static;
  * Utility methods working on {@link SampleDimension} instances.
  *
  * @author  Martin Desruisseaux (Geomatys)
- * @version 1.2
+ * @version 1.4
  * @since   1.2
  */
 public final class SampleDimensions extends Static {
+    /**
+     * The sample dimensions of a {@link 
org.apache.sis.image.BandedSampleConverter} image.
+     * We use this thread-local variable as an internal workaround for an 
parameter that we
+     * do not expose in the public API of {@link ImageProcessor}.
+     *
+     * <p>The content of the array in this thread-local variable shall not be 
modified,
+     * because it may be a direct reference to an internal array (not a 
clone).</p>
+     */
+    public static final ThreadLocal<SampleDimension[]> CONVERTED_BANDS = new 
ThreadLocal<>();
+
     /**
      * Do not allow instantiation of this class.
      */
@@ -49,13 +61,13 @@ public final class SampleDimensions extends Static {
      * @return the background values, or {@code null} if the given argument 
was null.
      *         Otherwise the returned array is never null but may contain null 
elements.
      */
-    public static Number[] backgrounds(final List<SampleDimension> bands) {
+    public static Number[] backgrounds(final SampleDimension... bands) {
         if (bands == null) {
             return null;
         }
-        final Number[] fillValues = new Number[bands.size()];
+        final Number[] fillValues = new Number[bands.length];
         for (int i=fillValues.length; --i >= 0;) {
-            final SampleDimension band = bands.get(i);
+            final SampleDimension band = bands[i];
             final Optional<Number> bg = band.getBackground();
             if (bg.isPresent()) {
                 fillValues[i] = bg.get();
@@ -66,25 +78,26 @@ public final class SampleDimensions extends Static {
 
     /**
      * Returns the {@code sampleFilters} arguments to use in a call to
-     * {@link ImageProcessor#statistics ImageProcessor.statistics(…)} for 
excluding no-data values.
+     * {@code ImageProcessor.statistics(…)} for excluding no-data values.
      * If the given sample dimensions are {@linkplain 
SampleDimension#converted() converted to units of measurement},
      * then all "no data" values are already NaN values and this method 
returns an array of {@code null} operators.
-     * Otherwise this method returns an array of operators that covert "no 
data" values to {@link Double#NaN}.
+     * Otherwise this method returns an array of operators that convert "no 
data" values to {@link Double#NaN}.
      *
      * <p>This method is not in public API because it partially duplicates the 
work
      * of {@linkplain SampleDimension#getTransferFunction() transfer 
function}.</p>
      *
-     * @param  processor  the processor to use for creating {@link 
DoubleUnaryOperator}.
-     * @param  bands      the sample dimensions for which to create {@code 
sampleFilters}, or {@code null}.
+     * @param  bands  the sample dimensions for which to create {@code 
sampleFilters}, or {@code null}.
      * @return the filters, or {@code null} if {@code bands} was null. The 
array may contain null elements.
+     *
+     * @see ImageProcessor#statistics(RenderedImage, Shape, 
DoubleUnaryOperator...)
      */
-    public static DoubleUnaryOperator[] toSampleFilters(final ImageProcessor 
processor, final List<SampleDimension> bands) {
+    public static DoubleUnaryOperator[] toSampleFilters(final 
SampleDimension... bands) {
         if (bands == null) {
             return null;
         }
-        final DoubleUnaryOperator[] sampleFilters = new 
DoubleUnaryOperator[bands.size()];
+        final DoubleUnaryOperator[] sampleFilters = new 
DoubleUnaryOperator[bands.length];
         for (int i = 0; i < sampleFilters.length; i++) {
-            final SampleDimension band = bands.get(i);
+            final SampleDimension band = bands[i];
             if (band != null) {
                 final List<Category> categories = band.getCategories();
                 final Number[] nodataValues = new Number[categories.size()];
@@ -103,7 +116,7 @@ public final class SampleDimensions extends Static {
                         nodataValues[j] = value;
                     }
                 }
-                sampleFilters[i] = processor.filterNodataValues(nodataValues);
+                sampleFilters[i] = 
ImageProcessor.filterNodataValues(nodataValues);
             }
         }
         return sampleFilters;
diff --git 
a/core/sis-feature/src/test/java/org/apache/sis/coverage/grid/ConvertedGridCoverageTest.java
 
b/core/sis-feature/src/test/java/org/apache/sis/coverage/grid/ConvertedGridCoverageTest.java
index 7c98795851..962168a6c1 100644
--- 
a/core/sis-feature/src/test/java/org/apache/sis/coverage/grid/ConvertedGridCoverageTest.java
+++ 
b/core/sis-feature/src/test/java/org/apache/sis/coverage/grid/ConvertedGridCoverageTest.java
@@ -18,6 +18,7 @@ package org.apache.sis.coverage.grid;
 
 import java.util.List;
 import java.awt.image.DataBuffer;
+import java.awt.image.RenderedImage;
 import org.opengis.referencing.datum.PixelInCell;
 import org.opengis.referencing.operation.MathTransform1D;
 import org.apache.sis.referencing.operation.transform.MathTransforms;
@@ -32,6 +33,7 @@ import org.junit.Test;
 
 import static org.apache.sis.test.FeatureAssert.*;
 import static org.apache.sis.test.TestUtilities.getSingleton;
+import static org.apache.sis.image.PlanarImage.SAMPLE_DIMENSIONS_KEY;
 
 
 /**
@@ -39,7 +41,7 @@ import static org.apache.sis.test.TestUtilities.getSingleton;
  *
  * @author  Johann Sorel (Geomatys)
  * @author  Martin Desruisseaux (Geomatys)
- * @version 1.3
+ * @version 1.4
  * @since   1.1
  */
 public final class ConvertedGridCoverageTest extends TestCase {
@@ -69,6 +71,20 @@ public final class ConvertedGridCoverageTest extends 
TestCase {
         return coverage;
     }
 
+    /**
+     * Creates a rendering of the given coverage and verifies that it contains
+     * a property for the sample dimensions.
+     */
+    private static RenderedImage render(final GridCoverage coverage) {
+        final RenderedImage image = coverage.render(null);
+        final Object bands = image.getProperty(SAMPLE_DIMENSIONS_KEY);
+        assertInstanceOf(SAMPLE_DIMENSIONS_KEY, SampleDimension[].class, 
bands);
+        assertArrayEquals(SAMPLE_DIMENSIONS_KEY,
+                coverage.getSampleDimensions().toArray(SampleDimension[]::new),
+                (SampleDimension[]) bands);
+        return image;
+    }
+
     /**
      * Tests forward conversion from packed values to "geophysics" values.
      * Test includes a conversion of an integer value to {@link Float#NaN}.
@@ -79,12 +95,12 @@ public final class ConvertedGridCoverageTest extends 
TestCase {
         /*
          * Verify values before and after conversion.
          */
-        assertValuesEqual(coverage.forConvertedValues(false).render(null), 0, 
new double[][] {
+        assertValuesEqual(render(coverage.forConvertedValues(false)), 0, new 
double[][] {
             {-1, 3}
         });
         final float nan = MathFunctions.toNanFloat(-1);
         assertTrue(Float.isNaN(nan));
-        assertValuesEqual(coverage.forConvertedValues(true).render(null), 0, 
new double[][] {
+        assertValuesEqual(render(coverage.forConvertedValues(true)), 0, new 
double[][] {
             {nan, 3}
         });
     }
@@ -101,7 +117,7 @@ public final class ConvertedGridCoverageTest extends 
TestCase {
         }, null);
         assertSame(target, target.forConvertedValues(true));
         assertSame(source, target.forConvertedValues(false));
-        assertValuesEqual(target.render(null), 0, new double[][] {
+        assertValuesEqual(render(target), 0, new double[][] {
             {90, 130}      // {-1, 3} × 10 + 100
         });
         final SampleDimension band = 
getSingleton(target.getSampleDimensions());
diff --git 
a/core/sis-feature/src/test/java/org/apache/sis/image/ImageProcessorTest.java 
b/core/sis-feature/src/test/java/org/apache/sis/image/ImageProcessorTest.java
index 4ad4b9f097..cf5c1a23bb 100644
--- 
a/core/sis-feature/src/test/java/org/apache/sis/image/ImageProcessorTest.java
+++ 
b/core/sis-feature/src/test/java/org/apache/sis/image/ImageProcessorTest.java
@@ -34,11 +34,38 @@ import static 
org.apache.sis.test.TestUtilities.getSingleton;
  * Tests {@link ImageProcessor}.
  *
  * @author  Martin Desruisseaux (Geomatys)
- * @version 1.1
+ * @version 1.4
  * @since   1.1
  */
 @DependsOn(org.apache.sis.internal.processing.isoline.IsolinesTest.class)
 public final class ImageProcessorTest extends TestCase {
+    /**
+     * The processor to test.
+     */
+    private final ImageProcessor processor;
+
+    /**
+     * Creates a new test case.
+     */
+    public ImageProcessorTest() {
+        processor = new ImageProcessor();
+    }
+
+    /**
+     * Tests {@link ImageProcessor#addUserProperties(RenderedImage, Map)}.
+     */
+    @Test
+    public void testAddUserProperties() {
+        final String key = "my-property";
+        final String value = "my-value";
+        final RenderedImage source = new BufferedImage(2, 2, 
BufferedImage.TYPE_BYTE_BINARY);
+        final RenderedImage image  = processor.addUserProperties(source, 
Map.of(key, value));
+        assertSame(BufferedImage.UndefinedProperty, source.getProperty(key));
+        assertSame(BufferedImage.UndefinedProperty,  
image.getProperty("another-property"));
+        assertSame(value, image.getProperty(key));
+        assertArrayEquals(new String[] {key}, image.getPropertyNames());
+    }
+
     /**
      * Tests {@link ImageProcessor#isolines(RenderedImage, double[][], 
MathTransform)}.
      */
@@ -46,8 +73,6 @@ public final class ImageProcessorTest extends TestCase {
     public void testIsolines() {
         final BufferedImage image = new BufferedImage(3, 3, 
BufferedImage.TYPE_BYTE_BINARY);
         image.getRaster().setSample(1, 1, 0, 1);
-
-        final ImageProcessor processor = new ImageProcessor();
         boolean parallel = false;
         do {
             processor.setExecutionMode(parallel ? 
ImageProcessor.Mode.SEQUENTIAL : ImageProcessor.Mode.PARALLEL);
diff --git 
a/core/sis-feature/src/test/java/org/apache/sis/image/StatisticsCalculatorTest.java
 
b/core/sis-feature/src/test/java/org/apache/sis/image/StatisticsCalculatorTest.java
index ad822fa29d..5fa7d1c10a 100644
--- 
a/core/sis-feature/src/test/java/org/apache/sis/image/StatisticsCalculatorTest.java
+++ 
b/core/sis-feature/src/test/java/org/apache/sis/image/StatisticsCalculatorTest.java
@@ -161,7 +161,7 @@ public final class StatisticsCalculatorTest extends 
TestCase {
     public void testWithSampleFilters() {
         final ImageProcessor operations = new ImageProcessor();
         sampleFilters = new DoubleUnaryOperator[] {
-            operations.filterNodataValues(100, 51324, 51323, 201, 310)
+            ImageProcessor.filterNodataValues(100, 51324, 51323, 201, 310)
         };
         compareParallelWithSequential(operations, 101, 51322);
     }
diff --git 
a/core/sis-portrayal/src/main/java/org/apache/sis/internal/map/coverage/RenderingData.java
 
b/core/sis-portrayal/src/main/java/org/apache/sis/internal/map/coverage/RenderingData.java
index 2ba1782b1f..bea3a65a64 100644
--- 
a/core/sis-portrayal/src/main/java/org/apache/sis/internal/map/coverage/RenderingData.java
+++ 
b/core/sis-portrayal/src/main/java/org/apache/sis/internal/map/coverage/RenderingData.java
@@ -20,6 +20,7 @@ import java.util.Map;
 import java.util.List;
 import java.util.HashMap;
 import java.util.Objects;
+import java.util.logging.Logger;
 import java.io.IOException;
 import java.io.UncheckedIOException;
 import java.awt.Graphics2D;
@@ -29,7 +30,6 @@ import java.awt.image.RenderedImage;
 import java.awt.geom.Rectangle2D;
 import java.awt.geom.AffineTransform;
 import java.awt.geom.NoninvertibleTransformException;
-import java.util.logging.Logger;
 import org.opengis.util.FactoryException;
 import org.opengis.geometry.DirectPosition;
 import org.opengis.metadata.extent.GeographicBoundingBox;
@@ -194,7 +194,7 @@ public class RenderingData implements Cloneable {
      * @see #setImageSpace(GridGeometry, List, int[])
      * @see #statistics()
      */
-    private List<SampleDimension> dataRanges;
+    private SampleDimension[] dataRanges;
 
     /**
      * Conversion or transformation from {@linkplain #data} CRS to {@linkplain 
PlanarCanvas#getObjectiveCRS()
@@ -306,10 +306,10 @@ public class RenderingData implements Cloneable {
      */
     @SuppressWarnings("AssignmentToCollectionOrArrayFieldFromParameter")
     public final void setImageSpace(final GridGeometry domain, final 
List<SampleDimension> ranges, final int[] xyDims) {
-        processor.setFillValues(SampleDimensions.backgrounds(ranges));
-        dataRanges   = ranges;      // Not cloned because already an 
unmodifiable list.
+        dataRanges   = (ranges != null) ? 
ranges.toArray(SampleDimension[]::new) : null;
         dataGeometry = domain;
         xyDimensions = xyDims;
+        processor.setFillValues(SampleDimensions.backgrounds(dataRanges));
         /*
          * If the grid geometry does not define a "grid to CRS" transform, set 
it to an identity transform.
          * We do that because this class needs a complete `GridGeometry` as 
much as possible.
@@ -536,7 +536,7 @@ public class RenderingData implements Cloneable {
                     image = coarse.render(sliceExtent);
                 }
             }
-            statistics = processor.valueOfStatistics(image, null, 
SampleDimensions.toSampleFilters(processor, dataRanges));
+            statistics = processor.valueOfStatistics(image, null, 
SampleDimensions.toSampleFilters(dataRanges));
         }
         final Map<String,Object> modifiers = new HashMap<>(8);
         modifiers.put("statistics", statistics);
@@ -652,7 +652,8 @@ public class RenderingData implements Cloneable {
         }
         /*
          * Apply a map projection on the image, then convert the floating 
point results to integer values
-         * that we can use with IndexColorModel.
+         * that we can use with `IndexColorModel`. The two operations 
(resampling and conversions) are
+         * combined in a single "visualization" operation of efficiency.
          *
          * TODO: if `colors` is null, instead of defaulting to 
`ColorModelBuilder.GRAYSCALE` we should get the colors
          *       from the current ColorModel. This work should be done in 
`ColorModelBuilder` by converting the ranges
@@ -661,13 +662,30 @@ public class RenderingData implements Cloneable {
          */
         if (CREATE_INDEX_COLOR_MODEL) {
             final ColorModelType ct = 
ColorModelType.find(recoloredImage.getColorModel());
-            if (ct.isSlow || (ct.useColorRamp && processor.getCategoryColors() 
!= null)) {
-                return processor.visualize(recoloredImage, bounds, 
displayToCenter, dataRanges);
+            if (ct.isSlow || (ct.useColorRamp && processor.getColorizer() != 
null)) {
+                return 
processor.visualize(withSampleDimensions(recoloredImage), bounds, 
displayToCenter);
             }
         }
         return processor.resample(recoloredImage, bounds, displayToCenter);
     }
 
+    /**
+     * Returns an image augmented with the sample dimensions if not already 
present.
+     * If the property is present but with a different value, the {@link 
#dataRanges}
+     * will overwrite the image property value.
+     *
+     * @param  image  the image for which to add a property if not already 
present.
+     * @return image augmented with the given property.
+     */
+    private RenderedImage withSampleDimensions(RenderedImage image) {
+        final String key = PlanarImage.SAMPLE_DIMENSIONS_KEY;
+        final SampleDimension[] value = dataRanges;
+        if (!Objects.deepEquals(image.getProperty(key), value)) {
+            image = processor.addUserProperties(image, Map.of(key, value));
+        }
+        return image;
+    }
+
     /**
      * Conversion or transformation from {@linkplain 
PlanarCanvas#getObjectiveCRS() objective CRS} to
      * {@linkplain #data} CRS. This transform will include {@code 
WraparoundTransform} steps if needed.
diff --git 
a/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/esri/RasterStore.java
 
b/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/esri/RasterStore.java
index 497a3b8b0e..fc83cb467c 100644
--- 
a/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/esri/RasterStore.java
+++ 
b/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/esri/RasterStore.java
@@ -473,24 +473,24 @@ abstract class RasterStore extends PRJDataStore 
implements GridCoverageResource
     final GridCoverage2D createCoverage(final GridGeometry domain, final 
RangeArgument range,
                                         final WritableRaster data, final 
Statistics stats)
     {
+        final SampleDimension[] bands = range.select(sampleDimensions);
         Hashtable<String,Object> properties = null;
         if (stats != null) {
             final Statistics[] as = new Statistics[range.getNumBands()];
             Arrays.fill(as, stats);
             properties = new Hashtable<>();
             properties.put(PlanarImage.STATISTICS_KEY, as);
+            properties.put(PlanarImage.SAMPLE_DIMENSIONS_KEY, bands);
         }
-        List<SampleDimension> bands = sampleDimensions;
         ColorModel cm = colorModel;
         if (!range.isIdentity()) {
-            bands = Arrays.asList(range.select(sampleDimensions));
             cm = range.select(cm);
             if (cm == null) {
-                final SampleDimension band = bands.get(VISIBLE_BAND);
+                final SampleDimension band = bands[VISIBLE_BAND];
                 cm = ColorModelFactory.createGrayScale(data.getSampleModel(), 
VISIBLE_BAND, band.getSampleRange().orElse(null));
             }
         }
-        return new GridCoverage2D(domain, bands, new BufferedImage(cm, data, 
false, properties));
+        return new GridCoverage2D(domain, Arrays.asList(bands), new 
BufferedImage(cm, data, false, properties));
     }
 
     /**

Reply via email to