sis-commits mailing list archives

Site index · List index
Message view « Date » · « Thread »
Top « Date » · « Thread »
From desruisse...@apache.org
Subject [sis] 02/10: Add a `Colorizer` internal class for applying color ramp on raster of floating point values.
Date Wed, 29 Jul 2020 16:18:55 GMT
This is an automated email from the ASF dual-hosted git repository.

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

commit 6d2305192d513807480cddafa0337ee3b275521a
Author: Martin Desruisseaux <martin.desruisseaux@geomatys.com>
AuthorDate: Mon Jul 27 17:40:16 2020 +0200

    Add a `Colorizer` internal class for applying color ramp on raster of floating point values.
---
 .../apache/sis/internal/gui/ImageConverter.java    |  13 +-
 .../java/org/apache/sis/coverage/Category.java     |  28 ++
 .../org/apache/sis/coverage/ConvertedCategory.java |  14 +-
 .../org/apache/sis/coverage/SampleDimension.java   |  23 +-
 .../org/apache/sis/coverage/grid/GridCoverage.java |  14 +-
 .../sis/coverage/grid/GridCoverageBuilder.java     |  16 +-
 .../apache/sis/coverage/grid/ImageRenderer.java    |  14 +-
 .../java/org/apache/sis/image/ImageProcessor.java  |  82 +++-
 .../java/org/apache/sis/image/RecoloredImage.java  | 123 ++++-
 .../internal/coverage/j2d/ColorModelFactory.java   |  88 +---
 .../sis/internal/coverage/j2d/Colorizer.java       | 542 +++++++++++++++++++++
 .../sis/internal/coverage/j2d/ColorsForRange.java  | 171 +++++++
 .../sis/internal/coverage/j2d/ImageUtilities.java  |  15 +-
 .../internal/coverage/j2d/ScaledColorSpace.java    |  32 +-
 .../org/apache/sis/internal/feature/Resources.java |  15 +-
 .../sis/internal/feature/Resources.properties      |   3 +-
 .../sis/internal/feature/Resources_fr.properties   |   3 +-
 .../sis/internal/coverage/j2d/ColorizerTest.java   | 155 ++++++
 .../apache/sis/test/suite/FeatureTestSuite.java    |   1 +
 .../java/org/apache/sis/measure/NumberRange.java   |   2 +-
 .../java/org/apache/sis/util/resources/Errors.java |  10 +
 .../apache/sis/util/resources/Errors.properties    |   2 +
 .../apache/sis/util/resources/Errors_fr.properties |   2 +
 23 files changed, 1221 insertions(+), 147 deletions(-)

diff --git a/application/sis-javafx/src/main/java/org/apache/sis/internal/gui/ImageConverter.java b/application/sis-javafx/src/main/java/org/apache/sis/internal/gui/ImageConverter.java
index a5c58d0..c95ba45 100644
--- a/application/sis-javafx/src/main/java/org/apache/sis/internal/gui/ImageConverter.java
+++ b/application/sis-javafx/src/main/java/org/apache/sis/internal/gui/ImageConverter.java
@@ -16,6 +16,8 @@
  */
 package org.apache.sis.internal.gui;
 
+import java.util.Map;
+import java.awt.Color;
 import java.awt.Graphics2D;
 import java.awt.Rectangle;
 import java.awt.geom.AffineTransform;
@@ -29,10 +31,12 @@ import javafx.scene.image.PixelWriter;
 import javafx.scene.image.WritableImage;
 import org.apache.sis.image.ImageProcessor;
 import org.apache.sis.image.PlanarImage;
+import org.apache.sis.internal.coverage.j2d.ColorModelFactory;
 import org.apache.sis.internal.coverage.j2d.ImageUtilities;
 import org.apache.sis.internal.system.Loggers;
 import org.apache.sis.internal.jdk9.JDK9;
 import org.apache.sis.util.logging.Logging;
+import org.apache.sis.measure.NumberRange;
 import org.apache.sis.math.Statistics;
 
 
