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 2018/12/10 22:34:04 UTC

[sis] branch geoapi-4.0 updated (4348077 -> f6bafe1)

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

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


 discard 4348077  Ported ColorModelFactory from geotk. The previous skeleton was insufficient for netCDF reader needs.
     new f6bafe1  Ported ColorModelFactory from geotk. The previous skeleton was insufficient for netCDF reader needs.

This update added new revisions after undoing existing revisions.
That is to say, some revisions that were in the old version of the
branch are not in the new version.  This situation occurs
when a user --force pushes a change and generates a repository
containing something like this:

 * -- * -- B -- O -- O -- O   (4348077)
            \
             N -- N -- N   refs/heads/geoapi-4.0 (f6bafe1)

You should already have received notification emails for all of the O
revisions, and so the following emails describe only the N revisions
from the common base, B.

Any revisions marked "omit" are not gone; other references still
refer to them.  Any revisions marked "discard" are gone forever.

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


Summary of changes:
 .../main/java/org/apache/sis/internal/raster/ColorModelFactory.java   | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)


[sis] 01/01: Ported ColorModelFactory from geotk. The previous skeleton was insufficient for netCDF reader needs.

Posted by de...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

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

commit f6bafe1f6c2ea7106fe9def4e417a6f085173676
Author: Martin Desruisseaux <ma...@geomatys.com>
AuthorDate: Mon Dec 10 21:07:47 2018 +0100

    Ported ColorModelFactory from geotk. The previous skeleton was insufficient for netCDF reader needs.
---
 .../sis/internal/raster/ColorModelFactory.java     | 475 ++++++++++++++++++++-
 .../sis/internal/raster/ColorModelPatch.java       | 121 ++++++
 .../internal/raster/MultiBandsIndexColorModel.java | 236 ++++++++++
 .../sis/internal/raster/ScaledColorSpace.java      | 171 ++++++++
 .../org/apache/sis/image/DefaultIteratorTest.java  |   4 +-
 .../java/org/apache/sis/image/ImageTestCase.java   | 172 ++++++++
 .../test/java/org/apache/sis/image/TestViewer.java | 237 ++++++++++
 .../image/{TiledImage.java => TiledImageMock.java} |   4 +-
 .../sis/internal/raster/ScaledColorSpaceTest.java  | 104 +++++
 .../org/apache/sis/test/suite/RasterTestSuite.java |   3 +-
 .../org/apache/sis/test/TestConfiguration.java     |   7 +-
 .../java/org/apache/sis/storage/netcdf/Image.java  |   9 +-
 12 files changed, 1519 insertions(+), 24 deletions(-)

diff --git a/core/sis-raster/src/main/java/org/apache/sis/internal/raster/ColorModelFactory.java b/core/sis-raster/src/main/java/org/apache/sis/internal/raster/ColorModelFactory.java
index f03de49..26fd9a5 100644
--- a/core/sis-raster/src/main/java/org/apache/sis/internal/raster/ColorModelFactory.java
+++ b/core/sis-raster/src/main/java/org/apache/sis/internal/raster/ColorModelFactory.java
@@ -16,13 +16,31 @@
  */
 package org.apache.sis.internal.raster;
 
