You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@sis.apache.org by de...@apache.org on 2023/04/01 16:38:01 UTC

[sis] branch geoapi-4.0 updated: When no color is specified for a category or a range of sample values, and provided that `Colorizer` is used for styling an existing image, preserve the existing colors.

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

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


The following commit(s) were added to refs/heads/geoapi-4.0 by this push:
     new d0147d6a96 When no color is specified for a category or a range of sample values, and provided that `Colorizer` is used for styling an existing image, preserve the existing colors.
d0147d6a96 is described below

commit d0147d6a9634b5ee11881fcf693dcd1d179caff4
Author: Martin Desruisseaux <ma...@geomatys.com>
AuthorDate: Sat Apr 1 18:35:42 2023 +0200

    When no color is specified for a category or a range of sample values,
    and provided that `Colorizer` is used for styling an existing image,
    preserve the existing colors.
---
 .../apache/sis/gui/coverage/CoverageCanvas.java    |  58 +++++++-----
 .../apache/sis/gui/coverage/CoverageControls.java  |   2 +-
 .../apache/sis/gui/coverage/CoverageStyling.java   |  18 ++--
 .../apache/sis/internal/gui/control/ColorCell.java |   2 +-
 .../java/org/apache/sis/coverage/Category.java     |   4 +-
 .../sis/coverage/grid/GridCoverageBuilder.java     |   2 +-
 .../apache/sis/coverage/grid/ImageRenderer.java    |   7 +-
 .../apache/sis/image/BandedSampleConverter.java    |   2 +-
 .../main/java/org/apache/sis/image/Colorizer.java  |  22 ++---
 .../java/org/apache/sis/image/RecoloredImage.java  |   3 +-
 .../java/org/apache/sis/image/Visualization.java   |  13 ++-
 .../internal/coverage/j2d/ColorModelBuilder.java   |  56 ++++++++---
 .../internal/coverage/j2d/ColorModelFactory.java   |  13 ++-
 .../sis/internal/coverage/j2d/ColorsForRange.java  | 105 ++++++++++++++++-----
 .../coverage/j2d/ColorModelBuilderTest.java        |   4 +-
 .../sis/internal/map/coverage/RenderingData.java   |   2 +-
 .../java/org/apache/sis/measure/NumberRange.java   |   7 +-
 .../main/java/org/apache/sis/measure/Range.java    |  23 ++++-
 .../java/org/apache/sis/measure/RangeTest.java     |  17 +++-
 19 files changed, 248 insertions(+), 112 deletions(-)

diff --git a/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/CoverageCanvas.java b/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/CoverageCanvas.java
index 735b28b475..5cfe75de75 100644
--- a/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/CoverageCanvas.java
+++ b/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/CoverageCanvas.java
@@ -21,7 +21,6 @@ import java.util.EnumMap;
 import java.util.List;
 import java.util.Locale;
 import java.util.concurrent.Future;
-import java.util.function.Function;
 import java.util.logging.LogRecord;
 import java.io.IOException;
 import java.awt.Graphics2D;
@@ -54,7 +53,6 @@ import org.apache.sis.referencing.CommonCRS;
 import org.apache.sis.referencing.operation.transform.MathTransforms;
 import org.apache.sis.referencing.operation.transform.LinearTransform;
 import org.apache.sis.referencing.operation.matrix.AffineTransforms2D;
-import org.apache.sis.coverage.Category;
 import org.apache.sis.coverage.SampleDimension;
 import org.apache.sis.coverage.SubspaceNotSpecifiedException;
 import org.apache.sis.coverage.grid.GridCoverage;
@@ -62,6 +60,7 @@ import org.apache.sis.coverage.grid.GridExtent;
 import org.apache.sis.coverage.grid.GridGeometry;
 import org.apache.sis.geometry.Envelope2D;
 import org.apache.sis.geometry.Shapes2D;
+import org.apache.sis.image.Colorizer;
 import org.apache.sis.image.PlanarImage;
 import org.apache.sis.image.Interpolation;
 import org.apache.sis.storage.GridCoverageResource;
