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 20:08:48 UTC
[sis] branch geoapi-4.0 updated: Ported ColorModelFactory from
geotk. The previous skeleton was insufficient for netCDF reader needs.
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 <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..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);
}
}