+import java.util.Map;
+import java.util.List;
+import java.util.Arrays;
+import java.util.Comparator;
+import java.util.LinkedHashMap;
+import java.util.function.Function;
+import java.awt.Transparency;
+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.ComponentColorModel;
+import java.awt.image.DataBuffer;
+import org.apache.sis.coverage.Category;
+import org.apache.sis.coverage.SampleDimension;
+import org.apache.sis.measure.NumberRange;
+import org.apache.sis.util.ArraysExt;
+import org.apache.sis.util.ArgumentChecks;
+import org.apache.sis.util.resources.Errors;
+import org.apache.sis.util.collection.WeakHashSet;
+import org.apache.sis.util.collection.WeakValueHashMap;
 
 
 /**
- * Creates color models from given properties.
+ * A factory for {@link ColorModel} objects built from a sequence of colors.
  *
  * @author  Martin Desruisseaux (IRD, Geomatys)
  * @version 1.0
@@ -31,28 +49,451 @@ import java.awt.image.IndexColorModel;
  */
 public final class ColorModelFactory {
     /**
-     * Do not allow instantiation of this class.
+     * 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
+     * real interest is to share index color models.
+     */
+    @SuppressWarnings("rawtypes")
+    private static final WeakHashSet<ColorModelPatch> CACHE = new WeakHashSet<>(ColorModelPatch.class);
+
+    /**
+     * A pool of color models previously created by {@link #createColorModel()}.
+     *
+     * <div class="note"><b>Note:</b>
+     * we use {@linkplain java.lang.ref.WeakReference weak references} instead of {@linkplain java.lang.ref.SoftReference
+     * soft references} because the intent is not to cache the values. The intent is to share existing instances in order
+     * to reduce memory usage. Rational:
+     *
+     * <ul>
+     *   <li>{@link ColorModel} may consume a lot of memory. A 16 bits indexed color model can consume up to 256 kb.
+     *       We do not want to retain such large objects longer than necessary. We want to share existing instances
+     *       without preventing the garbage collector to collect them.</li>
+     *   <li>{@link #createColorModel()} is reasonably fast if invoked only occasionally, so it is not worth consuming 256 kb
+     *       for saving the few milliseconds requiring for building a new color model. Client code should retains their own
+     *       reference to a {@link ColorModel} if they plan to reuse it often in a short period of time.</li>
+     * </ul>
+     * </div>
+     */
+    private static final Map<ColorModelFactory,ColorModel> PIECEWISES = new WeakValueHashMap<>(ColorModelFactory.class);
+
+    /**
+     * 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));
+
+    /**
+     * The minimum and maximum sample values.
+     */
+    private final float minimum, maximum;
+
+    /**
+     * In a color map defined by a piecewise function, indices where to store the first interpolated value in the color map.
+     * The number of pieces (segments) is {@code pieceStarts.length}. The last element of this array is the index after the
+     * end of the last piece. The indices are unsigned short integers. Never {@code null} but may be empty.
+     */
+    private final short[] pieceStarts;
+
+    /**
+     * The Alpha-Red-Green-Blue codes for all segments of the piecewise function.
+     * This is {@code null} if {@link #pieceStarts} is empty.
+     */
+    private final int[][] ARGB;
+
+    /**
+     * The visible band (usually 0) used for the construction of a single instance of a {@link ColorModel}.
+     */
+    private final int visibleBand;
+
+    /**
+     * The number of bands (usually 1) used for the construction of a single instance of a {@link ColorModel}.
+     */
+    private final int numBands;
+
+    /**
+     * The color model type. One of {@link DataBuffer#TYPE_BYTE}, {@link DataBuffer#TYPE_USHORT},
+     * {@link DataBuffer#TYPE_FLOAT} or {@link DataBuffer#TYPE_DOUBLE}.
+     *
+     * @todo The user may want to set explicitly the number of bits each pixel occupies.
+     *       We need to think about an API to allows that.
+     */
+    private final int type;
+
+    /**
+     * Constructs a new {@code ColorModelFactory}. This object will be used as a key in a {@link Map},
+     * 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.
+     */
+    private ColorModelFactory(final Map<? extends NumberRange<?>, ? extends Color[]> categories,
+                              final int visibleBand, final int numBands, final int type)
+    {
+        this.visibleBand = visibleBand;
+        this.numBands    = numBands;
+        this.type        = type;
+        @SuppressWarnings({"unchecked", "rawtypes"})
+        final Map.Entry<NumberRange<?>, Color[]>[] entries = categories.entrySet().toArray(new Map.Entry[categories.size()]);
+        Arrays.sort(entries, RANGE_COMPARATOR);
+        int     count   = 0;
+        short[] starts  = new short[entries.length + 1];
+        int[][] codes   = new int[entries.length][];
+        double  minimum = Double.POSITIVE_INFINITY;
+        double  maximum = Double.NEGATIVE_INFINITY;
+        for (final Map.Entry<NumberRange<?>, Color[]> entry : entries) {
+            final NumberRange<?> range = entry.getKey();
+            final double min = range.getMinDouble(true);
+            final double max = range.getMaxDouble(false);
+            if (min < minimum) minimum = min;
+            if (max > maximum) maximum = max;
+            final int lower = Math.round((float) min);
+            final int upper = Math.round((float) max);
+            if (lower < upper) {
+                if (lower < 0 || upper > 0xFFFF) {
+                    starts = ArraysExt.EMPTY_SHORT;
+                    codes  = null;
+                } else if (codes != null) {
+                    if (count != 0) {
+                        final int before = Short.toUnsignedInt(starts[count]);
+                        if (before != lower) {
+                            if (before > lower) {
+                                // TODO: remove the overlapped colors in previous range.
+                            } else {
+                                // TODO: we could reduce the amount of copies.
+                                codes  = Arrays.copyOf(codes,   codes.length + 1);
+                                starts = Arrays.copyOf(starts, starts.length + 1);
+                                codes[count++] = ArraysExt.EMPTY_INT;
+                            }
+                        }
+                    }
+                    codes [  count] = toARGB(entry.getValue());
+                    starts[  count] = (short) lower;
+                    starts[++count] = (short) upper;
+                }
+            }
+        }
+        if (minimum >= maximum) {
+            minimum = 0;
+            maximum = 1;
+        }
+        this.minimum     = (float) minimum;
+        this.maximum     = (float) maximum;
+        this.pieceStarts = starts;
+        this.ARGB        = codes;
+    }
+
+    /**
+     * Constructs the color model from the {@code #codes} and {@link #ARGB} data.
+     * This method is invoked the first time the color model is created, or when
+     * the value in the cache has been discarded.
+     */
+    private ColorModel createColorModel() {
+        /*
+         * If the requested type is any type not supported by IndexColorModel,
+         * fallback on a generic (but very slow!) color model.
+         */
+        if (type != DataBuffer.TYPE_BYTE && type != DataBuffer.TYPE_USHORT) {
+            final ColorSpace colors = new ScaledColorSpace(numBands, visibleBand, minimum, maximum);
+            return new ComponentColorModel(colors, false, false, Transparency.OPAQUE, type);
+        }
+        /*
+         * If there is no category, constructs a gray scale palette.
+         */
+        final int categoryCount = pieceStarts.length - 1;
+        if (numBands == 1 && categoryCount <= 0) {
+            final ColorSpace cs = ColorSpace.getInstance(ColorSpace.CS_GRAY);
+            final int[] nBits = {
+                DataBuffer.getDataTypeSize(type)
+            };
+            return new ComponentColorModel(cs, nBits, false, true, Transparency.OPAQUE, type);
+        }
+        /*
+         * Interpolates the colors in the color palette. Colors that do not fall
+         * in the range of a category will be set to a transparent color.
+         */
+        final int[] colorMap = new int[Short.toUnsignedInt(pieceStarts[categoryCount])];
+        int transparent = -1;
+        for (int i=0; i<categoryCount; i++) {
+            final int[] colors = ARGB[i];
+            final int   lower  = Short.toUnsignedInt(pieceStarts[i  ]);
+            final int   upper  = Short.toUnsignedInt(pieceStarts[i+1]);
+            if (transparent < 0 && colors.length == 0) {
+                transparent = lower;
+            }
+            expand(colors, colorMap, lower, upper);
+        }
+        return createIndexColorModel(colorMap, numBands, visibleBand, transparent);
+    }
+
+    /**
+     * Public as an implementation side-effect.
+     *
+     * @return a hash code.
+     */
+    @Override
+    public int hashCode() {
+        final int categoryCount = pieceStarts.length - 1;
+        int code = 962745549 + (numBands*31 + visibleBand)*31 + categoryCount;
+        for (int i=0; i<categoryCount; i++) {
+            code += Arrays.hashCode(ARGB[i]);
+        }
+        return code;
+    }
+
+    /**
+     * Public as an implementation side-effect.
+     *
+     * @param  other the other object to compare for equality.
+     * @return whether the two objects are equal.
+     */
+    @Override
+    public boolean equals(final Object other) {
+        if (other == this) {
+            return true;
+        }
+        if (other instanceof ColorModelFactory) {
+            final ColorModelFactory that = (ColorModelFactory) other;
+            return this.type        == that.type
+                && this.numBands    == that.numBands
+                && this.visibleBand == that.visibleBand
+                && this.minimum     == that.minimum         // Should never be NaN.
+                && this.maximum     == that.maximum
+                && Arrays.equals(pieceStarts, that.pieceStarts)
+                && Arrays.deepEquals(ARGB, that.ARGB);
+        }
+        return false;
+    }
+
+    /**
+     * Returns a color model interpolated for the ranges in the given sample dimensions.
+     * This method builds up the color model from each category in the visible sample dimension.
+     * Returned instances of {@link ColorModel} are shared among all callers in the running virtual machine.
+     *
+     * @param  bands        the sample dimensions for which to create a color model.
+     * @param  visibleBand  the band to be made visible (usually 0). All other bands, if any will be ignored.
+     * @param  type         the color model type. One of {@link DataBuffer#TYPE_BYTE}, {@link DataBuffer#TYPE_USHORT},
+     *                      {@link DataBuffer#TYPE_FLOAT} or {@link DataBuffer#TYPE_DOUBLE}.
+     * @param  colors       the colors to use for each category. The function may return {@code null}, which means transparent.
+     * @return a color model suitable for {@link java.awt.image.RenderedImage} objects with values in the given ranges.
+     */
+    public static ColorModel createColorModel(final List<? extends SampleDimension> bands,
+            final int visibleBand, final int type, final Function<Category,Color[]> colors)
+    {
+        final Map<NumberRange<?>, Color[]> ranges = new LinkedHashMap<>();
+        for (final Category category : bands.get(visibleBand).getCategories()) {
+            ranges.put(category.getSampleRange(), colors.apply(category));
+        }
+        return createColorModel(ranges, visibleBand, bands.size(), type);
+    }
+
+    /**
+     * 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.
+     * Returned instances of {@link ColorModel} are shared among all callers in the running virtual machine.
+     *
+     * @param  categories   the colors associated to ranges of sample values.
+     * @param  visibleBand  the band to be made visible (usually 0). All other bands, if any will be ignored.
+     * @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  type         the color model type. One of {@link DataBuffer#TYPE_BYTE}, {@link DataBuffer#TYPE_USHORT},
+     *                      {@link DataBuffer#TYPE_FLOAT} or {@link DataBuffer#TYPE_DOUBLE}.
+     * @return a color model suitable for {@link java.awt.image.RenderedImage} objects with values in the given ranges.
+     */
+    public static ColorModel createColorModel(final Map<? extends NumberRange<?>, ? extends Color[]> categories,
+            final int visibleBand, final int numBands, final int type)
+    {
+        ArgumentChecks.ensureNonNull("categories", categories);
+        if (visibleBand < 0 || visibleBand >= numBands) {
+            throw new IllegalArgumentException(Errors.format(Errors.Keys.IllegalArgumentValue_2, "visibleBand", visibleBand));
+        }
+        final ColorModelFactory key = new ColorModelFactory(categories, visibleBand, numBands, type);
+        synchronized (PIECEWISES) {
+            ColorModel model = PIECEWISES.get(key);
+            if (model == null) {
+                model = key.createColorModel();
+                PIECEWISES.put(key, model);
+            }
+            return model;
+        }
+    }
+
+    /**
+     * Returns a tolerant index color model for the specified ARGB code.
+     * This color model accepts image with the specified number of bands.
+     *
+     * <p>This methods caches previously created instances using weak references,
+     * because index color model may be big (up to 256 kb).</p>
+     *
+     * @param  ARGB        An array of ARGB values.
+     * @param  numBands    The number of bands.
+     * @param  visibleBand The band to display.
+     * @param  transparent The transparent pixel, or -1 for auto-detection.
+     * @return An index color model for the specified array.
+     */
+    public static IndexColorModel createIndexColorModel(final int[] ARGB, final int numBands, final int visibleBand, int transparent) {
+        /*
+         * No need to scan the ARGB values in search of a transparent pixel;
+         * the IndexColorModel constructor does that for us.
+         */
+        final int length = ARGB.length;
+        final int bits = getBitCount(length);
+        final int type = getTransferType(length);
+        final IndexColorModel cm;
+        if (numBands == 1) {
+            cm = new IndexColorModel(bits, length, ARGB, 0, true, transparent, type);
+        } else {
+            cm = new MultiBandsIndexColorModel(bits, length, ARGB, 0, true, transparent,
+                                               type, numBands, visibleBand);
+        }
+        return unique(cm);
+    }
+
+    /**
+     * Returns a unique instance of the given color model. This method is automatically invoked by {@code create(…)} methods
+     * in this class. This {@code unique(ColorModel)} method is public for use by color models created by other ways.
+     *
+     * @param  <T>  the type of the color model to share.
+     * @param  cm   the color model for which to get a unique instance.
+     * @return a unique (shared) instance of the given color model.
+     */
+    public static <T extends ColorModel> T unique(T cm) {
+        ColorModelPatch<T> c = new ColorModelPatch<>(cm);
+        c = CACHE.unique(c);
+        return c.cm;
+    }
+
+    /**
+     * Returns a suggested type for an {@link IndexColorModel} of {@code mapSize} colors.
+     * This method returns {@link DataBuffer#TYPE_BYTE} or {@link DataBuffer#TYPE_USHORT}.
+     *
+     * @param  mapSize  the number of colors in the map.
+     * @return the suggested transfer type.
+     */
+    public static int getTransferType(final int mapSize) {
+        return (mapSize <= 256) ? DataBuffer.TYPE_BYTE : DataBuffer.TYPE_USHORT;
+    }
+
+    /**
+     * Returns a bit count for an {@link IndexColorModel} mapping {@code mapSize} colors.
+     * It is guaranteed that the following relation is hold:
+     *
+     * {@preformat java
+     *     (1 << getBitCount(mapSize)) >= mapSize
+     * }
+     *
+     * @param  mapSize  the number of colors in the map.
+     * @return the number of bits to use.
      */
-    private ColorModelFactory() {
+    public static int getBitCount(final int mapSize) {
+        final int count = Math.max(1, Integer.SIZE - Integer.numberOfLeadingZeros(mapSize - 1));
+        assert (1 << count) >= mapSize : mapSize;
+        assert (1 << (count-1)) < mapSize : mapSize;
+        return count;
     }
 
     /**
-     * Creates for image of the given type.
+     * Tries to guess the number of bands from the specified color model. The recommended approach is to invoke
+     * {@link java.awt.image.SampleModel#getNumBands()}. This method should be used only as a fallback when the
+     * sample model is not available. This method uses some heuristic rules for guessing the number of bands,
+     * so the return value may not be exact in all cases.
      *
-     * @param  type  one of {@link DataBuffer} constant.
-     * @return color model.
+     * @param  model  the color model for which to guess the number of bands. May be {@code null}.
+     * @return the number of bands in the given color model, or 0 if the given model is {@code null}.
+     */
+    public static int getNumBands(final ColorModel model) {
+        if (model == null) {
+            return 0;
+        } else if (model instanceof IndexColorModel) {
+            if (model instanceof MultiBandsIndexColorModel) {
+                return ((MultiBandsIndexColorModel) model).numBands;
+            } else {
+                return 1;
+            }
+        } else {
+            return model.getNumComponents();
+        }
+    }
+
+    /**
+     * Returns the ARGB codes for the given colors. If all colors are transparent, returns an empty array.
      *
-     * @todo need much improvement.
+     * @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.
      */
-    public static ColorModel create(final int type) {
-        final int bits = DataBuffer.getDataTypeSize(type);
-        final int[] ARGB = new int[1 << bits];
-        final float scale = 255f / ARGB.length;
-        for (int i=0; i<ARGB.length; i++) {
-            int c = Math.round(scale * i);
-            c |= (c << 8) | (c << 16);
-            ARGB[i] = c;
+    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++) {
+                int color = colors[i].getRGB();                     // Note: getRGB() is really getARGB().
+                combined |= color;
+                ARGB[i]   = color;
+            }
+            if ((combined & 0xFF000000) != 0) {
+                return ARGB;
+            }
         }
-        return new IndexColorModel(bits, ARGB.length, ARGB, 0, false, -1, DataBuffer.TYPE_USHORT);
+        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.
+     *
+     * @param  colors  colors to copy into the {@code ARGB} array.
+     * @param  ARGB    array of integer to write ARGB values into.
+     * @param  lower   index (inclusive) of the first element of {@code ARGB} to change.
+     * @param  upper   index (exclusive) of the last  element of {@code ARGB} to change.
+     */
+    @SuppressWarnings("fallthrough")
+    public static void expand(final int[] colors, final int[] ARGB, final int lower, final int upper) {
+        switch (colors.length) {
+            case 1: Arrays.fill(ARGB, lower, upper, colors[0]);         // fall through
+            case 0: return;
+        }
+        switch (upper - lower) {
+            case 1: ARGB[lower] = colors[0];                            // fall through
+            case 0: return;
+        }
+        /*
+         * Prepares the coefficients for the iteration.
+         * The non-final ones will be updated inside the loop.
+         */
+        final double scale = (double) (colors.length - 1) / (double) (upper - lower - 1);
+        final int maxBase = colors.length - 2;
+        float index = 0;
+        int   base  = 0;
+        for (int i=lower;;) {
+            final int C0 = colors[base    ];
+            final int C1 = colors[base + 1];
+            final int A0 = (C0 >>> 24) & 0xFF,   A1 = ((C1 >>> 24) & 0xFF) - A0;
+            final int R0 = (C0 >>> 16) & 0xFF,   R1 = ((C1 >>> 16) & 0xFF) - R0;
+            final int G0 = (C0 >>>  8) & 0xFF,   G1 = ((C1 >>>  8) & 0xFF) - G0;
+            final int B0 = (C0       ) & 0xFF,   B1 = ((C1       ) & 0xFF) - B0;
+            final int oldBase = base;
+            do {
+                final float delta = index - base;
+                ARGB[i] = (roundByte(A0 + delta*A1) << 24) |
+                          (roundByte(R0 + delta*R1) << 16) |
+                          (roundByte(G0 + delta*G1) <<  8) |
+                          (roundByte(B0 + delta*B1));
+                if (++i == upper) {
+                    return;
+                }
+                index = (float) ((i - lower) * scale);
+                base = Math.min(maxBase, (int) (index + Math.ulp(index)));          // Really want rounding toward 0.
+            } while (base == oldBase);
+        }
+    }
+
+    /**
+     * Rounds a float value and clamps the result between 0 and 255 inclusive.
+     *
+     * @param  value  the value to round.
+     * @return the rounded and clamped value.
+     */
+    private static int roundByte(final float value) {
+        return Math.min(Math.max(Math.round(value), 0), 255);
     }
 }
diff --git a/core/sis-raster/src/main/java/org/apache/sis/internal/raster/ColorModelPatch.java b/core/sis-raster/src/main/java/org/apache/sis/internal/raster/ColorModelPatch.java
new file mode 100644
index 0000000..35cce58
--- /dev/null
+++ b/core/sis-raster/src/main/java/org/apache/sis/internal/raster/ColorModelPatch.java
@@ -0,0 +1,121 @@
+/*
+ * 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.raster;
+
+import java.util.Arrays;
+import java.util.Objects;
+import java.awt.image.ColorModel;
+import java.awt.image.IndexColorModel;
+import org.apache.sis.util.Workaround;
+
+
+/**
+ * Workaround for broken {@link ColorModel#equals(Object)} in Java 8 and before.
+ * This workaround will be removed after upgrade to Java 9.
+ *
+ * @author  Martin Desruisseaux (Geomatys)
+ * @version 1.0
+ *
+ * @see <a href="https://bugs.openjdk.java.net/browse/JDK-7107905"></a>
+ * @todo Delete after migration to JDK9.
+ */
+@Workaround(library = "JDK", version = "8")
+final class ColorModelPatch<T extends ColorModel> {
+    /**
+     * The color model to share.
+     */
+    final T cm;
+
+    /**
+     * For internal use only.
+     */
+    ColorModelPatch(final T cm) {
+        this.cm = cm;
+    }
+
+    /**
+     * Returns {@code true} if the given color models are equal. The {@link ColorModel} class
+     * defines an {@code equals} method, but as of Java 6 that method does not compare every
+     * attributes. For example it does not compare the color space and the transfer type, so
+     * we have to compare them here.
+     *
+     * @param cm1  the first color model.
+     * @param cm2  the second color model.
+     * @return {@code true} if the two color models are equal.
+     */
+    private static boolean equals(final ColorModel cm1, final ColorModel cm2) {
+        if (cm1 == cm2) {
+            return true;
+        }
+        if (cm1 != null && cm1.equals(cm2) &&
+            cm1.getClass().equals(cm2.getClass()) &&
+            cm1.getTransferType() == cm2.getTransferType() &&
+            Objects.equals(cm1.getColorSpace(), cm2.getColorSpace()))
+        {
+            if (cm1 instanceof IndexColorModel) {
+                final IndexColorModel icm1 = (IndexColorModel) cm1;
+                final IndexColorModel icm2 = (IndexColorModel) cm2;
+                final int size = icm1.getMapSize();
+                if (icm2.getMapSize() == size &&
+                    icm1.getTransparentPixel() == icm2.getTransparentPixel() &&
+                    Objects.equals(icm1.getValidPixels(), icm2.getValidPixels()))
+                {
+                    for (int i=0; i<size; i++) {
+                        if (icm1.getRGB(i) != icm2.getRGB(i)) {
+                            return false;
+                        }
+                    }
+                }
+                if (cm1 instanceof MultiBandsIndexColorModel) {
+                    final MultiBandsIndexColorModel micm1 = (MultiBandsIndexColorModel) cm1;
+                    final MultiBandsIndexColorModel micm2 = (MultiBandsIndexColorModel) cm2;
+                    if (micm1.numBands != micm2.numBands || micm1.visibleBand != micm2.visibleBand) {
+                        return false;
+                    }
+                }
+            }
+            return true;
+        }
+        return false;
+    }
+
+    /**
+     * For internal use only.
+     *
+     * @param object object The object to compare to.
+     * @return {@code true} if both object are equal.
+     */
+    @Override
+    public boolean equals(final Object object) {
+        return (object instanceof ColorModelPatch<?>) && equals(cm, ((ColorModelPatch<?>) object).cm);
+    }
+
+    /**
+     * For internal use only.
+     */
+    @Override
+    public int hashCode() {
+        int code = cm.hashCode() ^ cm.getClass().hashCode();
+        if (cm instanceof IndexColorModel) {
+            final IndexColorModel icm = (IndexColorModel) cm;
+            final int[] ARGB = new int[icm.getMapSize()];
+            icm.getRGBs(ARGB);
+            code ^= Arrays.hashCode(ARGB);
+        }
+        return code;
+    }
+}
diff --git a/core/sis-raster/src/main/java/org/apache/sis/internal/raster/MultiBandsIndexColorModel.java b/core/sis-raster/src/main/java/org/apache/sis/internal/raster/MultiBandsIndexColorModel.java
new file mode 100644
index 0000000..f1f3d4d
--- /dev/null
+++ b/core/sis-raster/src/main/java/org/apache/sis/internal/raster/MultiBandsIndexColorModel.java
@@ -0,0 +1,236 @@
+/*
+ * 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.raster;
+
+import java.util.Arrays;
+import java.awt.image.Raster;
+import java.awt.image.DataBuffer;
+import java.awt.image.SampleModel;
+import java.awt.image.WritableRaster;
+import java.awt.image.IndexColorModel;
+import java.awt.image.BandedSampleModel;
+import java.awt.image.ComponentSampleModel;
+
+
+/**
+ * An {@link IndexColorModel} tolerant with image having more than one band.
+ * This class can support only the types supported by {@code IndexColorModel}
+ * parent class. As of Java 10 they are restricted to {@link DataBuffer#TYPE_BYTE}
+ * and {@code DataBuffer#TYPE_USHORT}.
+ *
+ * <p><b>Reminder:</b> {@link #getNumComponents()} will returns 3 or 4 no matter
+ * how many bands were specified to the constructor. This is not specific to this class;
+ * {@code IndexColorModel} behave that way. So we can't rely on this method for checking
+ * the number of bands.</p>
+ *
+ * @author  Martin Desruisseaux (IRD, Geomatys)
+ * @author  Andrea Aime (TOPP)
+ * @version 1.0
+ * @since   1.0
+ * @module
+ */
+final class MultiBandsIndexColorModel extends IndexColorModel {
+    /**
+     * The number of bands.
+     */
+    final int numBands;
+
+    /**
+     * The visible band.
+     */
+    final int visibleBand;
+
+    /**
+     * Constructs an index color model with the specified properties.
+     *
+     * @param bits          the number of bits each pixel occupies.
+     * @param size          the size of the color component arrays.
+     * @param cmap          the array of color components.
+     * @param start         the starting offset of the first color component.
+     * @param hasAlpha      indicates whether alpha values are contained in the {@code cmap} array.
+     * @param transparent   The index of the fully transparent pixel.
+     * @param transferType  The data type of the array used to represent pixel values.
+     * @param numBands      the number of bands.
+     * @param visibleBand   the band to display.
+     *
+     * @throws IllegalArgumentException if {@code bits} is less than 1 or greater than 16.
+     * @throws IllegalArgumentException if {@code size} is less than 1.
+     * @throws IllegalArgumentException if {@code transferType} is not one of
+     *         {@code DataBuffer.TYPE_BYTE} or {@code DataBuffer.TYPE_USHORT}.
+     */
+    public MultiBandsIndexColorModel(final int bits,
+                                     final int size,
+                                     final int[] cmap,
+                                     final int start,
+                                     final boolean hasAlpha,
+                                     final int transparent,
+                                     final int transferType,
+                                     final int numBands,
+                                     final int visibleBand)
+    {
+        super(bits, size, cmap, start, hasAlpha, transparent, transferType);
+        this.numBands    = numBands;
+        this.visibleBand = visibleBand;
+    }
+
+    /**
+     * Converts a RGB color to a representation of a pixel in this color model.
+     * This method returns an array with a length equal to the number of bands specified to
+     * the constructor ({@code IndexColorModel} would returns an array of length 1). All array
+     * elements are set to the same value. Replicating the pixel value is a somewhat arbitrary
+     * choice, but this choice makes this image appears as a gray scale image if the underlying
+     * {@link DataBuffer} were displayed again with a RGB color model instead of this one. Such
+     * a gray scale image seems more neutral than an image where only the Red component would vary.
+     *
+     * <p>All other {@code getDataElement(…)} methods in this color model are ultimately defined
+     * in terms of this method, so overriding this method if needed should be enough.</p>
+     */
+    @Override
+    public Object getDataElements(final int RGB, Object pixel) {
+        if (pixel == null) {
+            switch (transferType) {
+                case DataBuffer.TYPE_SHORT:  // Handled as a matter of principle.
+                case DataBuffer.TYPE_USHORT: pixel = new short[numBands]; break;
+                case DataBuffer.TYPE_BYTE:   pixel = new byte [numBands]; break;
+                case DataBuffer.TYPE_INT:    pixel = new int  [numBands]; break;
+            }
+        }
+        pixel = super.getDataElements(RGB, pixel);
+        switch (transferType) {
+            case DataBuffer.TYPE_BYTE: {
+                final byte[] array = (byte[]) pixel;
+                Arrays.fill(array, 1, numBands, array[0]);
+                break;
+            }
+            case DataBuffer.TYPE_SHORT:      // Handled as a matter of principle.
+            case DataBuffer.TYPE_USHORT: {
+                final short[] array = (short[]) pixel;
+                Arrays.fill(array, 1, numBands, array[0]);
+                break;
+            }
+            case DataBuffer.TYPE_INT: {
+                final int[] array = (int[]) pixel;
+                Arrays.fill(array, 1, numBands, array[0]);
+                break;
+            }
+        }
+        return pixel;
+    }
+
+    /**
+     * Returns the pixel value as an integer.
+     */
+    private int pixel(final Object inData) {
+        switch (transferType) {
+            case DataBuffer.TYPE_BYTE:   return  Byte.toUnsignedInt( ((byte[]) inData)[visibleBand]);
+            case DataBuffer.TYPE_USHORT: return Short.toUnsignedInt(((short[]) inData)[visibleBand]);
+            case DataBuffer.TYPE_SHORT:  return                     ((short[]) inData)[visibleBand];
+            case DataBuffer.TYPE_INT:    return                       ((int[]) inData)[visibleBand];
+            default: throw new UnsupportedOperationException();
+        }
+    }
+
+    /**
+     * Returns an array of unnormalized color/alpha components for a specified pixel in this color model.
+     * This method is the converse of {@link #getDataElements(int, Object)}.
+     */
+    @Override
+    public int[] getComponents(final Object pixel, final int[] components, final int offset) {
+        return getComponents(pixel(components), components, offset);
+    }
+
+    /**
+     * Returns the red color component for the specified pixel,
+     * scaled from 0 to 255 in the default RGB {@code ColorSpace}.
+     */
+    @Override
+    public int getRed(final Object inData) {
+        return getRed(pixel(inData));
+    }
+
+    /**
+     * Returns the green color component for the specified pixel,
+     * scaled from 0 to 255 in the default RGB {@code ColorSpace}.
+     */
+    @Override
+    public int getGreen(final Object inData) {
+        return getGreen(pixel(inData));
+    }
+
+    /**
+     * Returns the blue color component for the specified pixel,
+     * scaled from 0 to 255 in the default RGB {@code ColorSpace}.
+     */
+    @Override
+    public int getBlue(final Object inData) {
+        return getBlue(pixel(inData));
+    }
+
+    /**
+     * Returns the alpha component for the specified pixel, scaled from 0 to 255.
+     */
+    @Override
+    public int getAlpha(final Object inData) {
+        return getAlpha(pixel(inData));
+    }
+
+    /**
+     * Creates a {@code WritableRaster} with the specified width and height that has
+     * a data layout ({@code SampleModel}) compatible with this {@code ColorModel}.
+     *
+     * The difference with standard implementation is that this method creates a banded raster on the assumption that
+     * the number of bands is greater than 1. By contrast, the standard implementation provides various optimizations
+     * for one-banded raster.
+     */
+    @Override
+    public WritableRaster createCompatibleWritableRaster(final int width, final int height) {
+        return Raster.createBandedRaster(transferType, width, height, numBands, null);
+    }
+
+    /**
+     * Creates a {@code SampleModel} with the specified width and height
+     * that has a data layout compatible with this {@code ColorModel}.
+     */
+    @Override
+    public SampleModel createCompatibleSampleModel(final int width, final int height) {
+        return new BandedSampleModel(transferType, width, height, numBands);
+    }
+
+    /**
+     * Returns {@code true} if {@code raster} is compatible with this {@code ColorModel}.
+     * This method performs the same checks than the standard implementation except for the number of bands,
+     * which is required to be equal to {@link #numBands} instead than 1. The actual checks are delegated to
+     * {@link #isCompatibleSampleModel(SampleModel)} instead than duplicated in this method.
+     */
+    @Override
+    public boolean isCompatibleRaster(final Raster raster) {
+        return isCompatibleSampleModel(raster.getSampleModel());
+    }
+
+    /**
+     * Checks if the specified {@code SampleModel} is compatible with this {@code ColorModel}.
+     * This method performs the same checks than the standard implementation except for the number
+     * of bands and for not accepting {@code MultiPixelPackedSampleModel}.
+     */
+    @Override
+    public boolean isCompatibleSampleModel(final SampleModel sm) {
+        return (sm instanceof ComponentSampleModel)                  &&
+                sm.getTransferType()                 == transferType &&
+                sm.getNumBands()                     == numBands     &&
+                (1 << sm.getSampleSize(visibleBand)) >= getMapSize();
+    }
+}
diff --git a/core/sis-raster/src/main/java/org/apache/sis/internal/raster/ScaledColorSpace.java b/core/sis-raster/src/main/java/org/apache/sis/internal/raster/ScaledColorSpace.java
new file mode 100644
index 0000000..c6d15ac
--- /dev/null
+++ b/core/sis-raster/src/main/java/org/apache/sis/internal/raster/ScaledColorSpace.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.raster;
+
+import java.awt.color.ColorSpace;
+import org.apache.sis.util.Classes;
+
+
+/**
+ * Color space for images storing pixels as real numbers. The color model can have an
+ * arbitrary number of bands, but in current implementation only one band is used.
+ * Current implementation create a gray scale.
+ *
+ * <p>The use of this color model is very slow.
+ * It should be used only when no standard color model can be used.</p>
+ *
+ * @author  Martin Desruisseaux (IRD, Geomatys)
+ * @version 1.0
+ * @since   1.0
+ * @module
+ */
+public final class ScaledColorSpace extends ColorSpace {
+    /**
+     * For cross-version compatibility.
+     */
+    private static final long serialVersionUID = 438226855772441165L;
+
+    /**
+     * Minimal normalized RGB value.
+     */
+    private static final float MIN_VALUE = 0f;
+
+    /**
+     * Maximal normalized RGB value.
+     */
+    private static final float MAX_VALUE = 1f;
+
+    /**
+     * The scaling factor from sample values to RGB normalized values.
+     */
+    private final float scale;
+
+    /**
+     * The offset to subtract from sample values before to apply the {@linkplain #scale} factor.
+     */
+    private final float offset;
+
+    /**
+     * Index of the band to display.
+     */
+    private final int visibleBand;
+
+    /**
+     * Creates a color model for the given range of values.
+     *
+     * @param  numComponents  the number of components.
+     * @param  visibleBand    the band to use for computing colors.
+     * @param  minimum        the minimal sample value expected.
+     * @param  maximum        the maximal sample value expected.
+     */
+    public ScaledColorSpace(final int numComponents, final int visibleBand, final double minimum, final double maximum) {
+        super(TYPE_GRAY, numComponents);
+        this.visibleBand = visibleBand;
+        final double scale  = (MAX_VALUE - MIN_VALUE) / (maximum - minimum);
+        this.scale  = (float) scale;
+        this.offset = (float) (minimum - MIN_VALUE / scale);
+    }
+
+    /**
+     * Returns a RGB color for a sample value.
+     *
+     * @param  samples  sample values in the raster.
+     * @return color as normalized RGB values between 0 and 1.
+     */
+    @Override
+    public float[] toRGB(final float[] samples) {
+        float value = (samples[visibleBand] - offset) * scale;
+        if (!(value >= MIN_VALUE)) {                            // Use '!' for catching NaN.
+            value = MIN_VALUE;
+        } else if (value > MAX_VALUE) {
+            value = MAX_VALUE;
+        }
+        return new float[] {value, value, value};
+    }
+
+    /**
+     * Returns a sample value for the specified RGB color.
+     *
+     * @param  color  normalized RGB values between 0 and 1.
+     * @return sample values in the raster.
+     */
+    @Override
+    public float[] fromRGB(final float[] color) {
+        final float[] values = new float[getNumComponents()];
+        values[visibleBand] = (color[0] + color[1] + color[2]) / (3 * scale) + offset;
+        return values;
+    }
+
+    /**
+     * Returns a CIEXYZ color for a sample value.
+     *
+     * @param  values  sample values in the raster.
+     * @return color as normalized CIEXYZ values between 0 and 1.
+     */
+    @Override
+    public float[] toCIEXYZ(final float[] values) {
+        final float[] codes = toRGB(values);
+        codes[0] *= 0.9642f;
+        codes[2] *= 0.8249f;
+        return codes;
+    }
+
+    /**
+     * Returns a sample value for the specified CIEXYZ color.
+     *
+     * @param  color  normalized CIEXYZ values between 0 and 1.
+     * @return sample values in the raster.
+     */
+    @Override
+    public float[] fromCIEXYZ(final float[] color) {
+        final float[] values = new float[getNumComponents()];
+        values[visibleBand] = (color[0] / 0.9642f + color[1] + color[2] / 0.8249f) / (3 * scale) + offset;
+        return values;
+    }
+
+    /**
+     * Returns the minimum value for the specified RGB component.
+     *
+     * @param  component  the component index.
+     * @return minimum normalized component value.
+     */
+    @Override
+    public float getMinValue(final int component) {
+        return MIN_VALUE / scale + offset;
+    }
+
+    /**
+     * Returns the maximum value for the specified RGB component.
+     *
+     * @param  component  the component index.
+     * @return maximum normalized component value.
+     */
+    @Override
+    public float getMaxValue(final int component) {
+        return MAX_VALUE / scale + offset;
+    }
+
+    /**
+     * Returns a string representation of this color model.
+     *
+     * @return a string representation for debugging purpose.
+     */
+    @Override
+    public String toString() {
+        return Classes.getShortClassName(this) + '[' + getMinValue(visibleBand) + " … " + getMaxValue(visibleBand) + ']';
+    }
+}
diff --git a/core/sis-raster/src/test/java/org/apache/sis/image/DefaultIteratorTest.java b/core/sis-raster/src/test/java/org/apache/sis/image/DefaultIteratorTest.java
index d916eaf..863a3d9 100644
--- a/core/sis-raster/src/test/java/org/apache/sis/image/DefaultIteratorTest.java
+++ b/core/sis-raster/src/test/java/org/apache/sis/image/DefaultIteratorTest.java
@@ -154,7 +154,7 @@ public strictfp class DefaultIteratorTest extends TestCase {
         }
         expected = new float[StrictMath.max(subMaxX - subMinX, 0) * StrictMath.max(subMaxY - subMinY, 0) * numBands];
         final WritableRaster raster = Raster.createWritableRaster(new PixelInterleavedSampleModel(dataType,
-                width, height, numBands, width * numBands, TiledImage.bandOffsets(numBands)), new Point(xmin, ymin));
+                width, height, numBands, width * numBands, TiledImageMock.bandOffsets(numBands)), new Point(xmin, ymin));
         /*
          * At this point, all data structures have been created an initialized to zero sample values.
          * Now fill the data structures with arbitrary values.
@@ -210,7 +210,7 @@ public strictfp class DefaultIteratorTest extends TestCase {
             subMaxY = StrictMath.min(ymax, subArea.y + subArea.height);
         }
         expected = new float[StrictMath.max(subMaxX - subMinX, 0) * StrictMath.max(subMaxY - subMinY, 0) * numBands];
-        final TiledImage image = new TiledImage(dataType, numBands, xmin, ymin, width, height, tileWidth, tileHeight, minTileX, minTileY);
+        final TiledImageMock image = new TiledImageMock(dataType, numBands, xmin, ymin, width, height, tileWidth, tileHeight, minTileX, minTileY);
         /*
          * At this point, all data structures have been created an initialized to zero sample values.
          * Now fill the data structures with arbitrary values. We fill them tile-by-tile.
diff --git a/core/sis-raster/src/test/java/org/apache/sis/image/ImageTestCase.java b/core/sis-raster/src/test/java/org/apache/sis/image/ImageTestCase.java
new file mode 100644
index 0000000..2216b2a
--- /dev/null
+++ b/core/sis-raster/src/test/java/org/apache/sis/image/ImageTestCase.java
@@ -0,0 +1,172 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.sis.image;
+
+import java.io.File;
+import java.io.IOException;
+import javax.imageio.IIOException;
+import javax.imageio.ImageIO;
+import java.awt.image.Raster;
+import java.awt.image.RenderedImage;
+import java.awt.image.BufferedImage;
+import java.awt.image.WritableRaster;
+import java.awt.image.ImagingOpException;
+import org.apache.sis.test.TestCase;
+import org.apache.sis.test.TestConfiguration;
+import org.junit.AfterClass;
+
+import static java.lang.StrictMath.round;
+import static org.junit.Assert.assertNotNull;
+
+
+/**
+ * Base class for tests applied on images. This base class provides a {@link #viewEnabled}
+ * field initialized to {@code false}. If this field is set to {@code true}, then calls to
+ * the {@link #showCurrentImage(String)} method will show the {@linkplain #image}.
+ *
+ * @author  Martin Desruisseaux (IRD, Geomatys)
+ * @version 1.0
+ * @since   1.0
+ * @module
+ */
+public abstract strictfp class ImageTestCase extends TestCase {
+    /**
+     * Small value for comparisons of sample values. Since most grid coverage implementations in
+     * Apache SIS store real values as {@code float} numbers, this {@code SAMPLE_TOLERANCE} value
+     * must be of the order of magnitude of {@code float} precision, not {@code double}.
+     *
+     * The current value is {@code Math.ulp(255f)}.
+     * This is okay for tests on sample values in the (-256 … 256) range where 256 is exclusive.
+     */
+    protected static final float SAMPLE_TOLERANCE = 1.5258789E-5f;
+
+    /**
+     * The image being tested, or {@code null} if not yet defined.
+     */
+    protected RenderedImage image;
+
+    /**
+     * Set to {@code true} for enabling the display of test images. The default value is {@code false},
+     * unless the {@value TestConfiguration#SHOW_WIDGET_KEY} system property has been set to {@code "true"}.
+     *
+     * @see TestConfiguration#SHOW_WIDGET_KEY
+     */
+    protected boolean viewEnabled;
+
+    /**
+     * Set to {@code true} if we have show at least one image.
+     * This is used for avoiding useless {@link TestViewer} class loading in {@link #waitForFrameDisposal()}.
+     */
+    private static volatile boolean viewUsed;
+
+    /**
+     * Creates a new test case.
+     */
+    protected ImageTestCase() {
+        viewEnabled = Boolean.getBoolean(TestConfiguration.SHOW_WIDGET_KEY);
+    }
+
+    /**
+     * Displays the current {@linkplain #image} if {@link #viewEnabled} is set to {@code true},
+     * otherwise does nothing. This method is mostly for debugging purpose.
+     *
+     * @param title the window title.
+     */
+    protected final synchronized void showCurrentImage(final String title) {
+        final RenderedImage image = this.image;
+        assertNotNull("An image must be set.", image);
+        if (viewEnabled) {
+            viewUsed = true;
+            TestViewer.show(title, image);
+        }
+    }
+
+    /**
+     * Saves the current image as a PNG image in the given file. This is sometime useful for visual
+     * check purpose, and is used only as a helper tools for tuning the test suites. Floating-point
+     * images are converted to grayscale before to be saved.
+     *
+     * @param  filename  the name (optionally with its path) of the file to create.
+     * @throws ImagingOpException if an error occurred while writing the file.
+     */
+    protected final synchronized void saveCurrentImage(final String filename) throws ImagingOpException {
+        try {
+            savePNG(image, new File(filename));
+        } catch (IOException e) {
+            throw new ImagingOpException(e.toString());
+        }
+    }
+
+    /**
+     * Implementation of {@link #saveCurrentImage(String)}, to be shared by the widget
+     * shown by {@link #showCurrentImage(String)}.
+     */
+    static void savePNG(final RenderedImage image, final File file) throws IOException {
+        assertNotNull("An image must be set.", image);
+        if (!ImageIO.write(image, "png", file)) {
+            savePNG(image.getData(), file);
+        }
+    }
+
+    /**
+     * Saves the first band of the given raster as a PNG image in the given file.
+     * This is sometime useful for visual check purpose, and is used only as a helper tools
+     * for tuning the test suites. The raster is converted to grayscale before to be saved.
+     *
+     * @param  raster  the raster to write in PNG format.
+     * @param  file    the file to create.
+     * @throws IOException if an error occurred while writing the file.
+     */
+    private static void savePNG(final Raster raster, final File file) throws IOException {
+        float min = Float.POSITIVE_INFINITY;
+        float max = Float.NEGATIVE_INFINITY;
+        final int xmin   = raster.getMinX();
+        final int ymin   = raster.getMinY();
+        final int width  = raster.getWidth();
+        final int height = raster.getHeight();
+        for (int y=0; y<height; y++) {
+            for (int x=0; x<width; x++) {
+                final float value = raster.getSampleFloat(x + xmin, y + ymin, 0);
+                if (value < min) min = value;
+                if (value > min) max = value;
+            }
+        }
+        final float scale = 255 / (max - min);
+        final BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_BYTE_GRAY);
+        final WritableRaster dest = image.getRaster();
+        for (int y=0; y<height; y++) {
+            for (int x=0; x<width; x++) {
+                final double value = raster.getSampleDouble(x + xmin, y + ymin, 0);
+                dest.setSample(x, y, 0, round((value - min) * scale));
+            }
+        }
+        if (!ImageIO.write(image, "png", file)) {
+            throw new IIOException("No suitable PNG writer found.");
+        }
+    }
+
+    /**
+     * If a frame has been created by {@link #showCurrentImage(String)},
+     * waits for its disposal before to move to the next test class.
+     */
+    @AfterClass
+    public static void waitForFrameDisposal() {
+        if (viewUsed) {
+            TestViewer.waitForFrameDisposal();
+        }
+    }
+}
diff --git a/core/sis-raster/src/test/java/org/apache/sis/image/TestViewer.java b/core/sis-raster/src/test/java/org/apache/sis/image/TestViewer.java
new file mode 100644
index 0000000..5fbdbf2
--- /dev/null
+++ b/core/sis-raster/src/test/java/org/apache/sis/image/TestViewer.java
@@ -0,0 +1,237 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.sis.image;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.concurrent.CountDownLatch;
+import java.lang.reflect.InvocationTargetException;
+import java.awt.Dimension;
+import java.awt.EventQueue;
+import java.awt.Graphics;
+import java.awt.Graphics2D;
+import java.awt.RenderingHints;
+import java.awt.event.ActionEvent;
+import java.awt.event.WindowAdapter;
+import java.awt.event.WindowEvent;
+import java.awt.geom.AffineTransform;
+import java.awt.image.RenderedImage;
+import javax.swing.JDesktopPane;
+import javax.swing.JFrame;
+import javax.swing.JInternalFrame;
+import javax.swing.JMenu;
+import javax.swing.JMenuBar;
+import javax.swing.JMenuItem;
+import javax.swing.JOptionPane;
+import javax.swing.JPanel;
+
+import static java.lang.StrictMath.*;
+
+
+/**
+ * A viewer for images being tested. This can be used for visual verification.
+ *
+ * @author  Martin Desruisseaux (IRD, Geomatys)
+ * @version 1.0
+ * @since   1.0
+ * @module
+ */
+final strictfp class TestViewer {
+    /**
+     * The global image viewer where to collect all test images.
+     */
+    private static volatile TestViewer global;
+
+    /**
+     * Shows the given image.
+     *
+     * @param  title  title of the new internal window.
+     * @param  image  image to display in the internal window.
+     */
+    static void show(final String title, final RenderedImage image) {
+        try {
+            EventQueue.invokeAndWait(() -> {
+                TestViewer viewer = global;
+                if (viewer == null) {
+                    viewer = new TestViewer("Apache SIS tests");
+                    global = viewer;
+                }
+                viewer.addImage(image, String.valueOf(title));
+            });
+        } catch (InterruptedException | InvocationTargetException e) {
+            throw new AssertionError(e);
+        }
+    }
+
+    /**
+     * A lock used for waiting that at least one frame has been closed.
+     */
+    private final CountDownLatch lock;
+
+    /**
+     * The frame showing the images.
+     */
+    private final JFrame frame;
+
+    /**
+     * The desktop pane where to show each images.
+     */
+    private final JDesktopPane desktop;
+
+    /**
+     * The location of the next internal frame to create.
+     */
+    private int location;
+
+    /**
+     * Creates a new viewer and show it immediately.
+     */
+    private TestViewer(final String title) {
+        lock = new CountDownLatch(1);
+        frame = new JFrame(title);
+        frame.setDefaultCloseOperation(JFrame.DISPOSE_ON_CLOSE);
+        frame.addWindowListener(new WindowAdapter() {
+            @Override public void windowClosed(WindowEvent e) {
+                frame.removeWindowListener(this);
+                lock.countDown();
+                frame.dispose();
+            }
+        });
+        desktop = new JDesktopPane();
+        desktop.setSize(800, 600);
+        final JMenuBar menuBar = new JMenuBar();
+        {
+            final JMenu menu = new JMenu("File");
+            if (true) {
+                final JMenuItem item = new JMenuItem("Save as PNG");
+                item.addActionListener((ActionEvent e) -> savePNG());
+                menu.add(item);
+            }
+            menuBar.add(menu);
+        }
+        frame.setJMenuBar(menuBar);
+        frame.add(desktop);
+        frame.pack();
+        frame.setVisible(true);
+    }
+
+    /**
+     * Creates and shows a new internal frame for the given image.
+     */
+    private void addImage(final RenderedImage image, final String title) {
+        final JInternalFrame internal = new JInternalFrame(title, true, true);
+        internal.add(new ImagePanel(image));
+        internal.pack();
+        internal.show();
+        desktop.add(internal);
+        if (location > min(desktop.getWidth()  - internal.getWidth(),
+                           desktop.getHeight() - internal.getHeight()))
+        {
+            location = 0;
+        }
+        internal.setLocation(location, location);
+        location += 30;
+        internal.toFront();
+    }
+
+
+    /**
+     * A panel showing an image. Created by {@link #addImage(RenderedImage, String)}.
+     */
+    @SuppressWarnings("serial")
+    private static final strictfp class ImagePanel extends JPanel {
+        /** The image to show. */
+        private final RenderedImage image;
+
+        /** Creates a viewer for the given image. */
+        ImagePanel(final RenderedImage image) {
+            this.image = image;
+            setPreferredSize(new Dimension(max(300, image.getWidth()), max(30, image.getHeight())));
+        }
+
+        /** Paints the image. */
+        @Override public void paint(final Graphics graphics) {
+            super.paint(graphics);
+            final Graphics2D gr = (Graphics2D) graphics;
+            gr.setRenderingHint(RenderingHints.KEY_INTERPOLATION,
+                                RenderingHints.VALUE_INTERPOLATION_NEAREST_NEIGHBOR);
+            final double width  = image.getWidth();
+            final double height = image.getHeight();
+            final double scale  = min(getWidth() / width, getHeight() / height);
+            final AffineTransform gridToPanel = new AffineTransform(
+                    scale, 0, 0, scale,
+                    0.5*(getWidth()  - scale*width),
+                    0.5*(getHeight() - scale*height));
+            gr.drawRenderedImage(image, gridToPanel);
+        }
+    }
+
+    /**
+     * Returns the image of the currently selected frame.
+     */
+    private RenderedImage getSelectedImage() {
+        final JInternalFrame frame = desktop.getSelectedFrame();
+        if (frame != null) {
+            return ((ImagePanel) frame.getContentPane().getComponent(0)).image;
+        }
+        return null;
+    }
+
+    /**
+     * Saves the image of the currently selected frame.
+     */
+    private void savePNG() {
+        final RenderedImage image = getSelectedImage();
+        if (image != null) {
+            final File file = new File(System.getProperty("user.home"), "ImageTest.png");
+            final String title, message;
+            final int type;
+            if (file.exists()) {
+                type    = JOptionPane.WARNING_MESSAGE;
+                title   = "Confirm overwrite";
+                message = "File " + file + " exists. Overwrite?";
+            } else {
+                type    = JOptionPane.QUESTION_MESSAGE;
+                title   = "Confirm write";
+                message = "Save in " + file + '?';
+            }
+            if (JOptionPane.showInternalConfirmDialog(desktop, message, title,
+                    JOptionPane.YES_NO_OPTION, type) == JOptionPane.OK_OPTION)
+            {
+                try {
+                    ImageTestCase.savePNG(image, file);
+                } catch (IOException e) {
+                    JOptionPane.showInternalMessageDialog(desktop, e.toString(),
+                            "Error", JOptionPane.WARNING_MESSAGE);
+                }
+            }
+        }
+    }
+
+    /**
+     * Waits for the frame disposal.
+     */
+    static void waitForFrameDisposal() {
+        final TestViewer viewer = global;
+        if (viewer != null) try {
+            viewer.lock.await();
+            global = null;
+        } catch (InterruptedException e) {
+            // It is okay to continue. JUnit will close all windows.
+        }
+    }
+}
diff --git a/core/sis-raster/src/test/java/org/apache/sis/image/TiledImage.java b/core/sis-raster/src/test/java/org/apache/sis/image/TiledImageMock.java
similarity index 98%
rename from core/sis-raster/src/test/java/org/apache/sis/image/TiledImage.java
rename to core/sis-raster/src/test/java/org/apache/sis/image/TiledImageMock.java
index 23d398b..1744867 100644
--- a/core/sis-raster/src/test/java/org/apache/sis/image/TiledImage.java
+++ b/core/sis-raster/src/test/java/org/apache/sis/image/TiledImageMock.java
@@ -43,7 +43,7 @@ import static org.junit.Assert.*;
  * @since   0.8
  * @module
  */
