sis-commits mailing list archives

Site index · List index
Message view « Date » · « Thread »
Top « Date » · « Thread »
From desruisse...@apache.org
Subject [sis] branch geoapi-4.0 updated: Ported ColorModelFactory from geotk. The previous skeleton was insufficient for netCDF reader needs.
Date Mon, 10 Dec 2018 20:08:48 GMT
This is an automated email from the ASF dual-hosted git repository.

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


The following commit(s) were added to refs/heads/geoapi-4.0 by this push:
     new 4348077  Ported ColorModelFactory from geotk. The previous skeleton was insufficient for netCDF reader needs.
4348077 is described below

commit 43480776aa1ce414b04d43364b6c738e055af787
Author: Martin Desruisseaux <martin.desruisseaux@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..8212cc4 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  = pieceStarts[i  ];
+            final int   upper  = 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);
     }
 }


Mime
View raw message