@@ -60,6 +64,13 @@ final class ImageConverter extends Task<Statistics[]> {
     private static final int MAX_SIZE = 600;
 
     /**
+     * Colors to apply on the mask image when that image is overlay on top of another image.
+     */
+    private static final Map<NumberRange<?>,Color[]> MASK_TRANSPARENCY = JDK9.mapOf(
+            NumberRange.create(0, true, 0, true), new Color[] {ColorModelFactory.TRANSPARENT},
+            NumberRange.create(1, true, 1, true), new Color[] {new Color(0x20FFFF00, true)});
+
+    /**
      * The Java2D image to convert.
      */
     private final RenderedImage source;
@@ -139,7 +150,7 @@ final class ImageConverter extends Task<Statistics[]> {
     private RenderedImage getMask(final ImageProcessor processor) {
         final Object mask = source.getProperty(PlanarImage.MASK_KEY);
         if (mask instanceof RenderedImage) try {
-            return processor.recolor((RenderedImage) mask, new int[] {0, 0x20FFFF00});
+            return processor.toIndexedColors((RenderedImage) mask, MASK_TRANSPARENCY);
         } catch (IllegalArgumentException e) {
             // Ignore, we will not apply any mask. Declare PropertyView.setImage(…) as the public method.
             Logging.recoverableException(Logging.getLogger(Loggers.APPLICATION), PropertyView.class, "setImage", e);
diff --git a/core/sis-feature/src/main/java/org/apache/sis/coverage/Category.java b/core/sis-feature/src/main/java/org/apache/sis/coverage/Category.java
index dfb2632..0fa5ab7 100644
--- a/core/sis-feature/src/main/java/org/apache/sis/coverage/Category.java
+++ b/core/sis-feature/src/main/java/org/apache/sis/coverage/Category.java
@@ -351,6 +351,8 @@ public class Category implements Serializable {
      * The category that describes values after {@linkplain #getTransferFunction() transfer function} has been applied.
      * If the values are already converted (eventually to NaN values), returns {@code this}.  This method differs from
      * {@link #converse} field in being unidirectional: navigate from sample to converted values but never backward.
+     *
+     * @see #forConvertedValues(boolean)
      */
     Category converted() {
         return converse;        // Overridden in ConvertedCategory.
@@ -457,6 +459,32 @@ public class Category implements Serializable {
     }
 
     /**
+     * Returns a category that describes measurement values or packed values,
+     * depending if {@code converted} is {@code true} or {@code false} respectively.
+     * Notes:
+     *
+     * <ul class="verbose">
+     *   <li>The converted values of a qualitative category is a NaN value.</li>
+     *   <li>The converted values of a {@linkplain #isQuantitative() quantitative} category are real values.
+     *       Those values are computed by the {@linkplain #getTransferFunction() transfer function}.
+     *       That function may be identity, in which case this method returns {@code this}.</li>
+     * </ul>
+     *
+     * @param  converted  {@code true} for a category describing values in units of measurement,
+     *                    or {@code false} for a category describing packed values (usually as integers).
+     * @return a category describing converted or packed values, depending on {@code converted} argument value.
+     *         May be {@code this} but never {@code null}.
+     *
+     * @see #getMeasurementRange()
+     * @see SampleDimension#forConvertedValues(boolean)
+     *
+     * @since 1.1
+     */
+    public Category forConvertedValues(final boolean converted) {
+        return converted ? converse : this;                             // Overridden in ConvertedCategory.
+    }
+
+    /**
      * Returns the identity transform. This is the value returned by {@link ConvertedCategory#getTransferFunction()}.
      */
     static MathTransform1D identity() {
diff --git a/core/sis-feature/src/main/java/org/apache/sis/coverage/ConvertedCategory.java b/core/sis-feature/src/main/java/org/apache/sis/coverage/ConvertedCategory.java
index 16b91cc..a80d55e 100644
--- a/core/sis-feature/src/main/java/org/apache/sis/coverage/ConvertedCategory.java
+++ b/core/sis-feature/src/main/java/org/apache/sis/coverage/ConvertedCategory.java
@@ -28,7 +28,7 @@ import org.opengis.referencing.operation.TransformException;
  * or an empty optional if this category is a qualitative one.
  *
  * @author  Martin Desruisseaux (IRD, Geomatys)
- * @version 1.0
+ * @version 1.1
  * @since   1.0
  * @module
  */
@@ -64,6 +64,18 @@ final class ConvertedCategory extends Category {
     }
 
     /**
+     * Returns a category that describes real values or sample values, depending if {@code converted} is {@code true}
+     * or {@code false} respectively.
+     *
+     * @param  converted  {@code true} for a category describing converted values,
+     *                    or {@code false} for a category describing packed values.
+     */
+    @Override
+    public Category forConvertedValues(final boolean converted) {
+        return converted ? this : converse;
+    }
+
+    /**
      * Returns the <cite>transfer function</cite> from sample values to real values in units of measurement.
      * The function is absent if this category is not a {@linkplain #isQuantitative() quantitative} category.
      */
diff --git a/core/sis-feature/src/main/java/org/apache/sis/coverage/SampleDimension.java b/core/sis-feature/src/main/java/org/apache/sis/coverage/SampleDimension.java
index 8b86eab..4754e0c 100644
--- a/core/sis-feature/src/main/java/org/apache/sis/coverage/SampleDimension.java
+++ b/core/sis-feature/src/main/java/org/apache/sis/coverage/SampleDimension.java
@@ -426,6 +426,7 @@ public class SampleDimension implements Serializable {
      * @return a sample dimension describing converted or packed values, depending on {@code converted} argument value.
      *         May be {@code this} but never {@code null}.
      *
+     * @see Category#forConvertedValues(boolean)
      * @see org.apache.sis.coverage.grid.GridCoverage#forConvertedValues(boolean)
      */
     public SampleDimension forConvertedValues(final boolean converted) {
@@ -746,11 +747,18 @@ public class SampleDimension implements Serializable {
          *
          * @param  name    the category name as a {@link String} or {@link InternationalString} object,
          *                 or {@code null} for a default "no data" name.
-         * @param  sample  the sample value as a real number.
+         * @param  sample  the sample value as a real number or a NaN value.
          * @return {@code this}, for method call chaining.
          */
         public Builder addQualitative(final CharSequence name, final float sample) {
-            return addQualitative(name, NumberRange.create(sample, true, sample, true));
+            final NumberRange<Float> range;
+            if (Float.isNaN(sample)) {
+                final Float wrapper = sample;
+                range = new NumberRange<>(Float.class, wrapper, true, wrapper, true);
+            } else {
+                range = NumberRange.create(sample, true, sample, true);
+            }
+            return addQualitative(name, range);
         }
 
         /**
@@ -761,11 +769,18 @@ public class SampleDimension implements Serializable {
          *
          * @param  name    the category name as a {@link String} or {@link InternationalString} object,
          *                 or {@code null} for a default "no data" name.
-         * @param  sample  the sample value as a real number.
+         * @param  sample  the sample value as a real number or a NaN value.
          * @return {@code this}, for method call chaining.
          */
         public Builder addQualitative(final CharSequence name, final double sample) {
-            return addQualitative(name, NumberRange.create(sample, true, sample, true));
+            final NumberRange<Double> range;
+            if (Double.isNaN(sample)) {
+                final Double wrapper = sample;
+                range = new NumberRange<>(Double.class, wrapper, true, wrapper, true);
+            } else {
+                range = NumberRange.create(sample, true, sample, true);
+            }
+            return addQualitative(name, range);
         }
 
         /**
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 d4a72ba..20bb9fb 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
@@ -32,8 +32,8 @@ import org.apache.sis.measure.NumberRange;
 import org.apache.sis.coverage.SampleDimension;
 import org.apache.sis.coverage.SubspaceNotSpecifiedException;
 import org.apache.sis.internal.coverage.j2d.BandedSampleConverter;
-import org.apache.sis.internal.coverage.j2d.ColorModelFactory;
 import org.apache.sis.internal.coverage.j2d.ImageUtilities;
+import org.apache.sis.internal.coverage.j2d.Colorizer;
 import org.apache.sis.util.collection.DefaultTreeTable;
 import org.apache.sis.util.collection.TableColumn;
 import org.apache.sis.util.collection.TreeTable;
@@ -245,10 +245,14 @@ public abstract class GridCoverage {
      */
     final RenderedImage convert(final RenderedImage source, final int dataType, final MathTransform1D[] converters) {
         final int visibleBand = Math.max(0, ImageUtilities.getVisibleBand(source));
-        final ColorModel cm = ColorModelFactory.createColorModel(dataType, sampleDimensions.length, visibleBand,
-                                    sampleDimensions[visibleBand].getCategories(), ColorModelFactory.GRAYSCALE);
-
-       return BandedSampleConverter.create(source, null, dataType, cm, getRanges(), converters);
+        final Colorizer colorizer = new Colorizer(Colorizer.GRAYSCALE);
+        final ColorModel colors;
+        if (colorizer.initialize(sampleDimensions[visibleBand]) || colorizer.initialize(source.getColorModel())) {
+            colors = colorizer.createColorModel(dataType, sampleDimensions.length, visibleBand);
+        } else {
+            colors = Colorizer.NULL_COLOR_MODEL;
+        }
+        return BandedSampleConverter.create(source, null, dataType, colors, getRanges(), converters);
     }
 
     /**
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 c6f5cd3..4133641 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
@@ -29,11 +29,12 @@ import java.awt.image.ColorModel;
 import java.awt.image.DataBuffer;
 import java.awt.image.Raster;
 import java.awt.image.RenderedImage;
+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.coverage.SampleDimension;
-import org.apache.sis.internal.coverage.j2d.ColorModelFactory;
+import org.apache.sis.internal.coverage.j2d.Colorizer;
 import org.apache.sis.internal.coverage.j2d.TiledImage;
 import org.apache.sis.internal.feature.Resources;
 import org.apache.sis.referencing.operation.matrix.Matrices;
@@ -461,14 +462,19 @@ public class GridCoverageBuilder {
                  * will infer better names.
                  */
                 bands = GridCoverage2D.defaultIfAbsent(bands, null, raster.getNumBands());
-                final int dataType = raster.getSampleModel().getDataType();
-                final ColorModel colors = ColorModelFactory.createColorModel(dataType, bands.size(), visibleBand,
-                                            bands.get(visibleBand).getCategories(), ColorModelFactory.GRAYSCALE);
+                final SampleModel sm = raster.getSampleModel();
+                final Colorizer colorizer = new Colorizer(Colorizer.GRAYSCALE);
+                final ColorModel colors;
+                if (colorizer.initialize(bands.get(visibleBand)) || colorizer.initialize(sm, visibleBand)) {
+                    colors = colorizer.createColorModel(sm.getDataType(), bands.size(), visibleBand);
+                } else {
+                    colors = Colorizer.NULL_COLOR_MODEL;
+                }
                 /*
                  * Create an image from the raster. We favor BufferedImage instance when possible,
                  * and fallback on TiledImage only if the BufferedImage can not be created.
                  */
-                if (raster instanceof WritableRaster && raster.getMinX() == 0 && raster.getMinY() == 0) {
+                if (colors != null && raster instanceof WritableRaster && (raster.getMinX() | raster.getMinY()) == 0) {
                     image = new BufferedImage(colors, (WritableRaster) raster, false, properties);
                 } else {
                     image = new TiledImage(properties, colors, raster.getWidth(), raster.getHeight(), 0, 0, raster);
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 b01351b..231ff8f 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
@@ -34,8 +34,8 @@ import org.opengis.geometry.MismatchedDimensionException;
 import org.apache.sis.coverage.SubspaceNotSpecifiedException;
 import org.apache.sis.coverage.MismatchedCoverageRangeException;
 import org.apache.sis.coverage.SampleDimension;
+import org.apache.sis.internal.coverage.j2d.Colorizer;
 import org.apache.sis.internal.coverage.j2d.DeferredProperty;
-import org.apache.sis.internal.coverage.j2d.ColorModelFactory;
 import org.apache.sis.internal.coverage.j2d.RasterFactory;
 import org.apache.sis.internal.coverage.j2d.TiledImage;
 import org.apache.sis.internal.feature.Resources;
@@ -622,9 +622,13 @@ public class ImageRenderer {
     @SuppressWarnings("UseOfObsoleteCollectionType")
     public RenderedImage image() {
         final WritableRaster raster = raster();
-        final ColorModel colors = ColorModelFactory.createColorModel(buffer.getDataType(), bands.length, visibleBand,
-                                                    bands[visibleBand].getCategories(), ColorModelFactory.GRAYSCALE);
-
+        final Colorizer colorizer = new Colorizer(Colorizer.GRAYSCALE);
+        final ColorModel colors;
+        if (colorizer.initialize(bands[visibleBand]) || colorizer.initialize(raster.getSampleModel(), visibleBand)) {
+            colors = colorizer.createColorModel(buffer.getDataType(), bands.length, visibleBand);
+        } else {
+            colors = Colorizer.NULL_COLOR_MODEL;
+        }
         SliceGeometry supplier = null;
         if (imageGeometry == null) {
             if (isSameGeometry(GridCoverage2D.BIDIMENSIONAL)) {
@@ -633,7 +637,7 @@ public class ImageRenderer {
                 supplier = new SliceGeometry(geometry, sliceExtent, gridDimensions, null);
             }
         }
-        if ((imageX | imageY) == 0) {
+        if (colors != null && (imageX | imageY) == 0) {
             return new Untiled(colors, raster, properties, imageGeometry, supplier);
         }
         if (properties == null) {
diff --git a/core/sis-feature/src/main/java/org/apache/sis/image/ImageProcessor.java b/core/sis-feature/src/main/java/org/apache/sis/image/ImageProcessor.java
index 661da66..4e27f41 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
@@ -20,8 +20,10 @@ import java.util.Map;
 import java.util.List;
 import java.util.Arrays;
 import java.util.Objects;
+import java.util.function.Function;
 import java.util.logging.Filter;
 import java.util.logging.LogRecord;
+import java.awt.Color;
 import java.awt.Shape;
 import java.awt.Rectangle;
 import java.awt.image.ColorModel;
@@ -31,14 +33,19 @@ import java.awt.image.RenderedImage;
 import java.awt.image.ImagingOpException;
 import java.awt.image.IndexColorModel;
 import javax.measure.Quantity;
+import org.apache.sis.coverage.Category;
 import org.opengis.referencing.operation.MathTransform;
+import org.opengis.referencing.operation.NoninvertibleTransformException;
+import org.apache.sis.referencing.operation.transform.MathTransforms;
+import org.apache.sis.coverage.SampleDimension;
 import org.apache.sis.math.Statistics;
 import org.apache.sis.util.ArraysExt;
 import org.apache.sis.util.ArgumentChecks;
 import org.apache.sis.util.collection.WeakHashSet;
 import org.apache.sis.internal.system.Modules;
 import org.apache.sis.internal.coverage.j2d.TiledImage;
-import org.apache.sis.referencing.operation.transform.MathTransforms;
+import org.apache.sis.internal.feature.Resources;
+import org.apache.sis.measure.NumberRange;
 import org.apache.sis.measure.Units;
 
 
@@ -568,7 +575,7 @@ public class ImageProcessor implements Cloneable {
      *
      * @param  source     the image to recolor.
      * @param  modifiers  modifiers for narrowing the range of values, or {@code null} if none.
-     * @return the image with color ramp stretched between the automatic bounds,
+     * @return the image with color ramp stretched between the specified or calculated bounds,
      *         or {@code image} unchanged if the operation can not be applied on the given image.
      */
     public RenderedImage stretchColorRamp(final RenderedImage source, final Map<String,?> modifiers) {
@@ -577,17 +584,72 @@ public class ImageProcessor implements Cloneable {
     }
 
     /**
-     * Changes the color ramp of the given image. The given image must use an {@link IndexColorModel}
-     * with the same number of colors than the given {@code ARGB} array length.
+     * 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
+     * are used as-is (they are not copied or converted). Otherwise this operation will convert sample
+     * values to unsigned bytes in order to enable the use of {@link IndexColorModel}.
+     *
+     * <p>The given map specifies the color to use for different ranges of values in the source image.
+     * The ranges of values in the returned image may not be the same; this method is free to rescale them.
+     * 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>
      *
-     * @param  source  the image for which to replace the color model.
-     * @param  ARGB    Alpha=Red=Green=Blue codes of new color map.
-     * @return image using the given color map.
+     * <p>The resulting image is suitable for visualization purposes, but should not be used for computation
+     * purposes. There is no guarantees about the number of bands in returned image and the formulas used for
+     * converting floating point values to integer values.</p>
+     *
+     * @param  source  the image to recolor for visualization purposes.
+     * @param  colors  colors to use for each range of values in the source image.
+     * @return recolored image for visualization purposes only.
      */
-    public RenderedImage recolor(final RenderedImage source, final int[] ARGB) {
+    public RenderedImage toIndexedColors(final RenderedImage source, final Map<NumberRange<?>,Color[]> colors) {
+        ArgumentChecks.ensureNonNull("source", source);
+        ArgumentChecks.ensureNonNull("colors", colors);
+        try {
+            return RecoloredImage.toIndexedColors(this, source, null, null, colors.entrySet());
+        } catch (IllegalStateException | NoninvertibleTransformException e) {
+            throw new IllegalArgumentException(Resources.format(Resources.Keys.UnconvertibleSampleValues), e);
+        }
+    }
+
+    /**
+     * 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
+     * are used as-is (they are not copied or converted). Otherwise this operation will convert sample
+     * values to unsigned bytes in order to enable the use of {@link IndexColorModel}.
+     *
+     * <p>This method is similar to {@link #toIndexedColors(RenderedImage, Map)}
+     * except that the {@link Map} argument is splitted in two parts: the ranges (map keys) are
+     * {@linkplain Category#getSampleRange() encapsulated in <code>Category</code>} objects, themselves
+     * {@linkplain SampleDimension#getCategories() encapsulated in <code>SampleDimension</code>} objects.
+     * The colors (map values) are determined by a function receiving {@link Category} inputs.
+     * This separation makes easier to apply colors based on criterion other than numerical values.
+     * For example colors could be determined from {@linkplain Category#getName() category name} such as "Temperature",
+     * or {@linkplain org.apache.sis.measure.MeasurementRange#unit() units of measurement}.
+     * 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>
+     *
+     * <p>The resulting image is suitable for visualization purposes, but should not be used for computation
+     * purposes. There is no guarantees about the number of bands in returned image and the formulas used for
+     * converting floating point values to integer values.</p>
+     *
+     * @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()}.
+     * @param  colors  the colors to use for given categories. This function can return {@code null} or
+     *                 empty arrays for some categories, which are interpreted as fully transparent pixels.
+     * @return recolored image for visualization purposes only.
+     */
+    public RenderedImage toIndexedColors(final RenderedImage source,
+            final List<SampleDimension> ranges, final Function<Category,Color[]> colors)
+    {
         ArgumentChecks.ensureNonNull("source", source);
-        ArgumentChecks.ensureNonNull("ARGB", ARGB);
-        return RecoloredImage.recolor(source, ARGB);
+        ArgumentChecks.ensureNonNull("colors", colors);
+        try {
+            return RecoloredImage.toIndexedColors(this, source, ranges, colors, null);
+        } catch (IllegalStateException | NoninvertibleTransformException e) {
+            throw new IllegalArgumentException(Resources.format(Resources.Keys.UnconvertibleSampleValues), e);
+        }
     }
 
     /**
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 05f9e31..7c5ac5c 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
@@ -17,19 +17,29 @@
 package org.apache.sis.image;
 
 import java.util.Map;
+import java.util.List;
+import java.util.Collection;
+import java.util.function.Function;
 import java.awt.Shape;
+import java.awt.Color;
 import java.awt.image.ColorModel;
 import java.awt.image.IndexColorModel;
 import java.awt.image.SampleModel;
 import java.awt.image.RenderedImage;
+import java.awt.image.DataBuffer;
+import org.opengis.referencing.operation.MathTransform1D;
+import org.opengis.referencing.operation.NoninvertibleTransformException;
+import org.apache.sis.coverage.Category;
+import org.apache.sis.coverage.SampleDimension;
+import org.apache.sis.internal.coverage.j2d.BandedSampleConverter;
 import org.apache.sis.internal.coverage.j2d.ColorModelFactory;
+import org.apache.sis.internal.coverage.j2d.Colorizer;
 import org.apache.sis.internal.coverage.j2d.ImageUtilities;
 import org.apache.sis.internal.feature.Resources;
-import org.apache.sis.internal.util.Strings;
 import org.apache.sis.util.resources.Errors;
 import org.apache.sis.util.ArgumentChecks;
+import org.apache.sis.measure.NumberRange;
 import org.apache.sis.math.Statistics;
-import org.apache.sis.util.Classes;
 
 
 /**
@@ -168,33 +178,98 @@ final class RecoloredImage extends ImageAdapter {
     }
 
     /**
-     * Changes the color ramp of the given image. The given image must use an {@link IndexColorModel}
-     * with the same number of colors than the given {@code ARGB} array length.
+     * 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
+     * are used as-is (they are not copied or converted). Otherwise this operation will convert sample
+     * values to unsigned bytes in order to enable the use of {@link IndexColorModel}.
      *
-     * @param  source  the image for which to replace the color model.
-     * @param  ARGB    Alpha=Red=Green=Blue codes of new color map.
-     * @return image using the given color map.
+     * <p>This method accepts two kinds of input. Use only one of the followings:</p>
+     * <ul>
+     *   <li>Non-null {@code sourceBands} and {@code colors}.</li>
+     *   <li>Non-null {@code rangesAndColors}.</li>
+     * </ul>
      *
-     * @see ImageProcessor#recolor(RenderedImage, int[])
+     * The resulting image is suitable for visualization purposes but should not be used for computation purposes.
+     * There is no guarantees about the number of bands in returned image and the formulas used for converting
+     * floating point values to integer values.
+     *
+     * @param  processor        the processor to use for computing statistics if needed.
+     * @param  source           the image for which to replace the color model.
+     * @param  sourceBands      description of {@code source} bands, or {@code null} if none.
+     * @param  colors           the colors to use for each category. The function may return {@code null}, which
+     *                          means transparent. This parameter is used only if {@code rangesAndColors} is null.
+     * @param  rangesAndColors  range of sample values in source image associated to colors to apply,
+     *                          or {@code null} for using {@code sourceBands} and {@code colors} instead.
+     * @return recolored image for visualization purposes only.
+     * @throws NoninvertibleTransformException if sample values in source image can not be converted
+     *         to sample values in the recolored image.
+     *
+     * @see ImageProcessor#toIndexedColors(RenderedImage, Map)
      */
-    static RenderedImage recolor(final RenderedImage source, final int[] ARGB) {
-        String expected, actual;                                // To be used in case of error.
-        final ColorModel cm = source.getColorModel();
-        if (cm instanceof IndexColorModel) {
-            final IndexColorModel icm = (IndexColorModel) cm;
-            if (icm.getMapSize() == ARGB.length) {
-                return create(source, ColorModelFactory.createIndexColorModel(
-                                        ImageUtilities.getNumBands(source),
-                                        ImageUtilities.getVisibleBand(source), ARGB, -1));
+    static RenderedImage toIndexedColors(final ImageProcessor processor, RenderedImage source,
+            final List<SampleDimension> sourceBands, final Function<Category,Color[]> colors,
+            final Collection<Map.Entry<NumberRange<?>,Color[]>> rangesAndColors)
+            throws NoninvertibleTransformException
+    {
+        final int visibleBand = ImageUtilities.getVisibleBand(source);
+        if (visibleBand < 0) {
+            throw new IllegalArgumentException(Resources.format(Resources.Keys.OperationRequiresSingleBand));
+        }
+        boolean initialized;
+        final Colorizer colorizer;
+        if (rangesAndColors != null) {
+            colorizer = new Colorizer(rangesAndColors);
+            initialized = true;
+        } else {
+            /*
+             * Ranges of sample values were not specified explicitly. Instead we will try to infer them
+             * in various ways: sample dimensions, scaled color model, statistics in last resort.
+             */
+            colorizer = new Colorizer(colors);
+            initialized = (sourceBands != null) && colorizer.initialize(sourceBands.get(visibleBand));
+            if (initialized) {
+                /*
+                 * If we have been able to configure Colorizer using the SampleModel, apply an adjustment based
+                 * on the ScaledColorModel if it exists.  Use case: an image is created with an IndexColorModel
+                 * determined by the SampleModel, then user enhanced contrast by a call to `stretchColorRamp(…)`
+                 * above. We want to preserve that contrast enhancement.
+                 */
+                colorizer.rescaleMainRange(source.getColorModel());
             } else {
-                expected = Strings.toIndexed("IndexColorModel", icm.getMapSize());
-                actual   = Strings.toIndexed("IndexColorModel", ARGB.length);
+                /*
+                 * If we have not been able to use the SampleDimension, try to use the ColorModel or SampleModel.
+                 * There is no call to `rescaleMainRange(…)` because the following code already uses the range
+                 * specified by the ColorModel, if available.
+                 */
+                initialized = colorizer.initialize(source.getColorModel()) ||
+                              colorizer.initialize(source.getSampleModel(), visibleBand);
             }
-        } else {
-            expected = "IndexColorModel";
-            actual   = Classes.getShortClassName(cm.getClass());
         }
-        throw new IllegalArgumentException(Resources.format(Resources.Keys.UnsupportedColorModel_2, expected, actual));
+        source = BandSelectImage.create(source, new int[] {visibleBand});               // Make single-banded.
+        if (!initialized) {
+            /*
+             * If none of above Colorizer 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 Statistics statistics = processor.getStatistics(source, null)[0];
+            colorizer.initialize(statistics.minimum(), statistics.maximum());
+        }
+        /*
+         * If the source image uses unsigned integer types, we can update the color model without changing
+         * the sample values. This is much cheaper and as accurate.
+         */
+        final int dataType = ImageUtilities.getDataType(source);
+        if (dataType == DataBuffer.TYPE_BYTE || dataType == DataBuffer.TYPE_USHORT) {
+            return create(source, colorizer.createColorModel(dataType, 1, 0));
+        }
+        /*
+         * Sample values can not be reused as-is; we need to convert them to integers in [0 … 255] range.
+         */
+        final ColorModel      colorModel = colorizer.compactColorModel(1, 0);           // Must be first.
+        final MathTransform1D converter  = colorizer.getSampleToIndexValues();
+        final NumberRange<?>  range      = colorizer.getRepresentativeRange();
+        return BandedSampleConverter.create(source, null, Colorizer.TYPE_COMPACT, colorModel,
+                                            new NumberRange<?>[] {range}, converter);
     }
 
     /**
@@ -218,7 +293,7 @@ final class RecoloredImage extends ImageAdapter {
      */
     @Override
     public int hashCode() {
-        return super.hashCode() + 37*colors.hashCode();
+        return super.hashCode() + 37 * colors.hashCode();
     }
 
     /**
diff --git a/core/sis-feature/src/main/java/org/apache/sis/internal/coverage/j2d/ColorModelFactory.java b/core/sis-feature/src/main/java/org/apache/sis/internal/coverage/j2d/ColorModelFactory.java
index d083d63..eab9478 100644
--- a/core/sis-feature/src/main/java/org/apache/sis/internal/coverage/j2d/ColorModelFactory.java
+++ b/core/sis-feature/src/main/java/org/apache/sis/internal/coverage/j2d/ColorModelFactory.java
@@ -17,11 +17,9 @@
 package org.apache.sis.internal.coverage.j2d;
 
 import java.util.Map;
-import java.util.List;
 import java.util.Arrays;
+import java.util.Collection;
 import java.util.Comparator;
-import java.util.AbstractMap;
-import java.util.function.Function;
 import java.awt.Transparency;
 import java.awt.Color;
 import java.awt.color.ColorSpace;
@@ -30,10 +28,8 @@ import java.awt.image.IndexColorModel;
 import java.awt.image.PackedColorModel;
 import java.awt.image.ComponentColorModel;
 import java.awt.image.DataBuffer;
-import org.apache.sis.coverage.Category;
 import org.apache.sis.measure.NumberRange;
 import org.apache.sis.util.ArraysExt;
-import org.apache.sis.util.ArgumentChecks;
 import org.apache.sis.util.collection.WeakHashSet;
 import org.apache.sis.util.collection.WeakValueHashMap;
 import org.apache.sis.util.Debug;
@@ -55,13 +51,6 @@ public final class ColorModelFactory {
     public static final Color TRANSPARENT = new Color(0, true);
 
     /**
-     * Applies a gray scale to quantitative category and transparent colors to qualitative categories.
-     * This is a possible argument for {@link #createColorModel(int, int, int, List, Function)}.
-     */
-    public static final Function<Category,Color[]> GRAYSCALE =
-            (category) -> category.isQuantitative() ? new Color[] {Color.BLACK, Color.WHITE} : null;
-
-    /**
      * Shared instances of {@link ColorModel}s. Maintaining shared instance is not that much interesting
      * for most kind of color models, except {@link IndexColorModel} which can potentially be quite big.
      * This class works for all color models because they were no technical reasons to restrict, but the
@@ -95,9 +84,9 @@ public final class ColorModelFactory {
     /**
      * Comparator for sorting ranges by their minimal value.
      */
-    private static final Comparator<Map.Entry<NumberRange<?>, Color[]>> RANGE_COMPARATOR =
-            (r1, r2) -> Double.compare(r1.getKey().getMinDouble(true),
-                                       r2.getKey().getMinDouble(true));
+    private static final Comparator<ColorsForRange> RANGE_COMPARATOR =
+            (r1, r2) -> Double.compare(r1.sampleRange.getMinDouble(true),
+                                       r2.sampleRange.getMinDouble(true));
 
     /**
      * The color model type. One of the following types:
@@ -152,11 +141,9 @@ public final class ColorModelFactory {
      * so this is not really a {@code ColorModelFactory} but a kind of "{@code ColorModelKey}" instead.
      * However, since this constructor is private, user does not need to know that.
      *
-     * @see #createColorModel(int, int, int, Map.Entry[])
+     * @see #createColorModel(int, int, int, ColorsForRange[])
      */
-    private ColorModelFactory(final int dataType, final int numBands, final int visibleBand,
-                              final Map.Entry<NumberRange<?>,Color[]>[] colors)
-    {
+    private ColorModelFactory(final int dataType, final int numBands, final int visibleBand, final ColorsForRange[] colors) {
         this.dataType    = dataType;
         this.numBands    = numBands;
         this.visibleBand = visibleBand;
@@ -166,8 +153,8 @@ public final class ColorModelFactory {
         int[][] codes   = new int[colors.length][];
         double  minimum = Double.POSITIVE_INFINITY;
         double  maximum = Double.NEGATIVE_INFINITY;
-        for (final Map.Entry<NumberRange<?>, Color[]> entry : colors) {
-            final NumberRange<?> range = entry.getKey();
+        for (final ColorsForRange entry : colors) {
+            final NumberRange<?> range = entry.sampleRange;
             final double min = range.getMinDouble(true);
             final double max = range.getMaxDouble(false);
             if (min < minimum) minimum = min;
@@ -193,7 +180,7 @@ public final class ColorModelFactory {
                             }
                         }
                     }
-                    codes [  count] = toARGB(entry.getValue());
+                    codes [  count] = entry.toARGB();
                     starts[  count] = lower;
                     starts[++count] = upper;
                 }
@@ -307,33 +294,31 @@ public final class ColorModelFactory {
     }
 
     /**
-     * Returns a color model interpolated for the ranges in the given categories.
+     * Returns a color model interpolated for the ranges in the given map entries.
      * Returned instances of {@link ColorModel} are shared among all callers in the running virtual machine.
      *
      * @param  dataType     the color model type. One of {@link DataBuffer#TYPE_BYTE}, {@link DataBuffer#TYPE_USHORT},
      *                      {@link DataBuffer#TYPE_SHORT}, {@link DataBuffer#TYPE_INT}, {@link DataBuffer#TYPE_FLOAT}
      *                      or {@link DataBuffer#TYPE_DOUBLE}.
-     * @param  numBands     number of bands.
+     * @param  numBands     the number of bands for the color model (usually 1). The returned color model will render only
+     *                      the {@code visibleBand} and ignore the others, but the existence of all {@code numBands} will
+     *                      be at least tolerated. Supplemental bands, even invisible, are useful for processing.
      * @param  visibleBand  the band to be made visible (usually 0). All other bands (if any) will be ignored.
-     * @param  categories   description of value ranges in the visible band.
-     * @param  colors       the colors to use for each category. The function may return {@code null}, which means transparent.
+     * @param  colors       the colors to use for each range of sample values.
+     *                      The map may contain {@code null} values, which means transparent.
      * @return a color model suitable for {@link java.awt.image.RenderedImage} objects with values in the given ranges.
+     *
+     * @see Colorizer
      */
     public static ColorModel createColorModel(final int dataType, final int numBands, final int visibleBand,
-                                final List<Category> categories, final Function<Category,Color[]> colors)
+                                              final Collection<Map.Entry<NumberRange<?>,Color[]>> colors)
     {
-        @SuppressWarnings({"unchecked", "rawtypes"})               // Generic array creation.
-        final Map.Entry<NumberRange<?>, Color[]>[] ranges = new Map.Entry[categories.size()];
-        for (int i=0; i<ranges.length; i++) {
-            final Category category = categories.get(i);
-            ranges[i] = new AbstractMap.SimpleImmutableEntry<>(category.getSampleRange(), colors.apply(category));
-        }
-        return createColorModel(dataType, numBands, visibleBand, ranges);
+        return createColorModel(dataType, numBands, visibleBand, ColorsForRange.list(colors));
     }
 
     /**
      * Returns a color model interpolated for the given ranges and colors.
-     * This method builds up the color model from each set of colors associated to ranges in the given map.
+     * This method builds up the color model from each set of colors associated to ranges in the given entries.
      * Returned instances of {@link ColorModel} are shared among all callers in the running virtual machine.
      *
      * <p>The given ranges are rounded to nearest integers and clamped to the range of 32 bits integer values.
@@ -350,11 +335,9 @@ public final class ColorModelFactory {
      * @param  colors       the colors associated to ranges of sample values.
      * @return a color model suitable for {@link java.awt.image.RenderedImage} objects with values in the given ranges.
      */
-    public static ColorModel createColorModel(final int dataType, final int numBands, final int visibleBand,
-                                              final Map.Entry<NumberRange<?>, Color[]>[] colors)
+    static ColorModel createColorModel(final int dataType, final int numBands, final int visibleBand,
+                                       final ColorsForRange[] colors)
     {
-        ArgumentChecks.ensureNonNull("colors", colors);
-        ArgumentChecks.ensureBetween("visibleBand", 0, numBands - 1, visibleBand);
         final ColorModelFactory key = new ColorModelFactory(dataType, numBands, visibleBand, colors);
         synchronized (PIECEWISES) {
             return PIECEWISES.computeIfAbsent(key, ColorModelFactory::createColorModel);
@@ -484,7 +467,7 @@ public final class ColorModelFactory {
             return unique(((ScaledColorModel) cm).createSubsetColorModel(bands));
         }
         // TODO: handle other color models.
-        return null;
+        return Colorizer.NULL_COLOR_MODEL;
     }
 
     /**
@@ -549,31 +532,6 @@ public final class ColorModelFactory {
     }
 
     /**
-     * Returns the ARGB codes for the given colors. If all colors are transparent, returns an empty array.
-     *
-     * @param  colors  the colors to convert to ARGB codes, or {@code null}.
-     * @return ARGB codes for the given colors. Never {@code null} but may be empty.
-     */
-    private static int[] toARGB(final Color[] colors) {
-        if (colors != null) {
-            int combined = 0;
-            final int[] ARGB = new int[colors.length];
-            for (int i=0; i<ARGB.length; i++) {
-                final Color color = colors[i];
-                if (color != null) {
-                    int c = color.getRGB();                         // Note: getRGB() is really getARGB().
-                    combined |= c;
-                    ARGB[i]   = c;
-                }
-            }
-            if ((combined & 0xFF000000) != 0) {
-                return ARGB;
-            }
-        }
-        return ArraysExt.EMPTY_INT;
-    }
-
-    /**
      * Copies {@code colors} into {@code ARGB} array from index {@code lower} inclusive to index {@code upper} exclusive.
      * If {@code upper-lower} is not equal to the length of {@code colors} array, then colors will be interpolated.
      *
diff --git a/core/sis-feature/src/main/java/org/apache/sis/internal/coverage/j2d/Colorizer.java b/core/sis-feature/src/main/java/org/apache/sis/internal/coverage/j2d/Colorizer.java
new file mode 100644
index 0000000..2161f3d
--- /dev/null
+++ b/core/sis-feature/src/main/java/org/apache/sis/internal/coverage/j2d/Colorizer.java
@@ -0,0 +1,542 @@
+/*
+ * 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.internal.coverage.j2d;
+
+import java.util.Map;
+import java.util.List;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.Collection;
+import java.util.function.Function;
+import java.awt.Color;
+import java.awt.color.ColorSpace;
+import java.awt.image.ColorModel;
+import java.awt.image.DataBuffer;
+import java.awt.image.IndexColorModel;
+import java.awt.image.SampleModel;
+import org.opengis.referencing.operation.MathTransform1D;
+import org.opengis.referencing.operation.NoninvertibleTransformException;
+import org.apache.sis.referencing.operation.transform.MathTransforms;
+import org.apache.sis.coverage.Category;
+import org.apache.sis.coverage.SampleDimension;
+import org.apache.sis.internal.feature.Resources;
+import org.apache.sis.internal.util.Numerics;
+import org.apache.sis.measure.NumberRange;
+import org.apache.sis.util.ArgumentChecks;
+import org.apache.sis.util.resources.Errors;
+import org.apache.sis.util.resources.Vocabulary;
+
+
+/**
+ * Helper classes for allowing an image to be colorized, by building an {@link IndexColorModel} if needed.
+ * Image created by this class are suitable for visualization purposes but generally not for computations.
+ * Usage:
+ *
+ * <ol>
+ *   <li>Create a new {@link Colorizer} instance.</li>
+ *   <li>Invoke one of {@code initialize(…)} methods.</li>
+ *   <li>Invoke {@link #createColorModel(int, int, int)}.</li>
+ *   <li>Discards {@code Colorizer}; each instance should be used only once.</li>
+ * </ol>
+ *
+ * There is no {@code initialize(Raster)} or {@code initialize(RenderedImage)} method because if those methods
+ * were present, users may expect them to iterate over sample values for finding minimum and maximum values.
+ * We do not perform such iteration because they are potentially costly and give unstable results:
+ * the resulting color model varies from image to image, which is confusing when they are images of the same
+ * product as different depth or different time.
+ *
+ * @author  Martin Desruisseaux (Geomatys)
+ * @version 1.1
+ *
+ * @see ColorModelFactory#createColorModel(int, int, int, Collection)
+ *
+ * @since 1.1
+ * @module
+ */
+public final class Colorizer {
+    /**
+     * A color model constant set to {@code null}, used for identifying code that explicitly set the
+     * color model to {@code null}. It may happen when no {@code initialize(…)} method can be applied.
+     */
+    public static final ColorModel NULL_COLOR_MODEL = null;
+
+    /**
+     * Maximal index value which can be used with a 8 bits {@link IndexColorModel}, inclusive.
+     * Sample values must be in that range for enabling the use of {@link #TYPE_COMPACT}.
+     */
+    private static final int MAX_VALUE = 0xFF;
+
+    /**
+     * The {@link DataBuffer} type resulting from sample values conversion applied by
+     * {@link #compactColorModel(int, int)}.
+     */
+    public static final int TYPE_COMPACT = DataBuffer.TYPE_BYTE;
+
+    /**
+     * Applies a gray scale to quantitative category and transparent colors to qualitative categories.
+     * This is a possible argument for the {@link #Colorizer(Function)} constructor.
+     */
+    public static final Function<Category,Color[]> GRAYSCALE =
+            (category) -> category.isQuantitative() ? new Color[] {Color.BLACK, Color.WHITE} : null;
+
+    /**
+     * Blue to red color palette with white in the middle. Useful for data with a clear 0 (white)
+     * in the range center and negative and positive values (to appear blue and red respectively).
+     */
+    public static final Function<Category,Color[]> BELL =
+            (category) -> category.isQuantitative() ? new Color[] {
+                Color.BLUE, Color.CYAN, Color.WHITE, Color.YELLOW, Color.RED} : null;
+
+    /**
+     * The colors to use for each category.
+     * The function may return {@code null}, which means transparent.
+     */
+    private final Function<Category,Color[]> colors;
+
+    /**
+     * The colors to use for each range of values in the source image.
+     * Entries will be sorted and modified in place.
+     */
+    private ColorsForRange[] entries;
+
+    /**
+     * The sample dimension for values before conversion, or {@code null} if unspecified.
+     * This object describes the range of values found in source image.
+     * They are not necessarily the range of values in the colorized image.
+     */
+    private SampleDimension source;
+
+    /**
+     * The sample dimension for values after conversion, or {@code null} if not yet computed.
+     * May be the same than {@link #source} or {@code source.forConvertedValues(true)} if one
+     * of those values is suitable, or a new sample dimension created by {@link #compact()}.
+     */
+    private SampleDimension target;
+
+    /**
+     * Creates a new colorizer which will apply colors on the given range of values in source image.
+     * The {@code Colorizer} is considered initialized after this constructor;
+     * callers shall <strong>not</strong> invoke an {@code initialize(…)} method.
+     *
+     * @param  colors  the colors to use for each range of values in source image.
+     *                 A {@code null} value means transparent.
+     */
+    public Colorizer(final Collection<Map.Entry<NumberRange<?>,Color[]>> colors) {
+        ArgumentChecks.ensureNonNull("colors", colors);
+        entries = ColorsForRange.list(colors);
+        this.colors = null;
+    }
+
+    /**
+     * Creates a new colorizer which will use the given function for determining the colors to apply.
+     * Callers need to invoke an {@code initialize(…)} method after this constructor.
+     *
+     * @param  colors  the colors to use for each category.
+     *                 The function may return {@code null}, which means transparent.
+     */
+    public Colorizer(final Function<Category,Color[]> colors) {
+        ArgumentChecks.ensureNonNull("colors", colors);
+        this.colors = colors;
+    }
+
+    /**
+     * Verifies whether the {@link #entries} value is defined.
+     *
+     * @param  initialized  the expected initialization state.
+     */
+    private void checkInitializationStatus(final boolean initialized) {
+        if ((entries != null) != initialized) {
+            throw new IllegalStateException(Errors.format(
+                    initialized ? Errors.Keys.Uninitialized_1 : Errors.Keys.AlreadyInitialized_1, getClass()));
+        }
+    }
+
+    /**
+     * Returns {@code true} if the given range is already the [0 … 255] range.
+     */
+    private static boolean isAlreadyScaled(final NumberRange<?> range) {
+        return range.getMinDouble(true) == 0 && range.getMaxDouble(true) == MAX_VALUE;
+    }
+
+    /**
+     * Uses the given sample dimension for mapping range of values to colors. For each category in
+     * the sample dimension, colors will be determined by a call to {@code colors.apply(category)}
+     * where {@code colors} is the function specified at construction time.
+     *
+     * @param  source  description of range of values in the source image, or {@code null}.
+     * @return {@code true} on success, or {@code false} if no range of values has been found.
+     * @throws IllegalStateException if a sample dimension is already defined on this colorizer.
+     */
+    public boolean initialize(final SampleDimension source) {
+        checkInitializationStatus(false);
+        if (source != null) {
+            this.source = source;
+            final List<Category> categories = source.getCategories();
+            if (!categories.isEmpty()) {
+                entries = new ColorsForRange[categories.size()];
+                for (int i=0; i<entries.length; i++) {
+                    final Category category = categories.get(i);
+                    entries[i] = new ColorsForRange(category, category.getSampleRange(), colors.apply(category));
+                }
+                // Leave `target` to null. It will be computed by `compact()` if needed.
+                return true;
+            }
+        }
+        return false;
+    }
+
+    /**
+     * Applies colors on the range of values of a raster using given sample model. The 0 index will be reserved
+     * for NaN value, and indices in the [1 … 255] range will be mapped to the range of sample values that can
+     * be stored in the specified band.
+     *
+     * @param  source  sample model of raster to be colored, or {@code null}.
+     * @param  band    raster band to be colored.
+     * @return {@code true} on success, or {@code false} if no range of values has been found.
+     * @throws IllegalStateException if a sample dimension is already defined on this colorizer.
+     */
+    public boolean initialize(final SampleModel source, final int band) {
+        checkInitializationStatus(false);
+        if (source != null) {
+            final int dataType = source.getDataType();
+            if (ImageUtilities.isIntegerType(dataType)) {
+                long minimum = 0;
+                long maximum = Numerics.bitmask(source.getSampleSize(band)) - 1;
+                if (dataType != DataBuffer.TYPE_BYTE && dataType != DataBuffer.TYPE_USHORT) {
+                    maximum >>>= 1;
+                    minimum = ~maximum;
+                }
+                initialize(minimum, maximum);
+                return true;
+            }
+        }
+        return false;
+    }
+
+    /**
+     * Uses the given color model for mapping range of values to new colors. The colors in the given color model
+     * are ignored (because they will be replaced by colors specified by this {@code Colorizer}); only the range
+     * of values will be fetched, if such range exists.
+     *
+     * @param  source  the color model from which to get a range of values, or {@code null}.
+     * @return {@code true} on success, or {@code false} if no range of values has been found.
+     * @throws IllegalStateException if a sample dimension is already defined on this colorizer.
+     */
+    public boolean initialize(final ColorModel source) {
+        checkInitializationStatus(false);
+        if (source != null) {
+            final ColorSpace cs = source.getColorSpace();
+            if (cs instanceof ScaledColorSpace) {
+                final ScaledColorSpace scs = (ScaledColorSpace) cs;
+                initialize(scs.offset, scs.maximum);
+                return true;
+            }
+        }
+        return false;
+    }
+
+    /*
+     * Do not provide methods taking Raster or RenderedImage argument.
+     * See class javadoc for rational.
+     */
+
+    /**
+     * Applies colors on the given range of values. The 0 index will be reserved for NaN value,
+     * and indices in the [1 … 255] will be mapped to the given range.
+     *
+     * <p>This method is typically used as a last resort fallback when other {@code initialize(…)}
+     * methods failed or can not be applied.</p>
+     *
+     * @param  minimum  minimum value, inclusive.
+     * @param  maximum  maximum value, inclusive.
+     * @throws IllegalStateException if a sample dimension is already defined on this colorizer.
+     */
+    public void initialize(final double minimum, final double maximum) {
+        checkInitializationStatus(false);
+        ArgumentChecks.ensureFinite("minimum", minimum);
+        ArgumentChecks.ensureFinite("maximum", maximum);
+        target = new SampleDimension.Builder()
+                .mapQualitative(null, 0, Float.NaN)
+                .addQuantitative(Vocabulary.formatInternational(Vocabulary.Keys.Data),
+                        NumberRange.create(1, true, MAX_VALUE, true),
+                        NumberRange.create(minimum, true, maximum, true)).build();
+
+        source = target.forConvertedValues(true);
+        final List<Category> categories = source.getCategories();
+        entries = new ColorsForRange[categories.size()];
+        for (int i=0; i<entries.length; i++) {
+            final Category category = categories.get(i);
+            entries[i] = new ColorsForRange(category, category.forConvertedValues(false).getSampleRange(), colors.apply(category));
+        }
+    }
+
+    /**
+     * Potentially rescales the range of values of the main category for the given color model.
+     * This method can be invoked when the color model may use a range of values different than the range
+     * specified by categories. It may happen if the color ramp associated to the quantitative category has
+     * been stretched dynamically using a "recolor" operation. We want to preserve that user customization,
+     * but we have no explicit information about which category to modify. This method does an heuristic
+     * choice based on the category having the largest intersection with the color model value range.
+     *
+     * <p>An {@code initialize(…)} method must have been invoked successfully before this method can be invoked.</p>
+     *
+     * @param  original  original color model of image for which a new color map is built, or {@code null} if none.
+     * @throws IllegalStateException if {@code initialize(…)} has not been invoked.
+     */
+    public void rescaleMainRange(final ColorModel original) {
+        checkInitializationStatus(true);
+        if (original != null) {
+            final ColorSpace cs = original.getColorSpace();
+            if (cs instanceof ScaledColorSpace) {
+                final ScaledColorSpace scs = (ScaledColorSpace) cs;
+                final double  minimum = scs.offset;
+                final double  maximum = scs.maximum;
+                ColorsForRange widest = null;
+                double widestSpan = 0;
+                for (final ColorsForRange entry : entries) {
+                    final double span = Math.min(entry.sampleRange.getMaxDouble(), maximum)
+                                      - Math.max(entry.sampleRange.getMinDouble(), minimum);
+                    if (span > widestSpan) {
+                        widestSpan = span;
+                        widest = entry;
+                    }
+                }
+                if (widest != null && widestSpan != widest.sampleRange.getSpan()) {
+                    widest.sampleRange = NumberRange.create(minimum, true, maximum, false);
+                    target = null;      // For recomputing the transfer function later.
+                }
+            }
+        }
+    }
+
+    /**
+     * Modifies the sample value ranges to make them fit in valid ranges for an {@link IndexColorModel}.
+     * The {@link SampleDimension#getSampleRange()} is constrained to range [0 … 255] inclusive.
+     * The {@link SampleDimension#getTransferFunction()} returns the conversion from original ranges
+     * to ranges of pixel values in the colorized image.
+     *
+     * <p>There is two outputs: the {@link #target} sample dimension, and modifications done in-place in the
+     * {@link #entries} array. For each {@link ColorsForRange} instance, the {@link ColorsForRange#sampleRange}
+     * range is replaced by range of indexed colors. In addition {@code entries} elements may be reordered.</p>
+     *
+     * <p>If {@lini #entries} has been built from a sample dimension, that {@link SampleDimension} is specified
+     * in the {@link #source} field. This is used only for providing a better name to the sample dimension.</p>
+     */
+    private void compact() {
+        if (target != null) {
+            return;
+        }
+        /*
+         * If a source SampleDimension has been specified, verify if it provides a transfer function that we can
+         * use directly. If this is the case, use the existing transfer function instead than inventing our own.
+         */
+reuse:  if (source != null) {
+            target = source.forConvertedValues(false);
+            if (target.getSampleRange().filter(Colorizer::isAlreadyScaled).isPresent()) {
+                if (target == source) {
+                    return;
+                }
+                /*
+                 * We will need to replace ranges specified in the source `SampleDimensions` by ranges used in the
+                 * colorized images. Prepare in advance a `mapper` with all replacements that we know about.
+                 */
+                final Map<NumberRange<?>,NumberRange<?>> mapper = new HashMap<>();
+                for (final Category category : target.getCategories()) {
+                    if (mapper.put(category.forConvertedValues(true).getSampleRange(), category.getSampleRange()) != null) {
+                        break reuse;        // Duplicated range of values in source SampleDimensions (should not happen).
+                    }
+                }
+                /*
+                 * Do the replacements in a temporary `ranges` array before to write in the `entries` array
+                 * because `entries` changes must be a "all or nothing" operation. We allow each range to be
+                 * used as most once.
+                 */
+                final NumberRange<?>[] ranges = new NumberRange<?>[entries.length];
+                for (int i=0; i<entries.length; i++) {
+                    if ((ranges[i] = mapper.remove(entries[i].sampleRange)) == null) {
+                        break reuse;            // Range not found or used twice.
+                    }
+                }
+                for (int i=0; i<entries.length; i++) {
+                    entries[i].sampleRange = ranges[i];
+                }
+                return;
+            }
+        }
+        /*
+         * IF we reach this point, `source` sample dimensions were not specified or can not be used for
+         * getting a transfer function to the [0 … 255] range of values. We will need to create our own.
+         * First, sort the entries for having transparent colors first.
+         */
+        Arrays.sort(entries);                               // Move transparent colors in first positions.
+        double span  = 0;                                   // Total span of all non-NaN ranges.
+        int lower    = 0;                                   // First available index in the [0 … 255] range.
+        int deferred = 0;                                   // Number of entries deferred to next loop.
+        int count    = entries.length;                      // Total number of valid entries.
+        final Map<NumberRange<Integer>,ColorsForRange> mapper = new HashMap<>();
+        final SampleDimension.Builder builder = new SampleDimension.Builder();
+        /*
+         * We will use the byte values range [0 … 255] with 0 reserved in priority for the most transparent pixels.
+         * The first loop below processes NaN values, which are usually the ones associated to transparent pixels.
+         * The second loop processes everything else.
+         */
+        for (int i=0; i<count; i++) {
+            final ColorsForRange entry = entries[i];
+            final double s = entry.sampleRange.getSpan();
+            if (Double.isNaN(s)) {
+                if (lower >= MAX_VALUE) {
+                    throw new IllegalArgumentException(Resources.format(Resources.Keys.TooManyQualitatives));
+                }
+                final NumberRange<Integer> samples = NumberRange.create(lower, true, ++lower, false);
+                if (mapper.put(samples, entry) == null) {
+                    builder.mapQualitative(entry.name(), samples, (float) s);
+                }
+            } else if (s > 0) {
+                // Range of real values: defer processing to next loop.
+                span += s;
+                System.arraycopy(entries, deferred, entries, deferred + 1, i - deferred);
+                entries[deferred++] = entry;
+            } else {
+                // Invalid range: silently discard.
+                System.arraycopy(entries, i+1, entries, i, --count - i);
+                entries[count] = null;
+            }
+        }
+        /*
+         * Above loop mapped all NaN values. Now map the real values. Usually, there is exactly one entry taking
+         * all remaining values in the [0 … 255] range, but code below is tolerant to arbitrary amount of ranges.
+         */
+        final int base = lower;
+        final double toIndexRange = (MAX_VALUE + 1 - base) / span;
+        span = 0;
+        for (int i=0; i<deferred; i++) {
+            final ColorsForRange entry = entries[i];
+            if (entry != null) {
+                span += entry.sampleRange.getSpan();
+                final int upper = Math.toIntExact(Math.round(span * toIndexRange) + base);
+                if (upper <= lower) {
+                    // May happen if too many qualitative categories have been added by previous loop.
+                    throw new IllegalArgumentException(Resources.format(Resources.Keys.TooManyQualitatives));
+                }
+                final NumberRange<Integer> samples = NumberRange.create(lower, true, upper, false);
+                if (mapper.put(samples, entry) == null) {
+                    builder.addQuantitative(entry.name(), samples, entry.sampleRange);
+                }
+                lower = upper;
+            }
+        }
+        /*
+         * At this point we created a `Category` instance for each given `ColorsForRange`.
+         * Update the given `ColorsForRange` instances with new range values.
+         */
+        if (source != null) {
+            builder.setName(source.getName());
+        } else {
+            builder.setName(Vocabulary.format(Vocabulary.Keys.Visual));
+        }
+        target = builder.build();
+        for (final Category category : target.getCategories()) {
+            final NumberRange<?> packed = category.getSampleRange();
+            mapper.get(packed).sampleRange = packed;
+            // A NullPointerException on above line would be a bug in our construction of `mapper`.
+        }
+    }
+
+    /**
+     * Returns a color model with colors interpolated in the ranges of values determined by constructors.
+     * This method builds up the color model from each set of colors associated to ranges in the given array.
+     * Returned instances of {@link ColorModel} are shared among all callers in the running virtual machine.
+     *
+     * @param  dataType     the color model type. One of {@link DataBuffer#TYPE_BYTE}, {@link DataBuffer#TYPE_USHORT},
+     *                      {@link DataBuffer#TYPE_SHORT}, {@link DataBuffer#TYPE_INT}, {@link DataBuffer#TYPE_FLOAT}
+     *                      or {@link DataBuffer#TYPE_DOUBLE}.
+     * @param  numBands     the number of bands for the color model (usually 1). The returned color model will render only
+     *                      the {@code visibleBand} and ignore the others, but the existence of all {@code numBands} will
+     *                      be at least tolerated. Supplemental bands, even invisible, are useful for processing.
+     * @param  visibleBand  the band to be made visible (usually 0). All other bands, if any, will be ignored.
+     * @return a color model suitable for {@link java.awt.image.RenderedImage} objects with values in the given ranges.
+     */
+    public ColorModel createColorModel(final int dataType, final int numBands, final int visibleBand) {
+        checkInitializationStatus(true);
+        ArgumentChecks.ensureStrictlyPositive("numBands", numBands);
+        ArgumentChecks.ensureBetween("visibleBand", 0, numBands - 1, visibleBand);
+        return ColorModelFactory.createColorModel(dataType, numBands, visibleBand, entries);
+    }
+
+    /**
+     * Returns a color model with colors interpolated in the [0 … 255] range of values.
+     * Conversions from range specified at construction time to the [0 … 255] range is
+     * given by {@link #getSampleToIndexValues()}. Images using this color model shall
+     * use a {@link DataBuffer} of type {@link #TYPE_COMPACT}.
+     *
+     * @param  numBands     the number of bands for the color model (usually 1). The returned color model will render only
+     *                      the {@code visibleBand} and ignore the others, but the existence of all {@code numBands} will
+     *                      be at least tolerated. Supplemental bands, even invisible, are useful for processing.
+     * @param  visibleBand  the band to be made visible (usually 0). All other bands, if any, will be ignored.
+     * @return a color model suitable for {@link java.awt.image.RenderedImage} objects with values in the given ranges.
+     */
+    public ColorModel compactColorModel(final int numBands, final int visibleBand) {
+        checkInitializationStatus(true);
+        compact();
+        return createColorModel(TYPE_COMPACT, numBands, visibleBand);
+    }
+
+    /**
+     * Returns the largest range of sample values in target image, ignoring all ranges for NaN values.
+     * This method does <strong>not</strong> compute the union of all ranges, because callers may take
+     * for example the {@linkplain NumberRange#getMedian() median} value as the most typical value.
+     * If the returned range was the union of distinct ranges, then we would have no guarantees
+     * that the median value is a valid value.
+     *
+     * @return largest range of sample values in target image, or {@code null} if unknown.
+     */
+    public NumberRange<?> getRepresentativeRange() {
+        checkInitializationStatus(true);
+        NumberRange<?> largest = null;
+        double span = 0;
+        for (final ColorsForRange entry : entries) {
+            if (entry.isData()) {
+                final double s = entry.sampleRange.getSpan();
+                if (s > span) {
+                    span = s;
+                    largest = entry.sampleRange;
+                }
+            }
+        }
+        return largest;
+    }
+
+    /**
+     * Returns the conversion from sample values in the source image to sample values in the recolored image.
+     *
+     * @return conversion to sample values in recolored image.
+     * @throws NoninvertibleTransformException if the conversion can not be created.
+     */
+    public MathTransform1D getSampleToIndexValues() throws NoninvertibleTransformException {
+        checkInitializationStatus(true);
+        return (target != null) ? target.getTransferFunction().orElseGet(Colorizer::identity).inverse() : identity();
+    }
+
+    /**
+     * Returns the identity transform.
+     *
+     * @see Category#identity()
+     */
+    private static MathTransform1D identity() {
+        return (MathTransform1D) MathTransforms.identity(1);
+    }
+}
diff --git a/core/sis-feature/src/main/java/org/apache/sis/internal/coverage/j2d/ColorsForRange.java b/core/sis-feature/src/main/java/org/apache/sis/internal/coverage/j2d/ColorsForRange.java
new file mode 100644
index 0000000..32cf7e2
--- /dev/null
+++ b/core/sis-feature/src/main/java/org/apache/sis/internal/coverage/j2d/ColorsForRange.java
@@ -0,0 +1,171 @@
+/*
+ * 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.internal.coverage.j2d;
+
+import java.util.Map;
+import java.util.Collection;
+import java.awt.Color;
+import java.awt.image.IndexColorModel;
+import org.apache.sis.coverage.Category;
+import org.apache.sis.measure.NumberRange;
+import org.apache.sis.util.ArgumentChecks;
+import org.apache.sis.util.ArraysExt;
+
+
+/**
+ * Colors to apply on a range of sample values. Instances of {@code ColorsForRange} are temporary, used only
+ * the time needed for {@link ColorModelFactory#createColorModel(int, int, int, ColorsForRange[])}.
+ *
+ * @author  Martin Desruisseaux (Geomatys)
+ * @version 1.1
+ *
+ * @see ColorModelFactory#createColorModel(int, int, int, ColorsForRange[])
+ *
+ * @since 1.1
+ * @module
+ */
+final class ColorsForRange implements Comparable<ColorsForRange> {
+    /**
+     * If this {@code ColorsForRange} has been created for a category, that category.
+     * Otherwise {@code null}.
+     */
+    private final Category category;
+
+    /**
+     * The range of sample values on which the colors will be applied. Shall never be null.
+     * May be updated after {@link Colorizer#compact()} mapped range of floating point values
+     * to range of {@link IndexColorModel} values.
+     */
+    NumberRange<?> sampleRange;
+
+    /**
+     * The colors to apply on the range of sample values.
+     * A null or empty array means transparent.
+     */
+    private final Color[] colors;
+
+    /**
+     * Creates a new instance for the given range of values.
+     *
+     * @param  category     the category for which this {@code ColorsForRange} is created, or {@code null}.
+     * @param  sampleRange  range of sample values on which the colors will be applied.
+     * @param  colors       colors to apply on the range of sample values, or {@code null} for transparent.
+     */
+    ColorsForRange(final Category category, final NumberRange<?> sampleRange, final Color[] colors) {
+        ArgumentChecks.ensureNonNull("sampleRange", sampleRange);
+        this.category    = category;
+        this.sampleRange = sampleRange;
+        this.colors      = colors;
+    }
+
+    /**
+     * Converts {@linkplain Map#entrySet() map entries} to an array of {@code ColorsForRange} entries.
+     * The {@link #category} of each entry is left to null.
+     */
+    static ColorsForRange[] list(final Collection<Map.Entry<NumberRange<?>,Color[]>> colors) {
+        final ColorsForRange[] entries = new ColorsForRange[colors.size()];
+        int n = 0;
+        for (final Map.Entry<NumberRange<?>,Color[]> entry : colors) {
+            entries[n++] = new ColorsForRange(null, entry.getKey(), entry.getValue());
+        }
+        return ArraysExt.resize(entries, n);            // `resize` should not be needed, but we are paranoiac.
+    }
+
+    /**
+     * Returns {@code true} if this entry should be taken as data, or {@code false} if it should be ignored.
+     * Entry to ignore and entries associated to NaN values.
+     */
+    final boolean isData() {
+        return category == null || category.isQuantitative();
+    }
+
+    /**
+     * Returns a name identifying the range of values. the category name is used if available,
+     * otherwise a string representation of the range is created.
+     */
+    final CharSequence name() {
+        if (category != null) {
+            final CharSequence name = category.getName();
+            if (name != null) return name;
+        }
+        return sampleRange.toString();
+    }
+
+    /**
+     * Returns a string representation for debugging purposes.
+     */
+    @Override
+    public String toString() {
+        return name().toString();
+    }
+
+    /**
+     * Comparator for sorting entries by their alpha value.
+     * The intent is to have transparent colors first.
+     *
+     * @param  other  the other instance to compare with this instance.
+     * @return -1 if this instance if more transparent, +1 if the other instance is more transparent, 0 if equal.
+     */
+    @Override
+    public int compareTo(final ColorsForRange other) {
+        return getAlpha() - other.getAlpha();
+    }
+
+    /**
+     * Returns the maximal alpha value found in colors.
+     */
+    private int getAlpha() {
+        int max = 0;
+        if (colors != null) {
+            for (int i=0; i<colors.length; i++) {
+                final int alpha = colors[i].getAlpha();
+                if (alpha > max) {
+                    if (alpha >= 0xFF) {
+                        return 0xFF;
+                    }
+                    max = alpha;
+                }
+            }
+        }
+        return max;
+    }
+
+    /**
+     * Returns the ARGB codes for the colors.
+     * If all colors are transparent, returns an empty array.
+     *
+     * @return ARGB codes for the given colors. Never {@code null} but may be empty.
+     */
+    final int[] toARGB() {
+        if (colors != null) {
+            int combined = 0;
+            final int[] ARGB = new int[colors.length];
+            for (int i=0; i<ARGB.length; i++) {
+                final Color color = colors[i];
+                if (color != null) {
+                    int c = color.getRGB();                         // Note: getRGB() is really getARGB().
+                    combined |= c;
+                    ARGB[i]   = c;
+                }
+            }
+            if ((combined & 0xFF000000) != 0) {
+                return ARGB;
+            }
+        }
+        return ArraysExt.EMPTY_INT;
+    }
+}
diff --git a/core/sis-feature/src/main/java/org/apache/sis/internal/coverage/j2d/ImageUtilities.java b/core/sis-feature/src/main/java/org/apache/sis/internal/coverage/j2d/ImageUtilities.java
index 11b3332..f289d59 100644
--- a/core/sis-feature/src/main/java/org/apache/sis/internal/coverage/j2d/ImageUtilities.java
+++ b/core/sis-feature/src/main/java/org/apache/sis/internal/coverage/j2d/ImageUtilities.java
@@ -142,17 +142,18 @@ public final class ImageUtilities extends Static {
         if (image != null) {
             final ColorModel cm = image.getColorModel();
             if (cm != null) {
-                final ColorSpace cs = cm.getColorSpace();
-                if (cs instanceof ScaledColorSpace) {
-                    return ((ScaledColorSpace) cs).visibleBand;
-                }
                 if (cm instanceof MultiBandsIndexColorModel) {
                     return ((MultiBandsIndexColorModel) cm).visibleBand;
                 }
-                if (cm.getNumComponents() == 1) {
-                    return 0;
+                final ColorSpace cs = cm.getColorSpace();
+                if (cs instanceof ScaledColorSpace) {
+                    return ((ScaledColorSpace) cs).visibleBand;
                 }
             }
+            final SampleModel sm = image.getSampleModel();
+            if (sm != null && sm.getNumBands() == 1) {           // Should never be null, but we are paranoiac.
+                return 0;
+            }
         }
         return -1;
     }
@@ -186,7 +187,7 @@ public final class ImageUtilities extends Static {
     public static int getDataType(final Raster raster) {
         if (raster != null) {
             final DataBuffer buffer = raster.getDataBuffer();
-            if (buffer != null) {
+            if (buffer != null) {                               // Should never be null, but we are paranoiac.
                 return buffer.getDataType();
             }
         }
diff --git a/core/sis-feature/src/main/java/org/apache/sis/internal/coverage/j2d/ScaledColorSpace.java b/core/sis-feature/src/main/java/org/apache/sis/internal/coverage/j2d/ScaledColorSpace.java
index 95c65cd..f858dba 100644
--- a/core/sis-feature/src/main/java/org/apache/sis/internal/coverage/j2d/ScaledColorSpace.java
+++ b/core/sis-feature/src/main/java/org/apache/sis/internal/coverage/j2d/ScaledColorSpace.java
@@ -41,7 +41,7 @@ final class ScaledColorSpace extends ColorSpace {
     /**
      * For cross-version compatibility.
      */
-    private static final long serialVersionUID = -6635165959083590494L;
+    private static final long serialVersionUID = -5146474397268513490L;
 
     /**
      * The scaling factor from sample values to RGB values. The target RGB values will be in range 0
@@ -56,6 +56,11 @@ final class ScaledColorSpace extends ColorSpace {
     final double offset;
 
     /**
+     * The maximum value specified at construction time.
+     */
+    final double maximum;
+
+    /**
      * Index of the band to display, from 0 inclusive to {@link #getNumComponents()} exclusive.
      */
     final int visibleBand;
@@ -72,6 +77,7 @@ final class ScaledColorSpace extends ColorSpace {
     ScaledColorSpace(final int numComponents, final int visibleBand, final double minimum, final double maximum) {
         super(TYPE_GRAY, numComponents);
         this.visibleBand = visibleBand;
+        this.maximum = maximum;
         scale  = ScaledColorModel.RANGE / (maximum - minimum);
         offset = minimum;
     }
@@ -81,8 +87,9 @@ final class ScaledColorSpace extends ColorSpace {
      */
     ScaledColorSpace(final ScaledColorSpace parent, final int[] bands) {
         super(TYPE_GRAY, bands.length);
-        scale  = parent.scale;
-        offset = parent.offset;
+        scale   = parent.scale;
+        offset  = parent.offset;
+        maximum = parent.maximum;
         for (int i=0; i<bands.length; i++) {
             if (bands[i] == parent.visibleBand) {
                 visibleBand = i;
@@ -101,7 +108,7 @@ final class ScaledColorSpace extends ColorSpace {
     @Override
     public float[] toRGB(final float[] samples) {
         float value = Math.min(1, (float) ((samples[visibleBand] - offset) * (1d/ScaledColorModel.RANGE * scale)));
-        if (!(value >= 0)) value = 0;                   // Use '!' for replacing NaN.
+        if (!(value >= 0)) value = 0;                   // Use `!` for replacing NaN.
         return new float[] {value, value, value};
     }
 
@@ -166,7 +173,7 @@ final class ScaledColorSpace extends ColorSpace {
      */
     @Override
     public float getMaxValue(final int component) {
-        return (float) (ScaledColorModel.RANGE / scale + offset);
+        return (float) maximum;
     }
 
     /**
@@ -189,8 +196,8 @@ final class ScaledColorSpace extends ColorSpace {
      */
     @Debug
     final void formatRange(final StringBuilder buffer) {
-        buffer.append('[').append(getMinValue(visibleBand))
-            .append(" … ").append(getMaxValue(visibleBand))
+        buffer.append('[').append(offset)
+            .append(" … ").append(maximum)
             .append(" in band ").append(visibleBand).append(']');
     }
 
@@ -211,11 +218,12 @@ final class ScaledColorSpace extends ColorSpace {
     public boolean equals(final Object obj) {
         if (obj instanceof ScaledColorSpace) {
             final ScaledColorSpace that = (ScaledColorSpace) obj;
-            return Numerics.equals(scale,  that.scale)             &&
-                   Numerics.equals(offset, that.offset)            &&
-                   visibleBand         ==  that.visibleBand        &&
-                   getNumComponents()  ==  that.getNumComponents() &&
-                   getType()           ==  that.getType();
+            return Numerics.equals(scale,   that.scale)             &&
+                   Numerics.equals(offset,  that.offset)            &&
+                   Numerics.equals(maximum, that.maximum)           &&
+                   visibleBand          ==  that.visibleBand        &&
+                   getNumComponents()   ==  that.getNumComponents() &&
+                   getType()            ==  that.getType();
         }
         return false;
     }
diff --git a/core/sis-feature/src/main/java/org/apache/sis/internal/feature/Resources.java b/core/sis-feature/src/main/java/org/apache/sis/internal/feature/Resources.java
index cd23c52..bf46750 100644
--- a/core/sis-feature/src/main/java/org/apache/sis/internal/feature/Resources.java
+++ b/core/sis-feature/src/main/java/org/apache/sis/internal/feature/Resources.java
@@ -339,6 +339,11 @@ public final class Resources extends IndexedResourceBundle {
         public static final short NotStrictlyOrderedDimensions = 46;
 
         /**
+         * This operation requires an image with only one band.
+         */
+        public static final short OperationRequiresSingleBand = 77;
+
+        /**
          * The {0} optional library is not available. Geometric operations will ignore that library.
          * Cause is {1}.
          */
@@ -387,6 +392,11 @@ public final class Resources extends IndexedResourceBundle {
         public static final short UnconvertibleGridCoordinate_2 = 59;
 
         /**
+         * Can not convert sample values.
+         */
+        public static final short UnconvertibleSampleValues = 76;
+
+        /**
          * Expected {0} bands but got {1}.
          */
         public static final short UnexpectedNumberOfBands_2 = 49;
@@ -438,11 +448,6 @@ public final class Resources extends IndexedResourceBundle {
         public static final short UnspecifiedTransform = 54;
 
         /**
-         * Unsupported color model. Expected ‘{0}’ but got ‘{1}’.
-         */
-        public static final short UnsupportedColorModel_2 = 76;
-
-        /**
          * Unsupported geometry {0}D object.
          */
         public static final short UnsupportedGeometryObject_1 = 20;
diff --git a/core/sis-feature/src/main/java/org/apache/sis/internal/feature/Resources.properties b/core/sis-feature/src/main/java/org/apache/sis/internal/feature/Resources.properties
index 2455f2f..4f08663 100644
--- a/core/sis-feature/src/main/java/org/apache/sis/internal/feature/Resources.properties
+++ b/core/sis-feature/src/main/java/org/apache/sis/internal/feature/Resources.properties
@@ -73,6 +73,7 @@ NonLinearInDimensions_1           = non-linear in {0} dimension{0,choice,1#|2#s}
 NotAGeometryAtFirstExpression     = Value provided by first expression is not a geometry.
 NotASingleton_1                   = Property \u201c{0}\u201d contains more than one value.
 NotStrictlyOrderedDimensions      = The specified dimensions are not in strictly ascending order.
+OperationRequiresSingleBand       = This operation requires an image with only one band.
 OptionalLibraryNotFound_2         = The {0} optional library is not available. Geometric operations will ignore that library.\nCause is {1}.
 OutOfIteratorDomain_2             = The ({0,number}, {1,number}) pixel coordinate is outside iterator domain.
 PropertyAlreadyExists_2           = Property \u201c{1}\u201d already exists in feature \u201c{0}\u201d.
@@ -82,6 +83,7 @@ TooManyQualitatives               = Too many qualitative categories.
 TransformDependsOnDimension_1     = Coordinate operation depends on grid dimension {0}.
 UnavailableGeometryLibrary_1      = The {0} geometry library is not available in current runtime environment.
 UnconvertibleGridCoordinate_2     = Can not convert grid coordinate {1} to type \u2018{0}\u2019.
+UnconvertibleSampleValues         = Can not convert sample values.
 UnexpectedNumberOfBands_2         = Expected {0} bands but got {1}.
 UnexpectedNumberOfComponents_4    = The \u201c{1}\u201d value given to \u201c{0}\u201d property should be separable in {2} components, but we got {3}.
 UnexpectedNumberOfCoordinates_4   = The \u201c{0}\u201d feature at {1} has a {3} coordinate values, while we expected a multiple of {2}.
@@ -92,5 +94,4 @@ UnspecifiedCRS                    = Coordinate reference system is unspecified.
 UnspecifiedGridExtent             = Grid extent is unspecified.
 UnspecifiedRasterData             = Raster data are unspecified.
 UnspecifiedTransform              = Coordinates transform is unspecified.
-UnsupportedColorModel_2           = Unsupported color model. Expected \u2018{0}\u2019 but got \u2018{1}\u2019.
 UnsupportedGeometryObject_1       = Unsupported geometry {0}D object.
diff --git a/core/sis-feature/src/main/java/org/apache/sis/internal/feature/Resources_fr.properties b/core/sis-feature/src/main/java/org/apache/sis/internal/feature/Resources_fr.properties
index 41bcf6d..63935a4 100644
--- a/core/sis-feature/src/main/java/org/apache/sis/internal/feature/Resources_fr.properties
+++ b/core/sis-feature/src/main/java/org/apache/sis/internal/feature/Resources_fr.properties
@@ -78,6 +78,7 @@ NonLinearInDimensions_1           = non-lin\u00e9aire dans {0} dimension{0,choic
 NotAGeometryAtFirstExpression     = La valeur fournie par la premi\u00e8re expression n\u2019est pas une g\u00e9om\u00e9trie.
 NotASingleton_1                   = La propri\u00e9t\u00e9 \u00ab\u202f{0}\u202f\u00bb contient plus de une valeur.
 NotStrictlyOrderedDimensions      = Les dimensions sp\u00e9cifi\u00e9es ne sont pas en ordre strictement croissant.
+OperationRequiresSingleBand       = Cette op\u00e9ration n\u00e9cessite une image avec une seule bande.
 OptionalLibraryNotFound_2         = La biblioth\u00e8que optionnelle {0} n\u2019est pas disponible. Les op\u00e9rations g\u00e9om\u00e9triques ignoreront cette biblioth\u00e8que.\nLa cause est {1}.
 OutOfIteratorDomain_2             = La coordonn\u00e9e pixel ({0,number}, {1,number}) est en dehors du domaine de l\u2019it\u00e9rateur.
 PointOutsideCoverageDomain_1      = Le point ({0}) est en dehors du domaine de la couverture de donn\u00e9es.
@@ -88,6 +89,7 @@ TooManyQualitatives               = Trop de cat\u00e9gories qualitatives.
 TransformDependsOnDimension_1     = L\u2019op\u00e9ration sur les coordonn\u00e9es d\u00e9pend de la dimension {0} de la grille.
 UnavailableGeometryLibrary_1      = La biblioth\u00e8que de g\u00e9om\u00e9tries {0} n\u2019est pas disponible dans l\u2019environnement d\u2019ex\u00e9cution actuel.
 UnconvertibleGridCoordinate_2     = Ne peut pas convertir la coordonn\u00e9e de grille {1} vers le type \u2018{0}\u2019.
+UnconvertibleSampleValues         = Ne peut pas convertir les valeurs d\u2019\u00e9chantillonnages.
 UnexpectedNumberOfBands_2         = On attendait {0} bandes mais {1} ont \u00e9t\u00e9 sp\u00e9cifi\u00e9es.
 UnexpectedNumberOfComponents_4    = La valeur \u00ab\u202f{1}\u202f\u00bb donn\u00e9e \u00e0 la propri\u00e9t\u00e9 \u00ab\u202f{0}\u202f\u00bb devrait \u00eatre s\u00e9parable en {2} composantes, mais on en a obtenus {3}.
 UnexpectedNumberOfCoordinates_4   = L\u2019entit\u00e9 nomm\u00e9e \u00ab\u202f{0}\u202f\u00bb \u00e0 {1} contient {3} coordonn\u00e9es, alors qu\u2019on attendait un multiple de {2}.
@@ -98,5 +100,4 @@ UnspecifiedCRS                    = Le syst\u00e8me de r\u00e9f\u00e9rence des c
 UnspecifiedGridExtent             = L\u2019\u00e9tendue de la grille n\u2019a pas \u00e9t\u00e9 sp\u00e9cifi\u00e9e.
 UnspecifiedRasterData             = Les donn\u00e9es du raster n\u2019ont pas \u00e9t\u00e9 sp\u00e9cifi\u00e9es.
 UnspecifiedTransform              = La transformation de coordonn\u00e9es n\u2019a pas \u00e9t\u00e9 sp\u00e9cifi\u00e9e.
-UnsupportedColorModel_2           = Mod\u00e8le de couleurs non-support\u00e9. On attendait \u2018{0}\u2019 alors que \u2018{1}\u2019 a \u00e9t\u00e9 trouv\u00e9.
 UnsupportedGeometryObject_1       = Object g\u00e9om\u00e9trique {0}D non-support\u00e9.
diff --git a/core/sis-feature/src/test/java/org/apache/sis/internal/coverage/j2d/ColorizerTest.java b/core/sis-feature/src/test/java/org/apache/sis/internal/coverage/j2d/ColorizerTest.java
new file mode 100644
index 0000000..098fc3d
--- /dev/null
+++ b/core/sis-feature/src/test/java/org/apache/sis/internal/coverage/j2d/ColorizerTest.java
@@ -0,0 +1,155 @@
+/*
+ * 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.internal.coverage.j2d;
+
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.AbstractMap.SimpleEntry;
+import java.awt.Color;
+import java.awt.image.DataBuffer;
+import java.awt.image.IndexColorModel;
+import org.opengis.referencing.operation.MathTransform1D;
+import org.opengis.referencing.operation.TransformException;
+import org.apache.sis.coverage.SampleDimension;
+import org.apache.sis.math.MathFunctions;
+import org.apache.sis.measure.NumberRange;
+import org.apache.sis.measure.Units;
+import org.apache.sis.test.TestCase;
+import org.junit.Test;
+
+import static org.junit.Assert.*;
+
+
+/**
+ * Tests {@link Colorizer}.
+ *
+ * @author  Martin Desruisseaux (Geomatys)
+ * @version 1.1
+ * @since   1.1
+ * @module
+ */
+public final strictfp class ColorizerTest extends TestCase {
+    /**
+     * Tests the creation of an index color model using {@link Colorizer#Colorizer(Collection)}.
+     *
+     * @throws TransformException if a sample value can not be converted.
+     */
+    @Test
+    public void testRangeAndColors() throws TransformException {
+        final Colorizer colorizer = new Colorizer(Arrays.asList(
+                new SimpleEntry<>(NumberRange.create(0, true,  0, true), new Color[] {Color.GRAY}),
+                new SimpleEntry<>(NumberRange.create(1, true,  1, true), new Color[] {ColorModelFactory.TRANSPARENT}),
+                new SimpleEntry<>(NumberRange.create(2, true, 15, true), new Color[] {Color.BLUE, Color.WHITE, Color.RED})));
+        /*
+         * No conversion of sample values should be necessary because the
+         * above-given ranges already fit in a 4-bits IndexColormodel.
+         */
+        assertTrue("isIdentity", colorizer.getSampleToIndexValues().isIdentity());
+        assertEquals("range", NumberRange.create(2, true, 15, true), colorizer.getRepresentativeRange());
+        final IndexColorModel cm = (IndexColorModel) colorizer.createColorModel(DataBuffer.TYPE_BYTE, 1, 0);
+        final int[] expected = {
+            0xFF808080,     // Color.GRAY
+            0x00000000,     // ColorModelFactory.TRANSPARENT
+            0xFF0000FF,     // Color.BLUE
+            0xFF2727FF,
+            0xFF4E4EFF,
+            0xFF7676FF,
+            0xFF9D9DFF,
+            0xFFC4C4FF,
+            0xFFEBEBFF,
+            0xFFFFEBEB,
+            0xFFFFC4C4,
+            0xFFFF9D9D,
+            0xFFFF7676,
+            0xFFFF4E4E,
+            0xFFFF2727,
+            0xFFFF0000      // Color.RED
+        };
+        assertEquals("mapSize", expected.length, cm.getMapSize());
+        assertEquals("transparentPixel", 1, cm.getTransparentPixel());
+        for (int i=0; i<expected.length; i++) {
+            assertEquals(expected[i], cm.getRGB(i));
+        }
+    }
+
+    /**
+     * Tests the creation of an index color model using {@link Colorizer#Colorizer(Function)}
+     * and an initialization with a {@link SampleDimension}.
+     *
+     * @throws TransformException if a sample value can not be converted.
+     */
+    @Test
+    public void testSampleDimension() throws TransformException {
+        final SampleDimension sd = new SampleDimension.Builder()
+                .addQualitative ("No data", Float.NaN)
+                .addQuantitative("Low temperature", -5, 24, Units.CELSIUS)
+                .addQuantitative("Hot temperature", 25, 40, Units.CELSIUS)
+                .addQualitative ("Error", MathFunctions.toNanFloat(3))
+                .setName("Temperature").build();
+
+        final Colorizer colorizer = new Colorizer(Colorizer.GRAYSCALE);
+        assertTrue("initialize", colorizer.initialize(sd));
+        final IndexColorModel cm = (IndexColorModel) colorizer.compactColorModel(1, 0);     // Must be first.
+        /*
+         * There is two ranges of real values (excluding ranges for NaN values). But `getSampleToIndexValues()`
+         * intentionally returns only one of those ranges, not the union of them. See its javadoc for rational.
+         */
+        assertEquals("range", NumberRange.create(2, true, 169, false), colorizer.getRepresentativeRange());
+        /*
+         * Test conversion of a few sample values to packed values.
+         */
+        final MathTransform1D tr = colorizer.getSampleToIndexValues();
+        assertFalse("isIdentity", tr.isIdentity());
+        assertEquals(  0, tr.transform(Float.NaN), STRICT);
+        assertEquals(  1, tr.transform(MathFunctions.toNanFloat(3)), STRICT);
+        assertEquals(  2, tr.transform(-5), 1E-14);
+        assertEquals(255, tr.transform(40), 1E-14);
+        /*
+         * Verifies a few values from the color map. We test about 1/16 of values.
+         * The color map is a simple grayscale, except the two first colors which
+         * are transparent.
+         */
+        assertEquals("mapSize", 256, cm.getMapSize());
+        assertEquals("transparentPixel", 0, cm.getTransparentPixel());
+        final int[] expected = {
+              0, 0x00000000,
+              1, 0x00000000,
+              2, 0xFF000000,
+             16, 0xFF161616,
+             32, 0xFF2E2E2E,
+             48, 0xFF474747,
+             64, 0xFF5F5F5F,
+             80, 0xFF787878,
+             96, 0xFF909090,
+            112, 0xFFA9A9A9,
+            128, 0xFFC2C2C2,
+            144, 0xFFDADADA,
+            160, 0xFFF3F3F3,
+            176, 0xFF151515,
+            192, 0xFF444444,
+            208, 0xFF747474,
+            224, 0xFFA3A3A3,
+            240, 0xFFD3D3D3,
+            255, 0xFFFFFFFF
+        };
+        for (int k=0; k<expected.length;) {
+            final int i = expected[k++];
+            final int e = expected[k++];
+            assertEquals(e, cm.getRGB(i));
+        }
+    }
+}
diff --git a/core/sis-feature/src/test/java/org/apache/sis/test/suite/FeatureTestSuite.java b/core/sis-feature/src/test/java/org/apache/sis/test/suite/FeatureTestSuite.java
index 1cd0150..f09eea8 100644
--- a/core/sis-feature/src/test/java/org/apache/sis/test/suite/FeatureTestSuite.java
+++ b/core/sis-feature/src/test/java/org/apache/sis/test/suite/FeatureTestSuite.java
@@ -78,6 +78,7 @@ import org.junit.runners.Suite;
     org.apache.sis.internal.coverage.j2d.ImageUtilitiesTest.class,
     org.apache.sis.internal.coverage.j2d.ImageLayoutTest.class,
     org.apache.sis.internal.coverage.j2d.ScaledColorSpaceTest.class,
+    org.apache.sis.internal.coverage.j2d.ColorizerTest.class,
     org.apache.sis.image.PlanarImageTest.class,
     org.apache.sis.image.ComputedImageTest.class,
     org.apache.sis.image.DefaultIteratorTest.class,
diff --git a/core/sis-utility/src/main/java/org/apache/sis/measure/NumberRange.java b/core/sis-utility/src/main/java/org/apache/sis/measure/NumberRange.java
index 67797cc..01201ec 100644
--- a/core/sis-utility/src/main/java/org/apache/sis/measure/NumberRange.java
+++ b/core/sis-utility/src/main/java/org/apache/sis/measure/NumberRange.java
@@ -751,7 +751,7 @@ public class NumberRange<E extends Number & Comparable<? super E>> extends Range
      * then delegates to {@link #intersect(Range)}.
      *
      * @param  range  the range to add to this range.
-     * @return the union of this range with the given range.
+     * @return the intersection of this range with the given range.
      * @throws IllegalArgumentException if the given range can not be converted to a valid type
      *         through widening conversion, or if the units of measurement are not convertible.
      */
diff --git a/core/sis-utility/src/main/java/org/apache/sis/util/resources/Errors.java b/core/sis-utility/src/main/java/org/apache/sis/util/resources/Errors.java
index 388ae0b..e4f8d2a 100644
--- a/core/sis-utility/src/main/java/org/apache/sis/util/resources/Errors.java
+++ b/core/sis-utility/src/main/java/org/apache/sis/util/resources/Errors.java
@@ -63,6 +63,11 @@ public final class Errors extends IndexedResourceBundle {
         }
 
         /**
+         * ‘{0}’ is already initialized.
+         */
+        public static final short AlreadyInitialized_1 = 188;
+
+        /**
          * Name “{2}” is ambiguous because it can be understood as either “{0}” or “{1}”.
          */
         public static final short AmbiguousName_3 = 1;
@@ -876,6 +881,11 @@ public final class Errors extends IndexedResourceBundle {
         public static final short UnexpectedValueInElement_2 = 144;
 
         /**
+         * ‘{0}’ has not been initialized.
+         */
+        public static final short Uninitialized_1 = 189;
+
+        /**
          * Command “{0}” is not recognized.
          */
         public static final short UnknownCommand_1 = 145;
diff --git a/core/sis-utility/src/main/java/org/apache/sis/util/resources/Errors.properties b/core/sis-utility/src/main/java/org/apache/sis/util/resources/Errors.properties
index eceebee..60c69a8 100644
--- a/core/sis-utility/src/main/java/org/apache/sis/util/resources/Errors.properties
+++ b/core/sis-utility/src/main/java/org/apache/sis/util/resources/Errors.properties
@@ -24,6 +24,7 @@
 # programmatic parameters do not have to be last in the formatted text, since each localized message
 # can reorder the parameters as they want.
 #
+AlreadyInitialized_1              = \u2018{0}\u2019 is already initialized.
 AmbiguousName_3                   = Name \u201c{2}\u201d is ambiguous because it can be understood as either \u201c{0}\u201d or \u201c{1}\u201d.
 CanIterateOnlyOnce                = This object can iterate only once.
 CanNotAddToExclusiveSet_2         = No element can be added to this set because properties \u2018{0}\u2019 and \u2018{1}\u2019 are mutually exclusive.
@@ -186,6 +187,7 @@ UnexpectedProperty_2              = Property \u201c{1}\u201d was not expected in
 UnexpectedScaleFactorForUnit_2    = Unexpected scale factor {1,number} for unit of measurement \u201c{0}\u201d.
 UnexpectedTypeForReference_3      = Expected \u201c{0}\u201d to reference an instance of \u2018{1}\u2019, but found an instance of \u2018{2}\u2019.
 UnexpectedValueInElement_2        = Unexpected value \u201c{1}\u201d in \u201c{0}\u201d element.
+Uninitialized_1                   = \u2018{0}\u2019 has not been initialized.
 UnknownCommand_1                  = Command \u201c{0}\u201d is not recognized.
 UnknownEnumValue_2                = \u201c{1}\u201d is not a known or supported value for the \u2018{0}\u2019 enumeration.
 UnknownKeyword_1                  = Keyword \u201c{0}\u201d is unknown.
diff --git a/core/sis-utility/src/main/java/org/apache/sis/util/resources/Errors_fr.properties b/core/sis-utility/src/main/java/org/apache/sis/util/resources/Errors_fr.properties
index f117a20..45ee1f7 100644
--- a/core/sis-utility/src/main/java/org/apache/sis/util/resources/Errors_fr.properties
+++ b/core/sis-utility/src/main/java/org/apache/sis/util/resources/Errors_fr.properties
@@ -21,6 +21,7 @@
 #   U+202F NARROW NO-BREAK SPACE  before  ; ! and ?
 #   U+00A0 NO-BREAK SPACE         before  :
 #
+AlreadyInitialized_1              = \u2018{0}\u2019 est d\u00e9j\u00e0 initialis\u00e9.
 AmbiguousName_3                   = Le nom \u00ab\u202f{2}\u202f\u00bb est ambigu\u00eb car il peut \u00eatre interpr\u00e9t\u00e9 comme \u00ab\u202f{0}\u202f\u00bb ou \u00ab\u202f{1}\u202f\u00bb.
 CanIterateOnlyOnce                = Cet objet ne peut it\u00e9rer qu\u2019une seule fois.
 CanNotAddToExclusiveSet_2         = Aucun \u00e9l\u00e9ment ne peut \u00eatre ajout\u00e9 \u00e0 cet ensemble car les propri\u00e9t\u00e9s \u2018{0}\u2019 et \u2018{1}\u2019 sont mutuellement exclusives.
@@ -182,6 +183,7 @@ UnexpectedProperty_2              = La propri\u00e9t\u00e9 \u00ab\u202f{1}\u202f
 UnexpectedScaleFactorForUnit_2    = Le facteur d\u2019\u00e9chelle {1,number} est inattendu pour l\u2019unit\u00e9 de mesure \u00ab\u202f{0}\u202f\u00bb.
 UnexpectedTypeForReference_3      = L\u2019identifiant \u201c{0}\u201d r\u00e9f\u00e9rence une instance de \u2018{2}\u2019 alors qu\u2019on attendait une instance de \u2018{1}\u2019.
 UnexpectedValueInElement_2        = La valeur \u00ab\u202f{1}\u202f\u00bb dans  l\u2019\u00e9l\u00e9ment \u00ab\u202f{0}\u202f\u00bb est inattendue.
+Uninitialized_1                   = \u2018{0}\u2019 n\u2019a pas \u00e9t\u00e9 initialis\u00e9.
 UnknownCommand_1                  = La commande \u00ab\u202f{0}\u202f\u00bb n\u2019est pas reconnue.
 UnknownEnumValue_2                = \u00ab\u202f{1}\u202f\u00bb n\u2019est pas une valeur connue ou support\u00e9e pour l\u2019\u00e9num\u00e9ration \u2018{0}\u2019.
 UnknownKeyword_1                  = Le mot-cl\u00e9 \u00ab\u202f{0}\u202f\u00bb n\u2019est pas reconnu.


Mime
View raw message