-final class TiledImage implements WritableRenderedImage {
+final class TiledImageMock implements WritableRenderedImage {
     /**
      * The minimum X or Y coordinate (inclusive) of the rendered image.
      */
@@ -96,7 +96,7 @@ final class TiledImage implements WritableRenderedImage {
      * @param dataType  sample data type as one of the {@link java.awt.image.DataBuffer} constants.
      * @param numBands  number of bands in the sample model to create.
      */
-    TiledImage(final int dataType,  final int numBands,
+    TiledImageMock(final int dataType,  final int numBands,
                final int minX,      final int minY,
                final int width,     final int height,
                final int tileWidth, final int tileHeight,
diff --git a/core/sis-raster/src/test/java/org/apache/sis/internal/raster/ScaledColorSpaceTest.java b/core/sis-raster/src/test/java/org/apache/sis/internal/raster/ScaledColorSpaceTest.java
new file mode 100644
index 0000000..66204ab
--- /dev/null
+++ b/core/sis-raster/src/test/java/org/apache/sis/internal/raster/ScaledColorSpaceTest.java
@@ -0,0 +1,104 @@
+/*
+ * 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.raster;
+
+import java.util.Random;
+import java.awt.Transparency;
+import java.awt.image.BufferedImage;
+import java.awt.image.ColorModel;
+import java.awt.image.ComponentColorModel;
+import java.awt.image.DataBuffer;
+import java.awt.image.WritableRaster;
+import org.apache.sis.image.ImageTestCase;
+import org.apache.sis.test.TestUtilities;
+import org.junit.Test;
+
+import static org.junit.Assert.*;
+import static java.lang.StrictMath.*;
+
+
+/**
+ * Tests the {@link ScaledColorSpace} implementation.
+ * This class contains a visual test which can be run by a main method.
+ *
+ * @author  Martin Desruisseaux (IRD, Geomatys)
+ * @version 1.0
+ * @since   1.0
+ * @module
+ */
+public final strictfp class ScaledColorSpaceTest extends ImageTestCase {
+    /**
+     * The minimal and maximal values to renderer.
+     */
+    private final double minimum, maximum;
+
+    /**
+     * The scaled color space to test.
+     */
+    private final ScaledColorSpace colors;
+
+    /**
+     * Sets up common objects used for all tests.
+     */
+    public ScaledColorSpaceTest() {
+        final Random random = TestUtilities.createRandomNumberGenerator();
+        minimum = random.nextDouble()*25;
+        maximum = random.nextDouble()*80 + minimum + 10;          // Shall be less than 256 for compliance with SAMPLE_TOLERANCE.
+        colors  = new ScaledColorSpace(1, 0, minimum, maximum);
+    }
+
+    /**
+     * Tests the color space.
+     */
+    @Test
+    public void testColorSpace() {
+        assertEquals(minimum, colors.getMinValue(0), SAMPLE_TOLERANCE);
+        assertEquals(maximum, colors.getMaxValue(0), SAMPLE_TOLERANCE);
+
+        final float[] array = new float[1];
+        final double step = (maximum - minimum) / 256;
+        for (double x=minimum; x<maximum; x+=step) {
+            array[0] = (float) x;
+            assertEquals(x, colors.fromRGB(colors.toRGB(array))[0], 2*SAMPLE_TOLERANCE);
+        }
+    }
+
+    /**
+     * Shows an image using the scaled color model.
+     * The image appears only if {@link #viewEnabled} is {@code true}.
+     */
+    @Test
+    public void view() {
+        if (viewEnabled) {
+            final int transparency = Transparency.OPAQUE;
+            final int datatype     = DataBuffer.TYPE_FLOAT;
+            final ColorModel model = new ComponentColorModel(colors, false, false, transparency, datatype);
+            final WritableRaster data = model.createCompatibleWritableRaster(200, 200);
+            image = new BufferedImage(model, data, false, null);
+            final int width  = data.getWidth();
+            final int height = data.getHeight();
+            for (int x=width; --x>=0;) {
+                for (int y=height; --y>=0;) {
+                    double v = hypot(((double) x) / width - 0.5, ((double) y) / height - 0.5);
+                    v = v*(maximum - minimum) + minimum;
+                    data.setSample(x, y, 0, v);
+                }
+            }
+            showCurrentImage("ScaledColorSpace");
+        }
+    }
+}
diff --git a/core/sis-raster/src/test/java/org/apache/sis/test/suite/RasterTestSuite.java b/core/sis-raster/src/test/java/org/apache/sis/test/suite/RasterTestSuite.java
index 5b90307..2da0f9f 100644
--- a/core/sis-raster/src/test/java/org/apache/sis/test/suite/RasterTestSuite.java
+++ b/core/sis-raster/src/test/java/org/apache/sis/test/suite/RasterTestSuite.java
@@ -36,7 +36,8 @@ import org.junit.BeforeClass;
     org.apache.sis.coverage.grid.GridGeometryTest.class,
     org.apache.sis.coverage.CategoryTest.class,
     org.apache.sis.coverage.CategoryListTest.class,
-    org.apache.sis.coverage.SampleDimensionTest.class
+    org.apache.sis.coverage.SampleDimensionTest.class,
+    org.apache.sis.internal.raster.ScaledColorSpaceTest.class
 })
 public final strictfp class RasterTestSuite extends TestSuite {
     /**
diff --git a/core/sis-utility/src/test/java/org/apache/sis/test/TestConfiguration.java b/core/sis-utility/src/test/java/org/apache/sis/test/TestConfiguration.java
index 345e274..f81ce13 100644
--- a/core/sis-utility/src/test/java/org/apache/sis/test/TestConfiguration.java
+++ b/core/sis-utility/src/test/java/org/apache/sis/test/TestConfiguration.java
@@ -23,7 +23,7 @@ import org.apache.sis.util.Static;
  * Information about the configuration of tests
  *
  * @author  Martin Desruisseaux (Geomatys)
- * @version 0.7
+ * @version 1.0
  * @since   0.3
  * @module
  */
@@ -43,6 +43,11 @@ public final strictfp class TestConfiguration extends Static {
     public static final String VERBOSE_OUTPUT_KEY = "org.apache.sis.test.verbose";
 
     /**
+     * The {@value} system property for enabling display of test images or widgets.
+     */
+    public static final String SHOW_WIDGET_KEY = "org.apache.sis.test.gui.show";
+
+    /**
      * The {@value} system property for setting the output encoding.
      * This property is used only if the {@link #VERBOSE_OUTPUT_KEY} property
      * is set to "{@code true}". If this property is not set, then the system
diff --git a/storage/sis-netcdf/src/main/java/org/apache/sis/storage/netcdf/Image.java b/storage/sis-netcdf/src/main/java/org/apache/sis/storage/netcdf/Image.java
index 027734d..d539b12 100644
--- a/storage/sis-netcdf/src/main/java/org/apache/sis/storage/netcdf/Image.java
+++ b/storage/sis-netcdf/src/main/java/org/apache/sis/storage/netcdf/Image.java
@@ -16,6 +16,7 @@
  */
 package org.apache.sis.storage.netcdf;
 
+import java.awt.Color;
 import java.util.List;
 import java.awt.image.DataBuffer;
 import java.awt.image.ColorModel;
@@ -40,6 +41,11 @@ import org.apache.sis.internal.raster.ColorModelFactory;
  */
 final class Image extends GridCoverage {
     /**
+     * Index of the band to show in rendered image.
+     */
+    private static final int VISIBLE_BAND = 0;
+
+    /**
      * The sample values.
      */
     private final DataBuffer data;
@@ -69,7 +75,8 @@ final class Image extends GridCoverage {
         final int width  = Math.toIntExact(extent.getSize(xAxis));
         final int height = Math.toIntExact(extent.getSize(yAxis));
         final WritableRaster raster = RasterFactory.createBandedRaster(data, width, height, width, null, null, null);
-        final ColorModel colors = ColorModelFactory.create(data.getDataType());
+        final ColorModel colors = ColorModelFactory.createColorModel(getSampleDimensions(), VISIBLE_BAND, data.getDataType(),
+                (category) -> category.isQuantitative() ? new Color[] {Color.BLACK, Color.WHITE} : null);
         return new BufferedImage(colors, raster, false, null);
     }
 }