@@ -407,21 +406,44 @@ public class CoverageCanvas extends MapCanvasAWT {
      * @see #interpolationProperty
      */
     public final void setInterpolation(final Interpolation interpolation) {
-        assert Platform.isFxApplicationThread();
         interpolationProperty.set(interpolation);
     }
 
     /**
-     * Sets the colors to use for given categories in image. Invoking this method causes a repaint event,
-     * so it should be invoked only if at least one color is known to have changed.
+     * Sets the colorization algorithm to apply on rendered images.
+     * Should be an algorithm based on coverage categories.
      *
-     * @param  colors  colors to use for arbitrary categories of sample values, or {@code null} for default.
+     * <p>{@code CoverageCanvas} can not detect when the given colorizer changes its internal state.
+     * The {@link #stylingChanged()} method should be invoked explicitly when such change occurs.</p>
+     *
+     * @param colorizer colorization algorithm to apply on computed image, or {@code null} for default.
      */
-    final void setCategoryColors(final Function<Category, java.awt.Color[]> colors) {
+    final void setColorizer(final Colorizer colors) {
+        data.processor.setColorizer(colors);
+        stylingChanged();
+    }
+
+    /**
+     * Invoked by {@link CoverageControls} when the user selected a new color stretching mode.
+     * The sample values are assumed the same, only the image appearance is modified.
+     */
+    final void setStretching(final Stretching selection) {
         if (TRACE) {
-            trace("setCategoryColors(Function): causes repaint.");
+            trace("setStretching(%s)", selection);
         }
-        data.processor.setCategoryColors(colors);
+        if (data.selectedDerivative != selection) {
+            data.selectedDerivative = selection;
+            stylingChanged();
+        }
+    }
+
+    /**
+     * Invoked when image colors changed. Derived features such are isolines are assumed unchanged.
+     * This method should be invoked explicitly when the {@link Colorizer} changes its internal state.
+     *
+     * @see #clearRenderedImage()
+     */
+    final void stylingChanged() {
         resampledImage = null;
         requestRepaint();
     }
@@ -701,8 +723,7 @@ public class CoverageCanvas extends MapCanvasAWT {
             trace("onInterpolationSpecified(%s)", newValue);
         }
         data.processor.setInterpolation(newValue);
-        resampledImage = null;
-        requestRepaint();
+        stylingChanged();
     }
 
     /**
@@ -1114,21 +1135,6 @@ public class CoverageCanvas extends MapCanvasAWT {
         return Shapes2D.transform(MathTransforms.bidimensional(getObjectiveToDisplay().inverse()), displayBounds, null);
     }
 
-    /**
-     * Invoked by {@link CoverageControls} when the user selected a new color stretching mode.
-     * The sample values are assumed the same; only the image appearance is modified.
-     */
-    final void setStyling(final Stretching selection) {
-        if (TRACE) {
-            trace("setStyling(%s)", selection);
-        }
-        if (data.selectedDerivative != selection) {
-            data.selectedDerivative = selection;
-            resampledImage = null;
-            requestRepaint();
-        }
-    }
-
     /**
      * Invoked when an exception occurred while computing a transform but the painting process can continue.
      */
diff --git a/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/CoverageControls.java b/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/CoverageControls.java
index 48e6437384..719edb2fb2 100644
--- a/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/CoverageControls.java
+++ b/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/CoverageControls.java
@@ -122,7 +122,7 @@ final class CoverageControls extends ViewAndControls {
              *   - Color stretching
              */
             interpolation = InterpolationConverter.button(view);
-            stretching = Stretching.createButton((p,o,n) -> view.setStyling(n));
+            stretching = Stretching.createButton((p,o,n) -> view.setStretching(n));
             final GridPane valuesControl = Styles.createControlGrid(0,
                 label(vocabulary, Vocabulary.Keys.Interpolation, interpolation),
                 label(vocabulary, Vocabulary.Keys.Stretching, stretching));
diff --git a/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/CoverageStyling.java b/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/CoverageStyling.java
index 987f588f7a..07fa93f7e1 100644
--- a/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/CoverageStyling.java
+++ b/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/CoverageStyling.java
@@ -32,6 +32,7 @@ import javafx.beans.value.ObservableValue;
 import javafx.scene.control.ContextMenu;
 import org.opengis.util.InternationalString;
 import org.apache.sis.coverage.Category;
+import org.apache.sis.image.Colorizer;
 import org.apache.sis.internal.gui.Resources;
 import org.apache.sis.internal.gui.ImmutableObjectProperty;
 import org.apache.sis.internal.gui.control.ColorRamp;
@@ -53,7 +54,7 @@ final class CoverageStyling extends ColorColumnHandler<Category> implements Func
     /**
      * Customized colors selected by user. Keys are English names of categories.
      *
-     * @see #key(Category)
+     * @see #apply(Category)
      */
     private final Map<String,ColorRamp> customizedColors;
 
@@ -65,9 +66,13 @@ final class CoverageStyling extends ColorColumnHandler<Category> implements Func
     /**
      * Creates a new styling instance.
      */
+    @SuppressWarnings("ThisEscapedInObjectConstruction")
     CoverageStyling(final CoverageCanvas canvas) {
         customizedColors = new HashMap<>();
         this.canvas = canvas;
+        if (canvas != null) {
+            canvas.setColorizer(Colorizer.forCategories(this));
+        }
     }
 
     /**
@@ -77,7 +82,7 @@ final class CoverageStyling extends ColorColumnHandler<Category> implements Func
     final void copyStyling(final CoverageStyling source) {
         customizedColors.putAll(source.customizedColors);
         if (canvas != null) {
-            canvas.setCategoryColors(customizedColors.isEmpty() ? null : this);
+            canvas.stylingChanged();
         }
     }
 
@@ -92,7 +97,7 @@ final class CoverageStyling extends ColorColumnHandler<Category> implements Func
         customizedColors.clear();
         items.setAll(content);
         if (canvas != null) {
-            canvas.setCategoryColors(null);
+            canvas.stylingChanged();
         }
     }
 
@@ -146,8 +151,8 @@ final class CoverageStyling extends ColorColumnHandler<Category> implements Func
     }
 
     /**
-     * Associates colors to the given category.
-     * This is invoked when users confirmed that (s)he wants to use the selected colors.
+     * Associates colors to the given category. This method is invoked when new categories are shown
+     * in table column managed by this {@code CoverageStyling}, and when user selects new colors.
      *
      * @param  category  the category for which to assign new color(s).
      * @param  colors    the new color for the given category, or {@code null} for resetting default value.
@@ -163,8 +168,7 @@ final class CoverageStyling extends ColorColumnHandler<Category> implements Func
             old = customizedColors.remove(key);
         }
         if (canvas != null && !Objects.equals(colors, old)) {
-            canvas.setCategoryColors(customizedColors.isEmpty() ? null : this);
-            // Above method call causes a repaint event even if value is the same.
+            canvas.stylingChanged();
         }
         return category.isQuantitative() ? ColorRamp.Type.GRADIENT : ColorRamp.Type.SOLID;
     }
diff --git a/application/sis-javafx/src/main/java/org/apache/sis/internal/gui/control/ColorCell.java b/application/sis-javafx/src/main/java/org/apache/sis/internal/gui/control/ColorCell.java
index 87e3df3fe1..c4a206d230 100644
--- a/application/sis-javafx/src/main/java/org/apache/sis/internal/gui/control/ColorCell.java
+++ b/application/sis-javafx/src/main/java/org/apache/sis/internal/gui/control/ColorCell.java
@@ -292,7 +292,7 @@ final class ColorCell<S> extends TableCell<S,ColorRamp> implements EventHandler<
             if (row != null) {
                 final S item = row.getItem();
                 if (item != null) {
-                    type = handler.applyColors(item, colors);
+                    type = handler.applyColors(item, (colors != ColorRamp.DEFAULT) ? colors : null);
                 }
             }
         }
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 59efc35e42..df3901ef6c 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
@@ -179,8 +179,8 @@ public class Category implements Serializable {
      * Creates a copy of the given category except for the {@link #converse} and {@link #toConverse} fields.
      * This constructor serves two purposes:
      * <ul>
-     *   <li>If {@code caller} is null, then {@link #toConverse} is is set to identity.
-     *       This is used only if a user specify a {@code ConvertedCategory} to {@link SampleDimension} constructor.
+     *   <li>If {@code caller} is null, then {@link #toConverse} is set to identity.
+     *       This is used only if a user specifies a {@code ConvertedCategory} to {@link SampleDimension} constructor.
      *       Such converted category can only come from another {@code SampleDimension} and may have inconsistent
      *       information for the new sample dimension that the user is creating.</li>
      *   <li>If {@code caller} is non-null, then {@link #toConverse} is set to the same transform than {@code copy} and
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 15a52e41ad..5e46bc3419 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
@@ -470,7 +470,7 @@ public class GridCoverageBuilder {
                  */
                 bands = GridCoverage2D.defaultIfAbsent(bands, null, raster.getNumBands());
                 final SampleModel sm = raster.getSampleModel();
-                final ColorModelBuilder colorizer = new ColorModelBuilder(ColorModelBuilder.GRAYSCALE);
+                final ColorModelBuilder colorizer = new ColorModelBuilder(ColorModelBuilder.GRAYSCALE, null);
                 final ColorModel colors;
                 if (colorizer.initialize(sm, bands.get(visibleBand)) || colorizer.initialize(sm, visibleBand)) {
                     colors = colorizer.createColorModel(ImageUtilities.getBandType(sm), bands.size(), visibleBand);
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 31545550ea..1d5bd9e17e 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
@@ -661,8 +661,9 @@ public class ImageRenderer {
 
     /**
      * Specifies the colors to apply for each category in a sample dimension.
-     * The given function can return {@code null}, which means transparent.
-     * If this method is never invoked, then the default is a grayscale for
+     * The given function can return {@code null} for unrecognized categories.
+     * If this method is never invoked, or if a category is unrecognized,
+     * then the default is a grayscale for
      * {@linkplain Category#isQuantitative() quantitative categories} and
      * transparent for qualitative categories (typically "no data" values).
      *
@@ -752,7 +753,7 @@ public class ImageRenderer {
     @SuppressWarnings("UseOfObsoleteCollectionType")
     public RenderedImage createImage() {
         final Raster raster = createRaster();
-        final ColorModelBuilder colorizer = new ColorModelBuilder(colors);
+        final ColorModelBuilder colorizer = new ColorModelBuilder(colors, null);
         final ColorModel colors;
         final SampleModel sm = raster.getSampleModel();
         if (colorizer.initialize(sm, bands[visibleBand]) || colorizer.initialize(sm, visibleBand)) {
diff --git a/core/sis-feature/src/main/java/org/apache/sis/image/BandedSampleConverter.java b/core/sis-feature/src/main/java/org/apache/sis/image/BandedSampleConverter.java
index 8cc2527aa5..3ba4de609c 100644
--- a/core/sis-feature/src/main/java/org/apache/sis/image/BandedSampleConverter.java
+++ b/core/sis-feature/src/main/java/org/apache/sis/image/BandedSampleConverter.java
@@ -238,7 +238,7 @@ class BandedSampleConverter extends ComputedImage {
             if (sampleDimensions != null && visibleBand >= 0 && visibleBand < sampleDimensions.length) {
                 sd = sampleDimensions[visibleBand];
             }
-            final var builder = new ColorModelBuilder(ColorModelBuilder.GRAYSCALE);
+            final var builder = new ColorModelBuilder(ColorModelBuilder.GRAYSCALE, null);
             if (builder.initialize(source.getSampleModel(), sd) ||
                 builder.initialize(source.getColorModel()))
             {
diff --git a/core/sis-feature/src/main/java/org/apache/sis/image/Colorizer.java b/core/sis-feature/src/main/java/org/apache/sis/image/Colorizer.java
index 8888913868..f926f3e84e 100644
--- a/core/sis-feature/src/main/java/org/apache/sis/image/Colorizer.java
+++ b/core/sis-feature/src/main/java/org/apache/sis/image/Colorizer.java
@@ -191,18 +191,19 @@ public interface Colorizer extends Function<Colorizer.Target, Optional<ColorMode
      * this colorizer creates a non-standard (and potentially slow) color model.</p>
      *
      * <h4>Default colors</h4>
-     * The {@code colors} map shall not be null or empty but may contain {@code null} values.
-     * Those null values are translated to default sets of colors in an implementation dependent way.
+     * The given {@code colors} map can associate to some keys a null or an empty color arrays.
+     * An empty array (i.e. no color) is interpreted as an explicit request for transparency.
+     * But null values are interpreted as unspecified colors,
+     * in which case the defaults are implementation dependent.
      * In current implementation, the defaults are:
      *
      * <ul>
-     *   <li>If the range minimum and maximum values are not equal, default to grayscale colors.</li>
+     *   <li>If this colorizer is used for {@linkplain ImageProcessor#visualize(RenderedImage) visualization},
+     *       try to keep the existing colors of the image to visualize.</li>
+     *   <li>Otherwise if the range minimum and maximum values are not equal, default to grayscale colors.</li>
      *   <li>Otherwise default to a fully transparent color.</li>
      * </ul>
      *
-     * Those defaults may change in any future Apache SIS version.
-     * For example a future version may first tries to preserve the existing colors of an image.
-     *
      * <h4>Limitations</h4>
      * In current implementation, the non-standard color model ignores the specified colors.
      * If the image data type is not 8 or 16 bits integer, the colors are always grayscale.
@@ -247,14 +248,13 @@ public interface Colorizer extends Function<Colorizer.Target, Optional<ColorMode
      * In current implementation, the defaults are:
      *
      * <ul>
-     *   <li>If all categories are unrecognized, then the colorizer returns an empty value.</li>
+     *   <li>If this colorizer is used for {@linkplain ImageProcessor#visualize(RenderedImage) visualization},
+     *       try to keep the existing colors of the image to visualize.</li>
+     *   <li>Otherwise if all categories are unrecognized, then the colorizer returns an empty value.</li>
      *   <li>Otherwise, {@linkplain Category#isQuantitative() quantitative} categories default to grayscale colors.</li>
      *   <li>Otherwise qualitative categories default to a fully transparent color.</li>
      * </ul>
      *
-     * Those defaults may change in any future Apache SIS version.
-     * For example a future version may first tries to preserve the existing colors of an image.
-     *
      * <h4>Conditions</h4>
      * This colorizer is used when {@link Target#getRanges()} provides a non-empty value.
      * That value is typically fetched from the {@value PlanarImage#SAMPLE_DIMENSIONS_KEY} image property,
@@ -280,7 +280,7 @@ public interface Colorizer extends Function<Colorizer.Target, Optional<ColorMode
                     final List<SampleDimension> ranges = target.getRanges().orElse(null);
                     if (visibleBand < ranges.size()) {
                         final SampleModel model = target.getSampleModel();
-                        final var c = new ColorModelBuilder(colors);
+                        final var c = new ColorModelBuilder(colors, null);
                         if (c.initialize(model, ranges.get(visibleBand))) {
                             return Optional.ofNullable(c.createColorModel(model.getDataType(), model.getNumBands(), visibleBand));
                         }
diff --git a/core/sis-feature/src/main/java/org/apache/sis/image/RecoloredImage.java b/core/sis-feature/src/main/java/org/apache/sis/image/RecoloredImage.java
index ce24f13234..564d45b88f 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
@@ -304,8 +304,7 @@ final class RecoloredImage extends ImageAdapter {
             Arrays.fill(ARGB, end+1, validMax+1, icm.getRGB(validMax));
             final float scale = (float) ((validMax - validMin) / (maximum - minimum));
             for (int i = start; i <= end; i++) {
-                final float s = (i - start) * scale + validMin;
-                ARGB[i] = icm.getRGB(Math.round(s));
+                ARGB[i] = icm.getRGB(Math.round((i - start) * scale) + validMin);
             }
             final SampleModel sm = source.getSampleModel();
             cm = ColorModelFactory.createIndexColorModel(sm.getNumBands(), visibleBand, ARGB, icm.hasAlpha(), icm.getTransparentPixel());
diff --git a/core/sis-feature/src/main/java/org/apache/sis/image/Visualization.java b/core/sis-feature/src/main/java/org/apache/sis/image/Visualization.java
index d83417ecba..ed6313022e 100644
--- a/core/sis-feature/src/main/java/org/apache/sis/image/Visualization.java
+++ b/core/sis-feature/src/main/java/org/apache/sis/image/Visualization.java
@@ -287,6 +287,7 @@ final class Visualization extends ResampledImage {
             if (colorizer != null) {
                 colorModel = colorizer.apply(target).orElse(null);
             }
+            final ColorModel sourceCM = coloredSource.getColorModel();
             /*
              * Get a `ColorModelBuilder` which will compute the `ColorModel` of destination image.
              * There is different ways to setup the builder, depending on which `Colorizer` is used.
@@ -300,15 +301,14 @@ final class Visualization extends ResampledImage {
             final ColorModelBuilder builder;
             final var rangeColors = target.rangeColors;
             if (rangeColors != null && !rangeColors.isEmpty()) {
-                builder = new ColorModelBuilder(rangeColors.entrySet());
+                builder = new ColorModelBuilder(rangeColors.entrySet(), sourceCM);
                 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, or image statistics in last resort.
                  */
-                builder = new ColorModelBuilder(target.categoryColors);
-                final ColorModel colorModel = coloredSource.getColorModel();
+                builder = new ColorModelBuilder(target.categoryColors, sourceCM);
                 initialized = builder.initialize(coloredSource.getSampleModel(), visibleSD);
                 if (initialized) {
                     /*
@@ -317,7 +317,7 @@ final class Visualization extends ResampledImage {
                      * determined by the SampleModel, then user enhanced contrast by a call to `stretchColorRamp(…)`.
                      * We want to preserve that contrast enhancement.
                      */
-                    builder.rescaleMainRange(colorModel);
+                    builder.rescaleMainRange(sourceCM);
                 } else {
                     /*
                      * At this point there is no more user-supplied colors (through `Colorizer`) that we can use.
@@ -325,7 +325,7 @@ final class Visualization extends ResampledImage {
                      * There is no call to `rescaleMainRange(…)` because the following code already uses the range
                      * specified by the ColorModel, if available.
                      */
-                    initialized = builder.initialize(colorModel);
+                    initialized = builder.initialize(sourceCM);
                     if (!initialized) {
                         if (coloredSource instanceof RecoloredImage) {
                             final RecoloredImage colored = (RecoloredImage) coloredSource;
@@ -353,6 +353,9 @@ final class Visualization extends ResampledImage {
                 builder.getSampleToIndexValues()            // Must be after `compactColorModel(…)`.
             };
             if (shortcut) {
+                if (converters[0].isIdentity() && colorModel.equals(sourceCM)) {
+                    return coloredSource;
+                }
                 interpolation = Interpolation.NEAREST;
             } else {
                 interpolation = combine(interpolation.toCompatible(source), converters);
diff --git a/core/sis-feature/src/main/java/org/apache/sis/internal/coverage/j2d/ColorModelBuilder.java b/core/sis-feature/src/main/java/org/apache/sis/internal/coverage/j2d/ColorModelBuilder.java
index 455899fa16..2243187865 100644
--- a/core/sis-feature/src/main/java/org/apache/sis/internal/coverage/j2d/ColorModelBuilder.java
+++ b/core/sis-feature/src/main/java/org/apache/sis/internal/coverage/j2d/ColorModelBuilder.java
@@ -51,8 +51,9 @@ import org.apache.sis.util.resources.Vocabulary;
  * <ol>
  *   <li>Create a new {@link ColorModelBuilder} instance.</li>
  *   <li>Invoke one of {@code initialize(…)} methods.</li>
- *   <li>Invoke {@link #createColorModel(int, int, int)}.</li>
- *   <li>Discards {@code ColorModelBuilder}; each instance should be used only once.</li>
+ *   <li>Invoke {@link #createColorModel(int, int, int)} or {@link #compactColorModel(int, int)}.</li>
+ *   <li>Invoke {@link #getSampleToIndexValues()} if this auxiliary information is useful.</li>
+ *   <li>Discards {@code ColorModelBuilder}. Each instance shall be used only once.</li>
  * </ol>
  *
  * There is no {@code initialize(Raster)} or {@code initialize(RenderedImage)} method because if those methods
@@ -126,8 +127,8 @@ public final class ColorModelBuilder {
 
     /**
      * The colors to use for each range of values in the source image.
-     * Entries will be sorted and modified in place.
-     * The array may be null if unspecified, but shall not contain null element.
+     * This array is initially null and created by an {@code initialize(…)} method.
+     * After initialization, this array shall not contain null element.
      */
     private ColorsForRange[] entries;
 
@@ -146,14 +147,26 @@ public final class ColorModelBuilder {
      * <p>This sample dimension should not be returned to the user because it may not contain meaningful values.
      * For example, it may contain an "artificial" transfer function for computing a {@link MathTransform1D} from
      * source range to the [0 … 255] value range.</p>
+     *
+     * @see #getSampleToIndexValues()
      */
     private SampleDimension target;
 
     /**
-     * Default range of values to use if no explicitly specified by a {@link Category}.
+     * Default range of values to use if not explicitly specified by a {@link Category}.
      */
     private NumberRange<?> defaultRange;
 
+    /**
+     * Colors to inherit if a range of values is undefined, or {@code null} if none.
+     * This field should be non-null only when this builder is used for styling an image before visualization.
+     * This field should be null when this builder is created for creating a new image because the meaning of
+     * pixel values may be completely different (i.e. meaning of {@linkplain #source} may not be applicable).
+     *
+     * @see ColorsForRange#isUndefined()
+     */
+    private final ColorModel inheritedColors;
+
     /**
      * Creates a new colorizer which will apply colors on the given range of values in source image.
      * The {@code ColorModelBuilder} is considered initialized after this constructor;
@@ -164,11 +177,14 @@ public final class ColorModelBuilder {
      * and to grayscale colors otherwise.
      * Empty arrays of colors are interpreted as explicitly transparent.</p>
      *
-     * @param  colors  the colors to use for each range of values in source image.
+     * @param  colors     the colors to use for each range of values in source image.
+     * @param  inherited  the colors to use as fallback if some ranges have undefined colors, or {@code null}.
+     *                    Should be non-null only for styling an exiting image before visualization.
      */
-    public ColorModelBuilder(final Collection<Map.Entry<NumberRange<?>,Color[]>> colors) {
+    public ColorModelBuilder(final Collection<Map.Entry<NumberRange<?>,Color[]>> colors, final ColorModel inherited) {
         ArgumentChecks.ensureNonEmpty("colors", colors);
-        entries = ColorsForRange.list(colors);
+        entries = ColorsForRange.list(colors, inherited);
+        inheritedColors = inherited;
         this.colors = GRAYSCALE;
     }
 
@@ -176,11 +192,19 @@ public final class ColorModelBuilder {
      * 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, or {@code null} for default.
-     *                 The function may return {@code null} for unrecognized categories.
+     * <p>The {@code inherited} parameter is non-null when this builder is created for styling
+     * an existing image before visualization. This parameter should be null when this builder
+     * is created for creating a new image, even when that new image is derived from a source,
+     * because the meaning of pixel values may be completely different.</p>
+     *
+     * @param  colors     the colors to use for each category, or {@code null} for default.
+     *                    The function may return {@code null} for unrecognized categories.
+     * @param  inherited  the colors to use as fallback for unrecognized categories, or {@code null}.
+     *                    Should be non-null only for styling an exiting image before visualization.
      */
-    public ColorModelBuilder(final Function<Category,Color[]> colors) {
+    public ColorModelBuilder(final Function<Category,Color[]> colors, final ColorModel inherited) {
         this.colors = (colors != null) ? colors : GRAYSCALE;
+        inheritedColors = inherited;
     }
 
     /**
@@ -222,7 +246,7 @@ public final class ColorModelBuilder {
                 boolean missingNodata = true;
                 ColorsForRange[] entries = new ColorsForRange[categories.size()];
                 for (int i=0; i<entries.length; i++) {
-                    final var range = new ColorsForRange(categories.get(i), colors);
+                    final var range = new ColorsForRange(categories.get(i), colors, inheritedColors);
                     isUndefined &= range.isUndefined();
                     missingNodata &= range.isData;
                     entries[i] = range;
@@ -236,7 +260,7 @@ public final class ColorModelBuilder {
                         final int count = entries.length;
                         entries = Arrays.copyOf(entries, count + 1);
                         entries[count] = new ColorsForRange(TRANSPARENT,
-                                NumberRange.create(Float.class, Float.NaN), null, false);
+                                NumberRange.create(Float.class, Float.NaN), null, false, inheritedColors);
                     }
                     // Leave `target` to null. It will be computed by `compact()` if needed.
                     this.entries = entries;
@@ -351,7 +375,9 @@ public final class ColorModelBuilder {
         final ColorsForRange[] entries = new ColorsForRange[categories.size()];
         for (int i=0; i<entries.length; i++) {
             final Category category = categories.get(i);
-            entries[i] = new ColorsForRange(category, colors);
+            final var range = new ColorsForRange(category.forConvertedValues(true), colors, inheritedColors);
+            range.sampleRange = category.getSampleRange();
+            entries[i] = range;
         }
         this.entries = entries;
     }
@@ -530,7 +556,7 @@ reuse:  if (source != null) {
                 span += sourceRange.getSpan();
                 final ColorsForRange[] tmp = Arrays.copyOf(entries, ++count);
                 System.arraycopy(entries, deferred, tmp, ++deferred, count - deferred);
-                tmp[deferred-1] = new ColorsForRange(null, sourceRange, null, true);
+                tmp[deferred-1] = new ColorsForRange(null, sourceRange, null, true, null);
                 entries = tmp;
             }
         }
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 510fa6cd61..69410a46a4 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
@@ -209,7 +209,7 @@ public final class ColorModelFactory {
                             }
                         }
                     }
-                    codes [  count] = entry.toARGB();
+                    codes [  count] = entry.toARGB(upper - lower);
                     starts[  count] = lower;
                     starts[++count] = upper;
                 }
@@ -309,7 +309,8 @@ public final class ColorModelFactory {
     public static ColorModelFactory piecewise(final Map<NumberRange<?>, Color[]> colors) {
         final var entries = colors.entrySet();
         ArgumentChecks.ensureNonEmpty("colors", entries);
-        return PIECEWISES.intern(new ColorModelFactory(DataBuffer.TYPE_BYTE, 0, DEFAULT_VISIBLE_BAND, ColorsForRange.list(entries)));
+        final var ranges = ColorsForRange.list(entries, null);
+        return PIECEWISES.intern(new ColorModelFactory(DataBuffer.TYPE_BYTE, 0, DEFAULT_VISIBLE_BAND, ranges));
     }
 
     /**
@@ -479,7 +480,7 @@ public final class ColorModelFactory {
     {
         ArgumentChecks.ensureNonEmpty("colors", colors);
         return createPiecewise(dataType, numBands, visibleBand, new ColorsForRange[] {
-            new ColorsForRange(null, new NumberRange<>(Double.class, lower, true, upper, false), colors, true)
+            new ColorsForRange(null, new NumberRange<>(Double.class, lower, true, upper, false), colors, true, null)
         });
     }
 
@@ -779,7 +780,7 @@ public final class ColorModelFactory {
     /**
      * 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.
-     * The given {@code colors} array must be initialized with zero values in the {@code lower} … {@code upper} range.
+     * The given {@code ARGB} array must be initialized with zero values in the {@code lower} … {@code upper} range.
      *
      * @param  colors  colors to copy into the {@code ARGB} array.
      * @param  ARGB    array of integer to write ARGB values into.
@@ -796,6 +797,10 @@ public final class ColorModelFactory {
             case 1: ARGB[lower] = colors[0];                            // fall through
             case 0: return;
         }
+        if (upper - lower == colors.length) {
+            System.arraycopy(colors, 0, ARGB, 0, colors.length);
+            return;
+        }
         /*
          * Prepares the coefficients for the iteration.
          * The non-final ones will be updated inside the loop.
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
index 31894df212..76dc35f90a 100644
--- 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
@@ -21,6 +21,7 @@ import java.util.Collection;
 import java.util.Objects;
 import java.util.function.Function;
 import java.awt.Color;
+import java.awt.image.ColorModel;
 import java.awt.image.IndexColorModel;
 import org.apache.sis.coverage.Category;
 import org.apache.sis.measure.NumberRange;
@@ -53,6 +54,13 @@ final class ColorsForRange implements Comparable<ColorsForRange> {
      */
     NumberRange<?> sampleRange;
 
+    /**
+     * The range of sample values as originally specified.
+     * Contrarily to {@link #sampleRange}, this range will not be modified by {@code compact()}.
+     * This is used for fetching colors from {@link #inheritedColors} if {@link #colors} is null.
+     */
+    private final NumberRange<?> originalSampleRange;
+
     /**
      * The colors to apply on the range of sample values.
      * An empty array means that the category is explicitly specified as transparent.
@@ -60,10 +68,22 @@ final class ColorsForRange implements Comparable<ColorsForRange> {
      * is grayscale for quantitative category and transparent for qualitative category.
      *
      * @see #isUndefined()
-     * @see #toARGB()
+     * @see #toARGB(int)
      */
     private final Color[] colors;
 
+    /**
+     * The original colors, or {@code null} if unspecified.
+     * This is used as a fallback if {@link #colors} is null.
+     * This field should be non-null only when this {@code ColorsForRange} is created for
+     * styling an image before visualization. It should be null when creating a new image,
+     * because the meaning of pixel values (i.e. the sample dimensions) may be different.
+     *
+     * @see #originalSampleRange
+     * @see ColorModelBuilder#inheritedColors
+     */
+    private final ColorModel inheritedColors;
+
     /**
      * {@code true} if this entry should be taken as data, or {@code false} if it should be ignored.
      * Entry to ignore are entries associated to NaN values.
@@ -73,57 +93,71 @@ final class ColorsForRange implements Comparable<ColorsForRange> {
     /**
      * Creates a new instance for the given category.
      *
-     * @param  category  the category for which this {@code ColorsForRange} is created, or {@code null}.
-     * @param  colors    colors to apply on the category.
+     * @param  category   the category for which this {@code ColorsForRange} is created.
+     * @param  colors     colors to apply on the category.
+     * @param  inherited  the original colors to use as fallback, or {@code null} if none.
+     *                    Should be non-null only for styling an exiting image before visualization.
      */
-    ColorsForRange(final Category category, final Function<Category,Color[]> colors) {
-        final CharSequence name = category.getName();
-        this.name        = (name != null) ? name : sampleRange.toString();
+    ColorsForRange(final Category category, final Function<Category,Color[]> colors, final ColorModel inherited) {
+        this.name        = category.getName();
         this.sampleRange = category.getSampleRange();
-        this.colors      = colors.apply(category);
         this.isData      = category.isQuantitative();
+        this.colors      = colors.apply(category);
+        inheritedColors  = inherited;
+        originalSampleRange = sampleRange;
     }
 
     /**
      * Creates a new instance for the given range of values.
      *
-     * @param  name         a name identifying the range of values, or {@code null} for automatic.
-     * @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 default.
-     * @param  isData       whether this entry should be taken as main data (not fill values).
+     * @param  name          a name identifying the range of values, or {@code null} for automatic.
+     * @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 default.
+     * @param  isData        whether this entry should be taken as main data (not fill values).
+     * @param  inherited     the original colors to use as fallback, or {@code null} if none.
+     *                       Should be non-null only for styling an exiting image before visualization.
      */
-    ColorsForRange(final CharSequence name, final NumberRange<?> sampleRange, final Color[] colors, final boolean isData) {
+    ColorsForRange(final CharSequence name, final NumberRange<?> sampleRange, final Color[] colors,
+                   final boolean isData, final ColorModel inherited)
+    {
         ArgumentChecks.ensureNonNull("sampleRange", sampleRange);
         this.name        = (name != null) ? name : sampleRange.toString();
-        this.sampleRange = sampleRange;
-        this.colors      = colors;
+        this.sampleRange = originalSampleRange = sampleRange;
         this.isData      = isData;
+        this.colors      = colors;
+        inheritedColors  = inherited;
     }
 
     /**
      * Returns {@code true} if no color has been specified for this range.
      * Note that "undefined" is not the same as fully transparent color.
+     *
+     * <p>If no colors were explicitly defined but a fallback exists, then this method considers
+     * this range as defined for allowing {@link ColorModelBuilder} to inherit those colors with
+     * the range of values specified by {@link #originalSampleRange}. We conceptually accept any
+     * {@link #inheritedColors} even if {@link #toARGB(int)} can not handle all of them.</p>
      */
     final boolean isUndefined() {
-        return colors == null;
+        return colors == null && inheritedColors == null;
     }
 
     /**
      * Converts {@linkplain Map#entrySet() map entries} to an array of {@code ColorsForRange} entries.
      * The {@link #category} of each entry is left to null.
      *
-     * @param  colors  the colors to use for each range of sample values.
-     *                 A {@code null} entry value means transparent.
+     * @param  colors     the colors to use for each range of sample values.
+     *                    A {@code null} entry value means transparent.
+     * @param  inherited  the original color model from which to inherit undefined colors, or {@code null} if none.
      * @return colors to use for each range of values in the source image.
      *         Never null and does not contain null elements.
      */
-    static ColorsForRange[] list(final Collection<Map.Entry<NumberRange<?>,Color[]>> colors) {
+    static ColorsForRange[] list(final Collection<Map.Entry<NumberRange<?>,Color[]>> colors, final ColorModel inherited) {
         final ColorsForRange[] entries = new ColorsForRange[colors.size()];
         int n = 0;
         for (final Map.Entry<NumberRange<?>,Color[]> entry : colors) {
             final NumberRange<?> range = entry.getKey();
             boolean singleton = Objects.equals(range.getMinValue(), range.getMaxValue());
-            entries[n++] = new ColorsForRange(null, range, entry.getValue(), !singleton);
+            entries[n++] = new ColorsForRange(null, range, entry.getValue(), !singleton, inherited);
         }
         return ArraysExt.resize(entries, n);            // `resize` should not be needed, but we are paranoiac.
     }
@@ -134,7 +168,7 @@ final class ColorsForRange implements Comparable<ColorsForRange> {
     @Override
     public String toString() {
         final StringBuilder buffer = new StringBuilder(name).append(": ").append(sampleRange);
-        appendColorRange(buffer, toARGB());
+        appendColorRange(buffer, toARGB(2));
         return buffer.toString();
     }
 
@@ -197,9 +231,10 @@ final class ColorsForRange implements Comparable<ColorsForRange> {
      * 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.
+     * @param  length  desired array length. This is only a hint and may be ignored.
+     * @return ARGB codes for this color ramp. Never {@code null} but may be empty.
      */
-    final int[] toARGB() {
+    final int[] toARGB(final int length) {
         if (colors != null) {
             int combined = 0;
             final int[] ARGB = new int[colors.length];
@@ -214,6 +249,32 @@ final class ColorsForRange implements Comparable<ColorsForRange> {
             if ((combined & 0xFF000000) != 0) {
                 return ARGB;
             }
+        } else if (!originalSampleRange.isEmpty() && inheritedColors instanceof IndexColorModel) {
+            /*
+             * If colors are undefined, try to inherit them from the original colors.
+             * If the number of available colors is larger than the desired number,
+             * this block returns a subset of the inherited colors.
+             */
+            final IndexColorModel icm = (IndexColorModel) inheritedColors;
+            int offset = Math.round((float) originalSampleRange.getMinDouble(true));
+            int numSrc = Math.round((float) originalSampleRange.getMaxDouble()) - offset;
+            if (originalSampleRange.isMinIncluded()) numSrc++;
+            final int[] ARGB;
+            if (numSrc <= length) {
+                ARGB = new int[numSrc];
+                if (offset == 0 && numSrc == icm.getMapSize()) {
+                    icm.getRGBs(ARGB);
+                } else for (int i=0; i<numSrc; i++) {
+                    ARGB[i] = icm.getRGB(i + offset);
+                }
+            } else {
+                ARGB = new int[length];
+                final float scale = ((float) (numSrc-1)) / (length-1);
+                for (int i=0; i<length; i++) {
+                    ARGB[i] = icm.getRGB(Math.round(i * scale) + offset);
+                }
+            }
+            return ARGB;
         } else if (isData) {
             return new int[] {0xFF000000, 0xFFFFFFFF};
         }
diff --git a/core/sis-feature/src/test/java/org/apache/sis/internal/coverage/j2d/ColorModelBuilderTest.java b/core/sis-feature/src/test/java/org/apache/sis/internal/coverage/j2d/ColorModelBuilderTest.java
index 66bb12b931..012e1dcae6 100644
--- a/core/sis-feature/src/test/java/org/apache/sis/internal/coverage/j2d/ColorModelBuilderTest.java
+++ b/core/sis-feature/src/test/java/org/apache/sis/internal/coverage/j2d/ColorModelBuilderTest.java
@@ -52,7 +52,7 @@ public final class ColorModelBuilderTest extends TestCase {
         final ColorModelBuilder colorizer = new ColorModelBuilder(List.of(
                 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})));
+                new SimpleEntry<>(NumberRange.create(2, true, 15, true), new Color[] {Color.BLUE, Color.WHITE, Color.RED})), null);
         /*
          * No conversion of sample values should be necessary because the
          * above-given ranges already fit in a 4-bits IndexColormodel.
@@ -99,7 +99,7 @@ public final class ColorModelBuilderTest extends TestCase {
                 .addQualitative ("Error", MathFunctions.toNanFloat(3))
                 .setName("Temperature").build();
 
-        final ColorModelBuilder colorizer = new ColorModelBuilder(ColorModelBuilder.GRAYSCALE);
+        final ColorModelBuilder colorizer = new ColorModelBuilder(ColorModelBuilder.GRAYSCALE, null);
         assertTrue("initialize", colorizer.initialize(null, sd));
         final IndexColorModel cm = (IndexColorModel) colorizer.compactColorModel(1, 0);     // Must be first.
         /*
diff --git a/core/sis-portrayal/src/main/java/org/apache/sis/internal/map/coverage/RenderingData.java b/core/sis-portrayal/src/main/java/org/apache/sis/internal/map/coverage/RenderingData.java
index 6c51dd0946..0b5f9efe5b 100644
--- a/core/sis-portrayal/src/main/java/org/apache/sis/internal/map/coverage/RenderingData.java
+++ b/core/sis-portrayal/src/main/java/org/apache/sis/internal/map/coverage/RenderingData.java
@@ -662,7 +662,7 @@ public class RenderingData implements Cloneable {
          */
         if (CREATE_INDEX_COLOR_MODEL) {
             final ColorModelType ct = ColorModelType.find(recoloredImage.getColorModel());
-            if (ct.isSlow || (ct.useColorRamp && processor.getColorizer() != null)) try {
+            if (ct.isSlow || ct.useColorRamp) try {
                 SampleDimensions.IMAGE_PROCESSOR_ARGUMENT.set(dataRanges);
                 return processor.visualize(recoloredImage, bounds, displayToCenter);
             } finally {
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 80f72e2717..5be9072aa1 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
@@ -108,11 +108,10 @@ public class NumberRange<E extends Number & Comparable<? super E>> extends Range
     /**
      * Returns a unique instance of the given range, except if the range is empty.
      *
-     * <div class="note"><b>Rational:</b>
-     * we exclude empty ranges because the {@link Range#equals(Object)} consider them as equal.
+     * <h4>Rational</h4>
+     * We exclude empty ranges because the {@link Range#equals(Object)} consider them as equal.
      * Consequently, if empty ranges were included in the pool, this method would return in some
      * occasions an empty range with different values than the given {@code range} argument.
-     * </div>
      *
      * We use this method only for caching range of wrapper of primitive types ({@link Byte},
      * {@link Short}, <i>etc.</i>) because those types are known to be immutable.
@@ -645,7 +644,7 @@ public class NumberRange<E extends Number & Comparable<? super E>> extends Range
 
     /**
      * Computes the difference between minimum and maximum values. If numbers are integers, the difference is computed
-     * using inclusive values (e.g. equivalent to <code>{@linkplain #getMinDouble(boolean) getMinDouble}(true)</code>).
+     * using inclusive values (e.g. using <code>{@linkplain #getMinDouble(boolean) getMinDouble}(true)</code>).
      * Otherwise the minimum and maximum values are used as-is
      * (because making them inclusive is considered an infinitely small change).
      *
diff --git a/core/sis-utility/src/main/java/org/apache/sis/measure/Range.java b/core/sis-utility/src/main/java/org/apache/sis/measure/Range.java
index 6e63f3f36b..ffe769cf1b 100644
--- a/core/sis-utility/src/main/java/org/apache/sis/measure/Range.java
+++ b/core/sis-utility/src/main/java/org/apache/sis/measure/Range.java
@@ -81,7 +81,7 @@ import org.apache.sis.util.Numbers;
  * @author  Joe White
  * @author  Martin Desruisseaux (Geomatys)
  * @author  Jody Garnett (for parameterized type inspiration)
- * @version 1.0
+ * @version 1.4
  *
  * @param <E>  the type of range elements, typically a {@link Number} subclass or {@link java.util.Date}.
  *
@@ -269,7 +269,7 @@ public class Range<E extends Comparable<? super E>> implements CheckedContainer<
      * Returns {@code true} if this range is empty. A range is empty if the
      * {@linkplain #getMinValue() minimum value} is greater than the
      * {@linkplain #getMaxValue() maximum value}, or if they are equal while
-     * at least one of them is exclusive.
+     * at least one of them is exclusive, or if both bounds are NaN.
      *
      * <h4>API note</h4>
      * This method is final because often used by the internal implementation.
@@ -286,8 +286,25 @@ public class Range<E extends Comparable<? super E>> implements CheckedContainer<
         if (c < 0) {
             return false;                               // Minimum is smaller than maximum.
         }
+        if (c != 0) {                                   // Minimum is NaN or greater than maximum.
+            return !isNaN(minValue);
+        }
         // If min and max are equal, then the range is empty if at least one of them is exclusive.
-        return (c != 0) || !isMinIncluded || !isMaxIncluded;
+        if (!isMinIncluded || !isMaxIncluded) {
+            return true;
+        }
+        return isNaN(minValue);                         // At this point if min is NaN, max is also NaN.
+    }
+
+    /**
+     * Returns {@code true} if the given value is NaN. This method tests only the primitive wrappers
+     * because the behavior of their {@code compareTo(…)} method is clearly documented. Calls to this
+     * method assume that NaNs are considered by {@code compareTo(…)} as greater than all other values.
+     */
+    private static boolean isNaN(final Object value) {
+        if (value instanceof Double) return ((Double) value).isNaN();
+        if (value instanceof Float)  return ((Float)  value).isNaN();
+        return false;
     }
 
     /**
diff --git a/core/sis-utility/src/test/java/org/apache/sis/measure/RangeTest.java b/core/sis-utility/src/test/java/org/apache/sis/measure/RangeTest.java
index f2b2750853..d266990144 100644
--- a/core/sis-utility/src/test/java/org/apache/sis/measure/RangeTest.java
+++ b/core/sis-utility/src/test/java/org/apache/sis/measure/RangeTest.java
@@ -30,7 +30,7 @@ import static org.apache.sis.test.Assert.*;
  *
  * @author  Joe White
  * @author  Martin Desruisseaux (Geomatys)
- * @version 1.1
+ * @version 1.4
  * @since   0.3
  */
 public final class RangeTest extends TestCase {
@@ -103,6 +103,21 @@ public final class RangeTest extends TestCase {
         new Range(String.class, 123.233, true, 8740.09, true);
     }
 
+    /**
+     * Tests {@link Range#isEmpty()}.
+     */
+    @Test
+    public void testIsEmpty() {
+        assertFalse(new Range<>(Float.class, 3f,        true, 5f,        true).isEmpty());
+        assertFalse(new Range<>(Float.class, 3f,        true, 3f,        true).isEmpty());
+        assertTrue (new Range<>(Float.class, 3f,        true, 3f,       false).isEmpty());
+        assertTrue (new Range<>(Float.class, 3f,       false, 3f,        true).isEmpty());
+        assertTrue (new Range<>(Float.class, 3f,       false, 3f,       false).isEmpty());
+        assertFalse(new Range<>(Float.class, Float.NaN, true, 5f,        true).isEmpty());
+        assertFalse(new Range<>(Float.class, 3f,        true, Float.NaN, true).isEmpty());
+        assertTrue (new Range<>(Float.class, Float.NaN, true, Float.NaN, true).isEmpty());
+    }
+
     /**
      * Tests the {@link Range#contains(Comparable)} method.
      */