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: First draft of a `Visualization` image which combine the work of "resample" and "visualize" methods. The intent is to avoid to create an intermediate image when a resampling is done only for visualization.
Date Tue, 04 Aug 2020 18:07:26 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 9af55b6  First draft of a `Visualization` image which combine the work of "resample" and "visualize" methods. The intent is to avoid to create an intermediate image when a resampling is done only for visualization.
9af55b6 is described below

commit 9af55b63992a630ec3537e6aa570544e74da3172
Author: Martin Desruisseaux <martin.desruisseaux@geomatys.com>
AuthorDate: Tue Aug 4 20:06:07 2020 +0200

    First draft of a `Visualization` image which combine the work of "resample" and "visualize" methods.
    The intent is to avoid to create an intermediate image when a resampling is done only for visualization.
---
 .../apache/sis/gui/coverage/CoverageCanvas.java    |   5 +-
 .../sis/gui/coverage/ImagePropertyExplorer.java    |   6 +-
 .../org/apache/sis/gui/coverage/RenderingData.java |  32 +--
 .../apache/sis/internal/gui/ImageConverter.java    |   2 +-
 .../apache/sis/image/BandedSampleConverter.java    |   2 +-
 .../java/org/apache/sis/image/ComputedImage.java   |   9 +-
 .../java/org/apache/sis/image/ImageProcessor.java  |  74 ++++-
 .../java/org/apache/sis/image/RecoloredImage.java  | 258 ++++++-----------
 .../java/org/apache/sis/image/Visualization.java   | 308 +++++++++++++++++++++
 .../sis/internal/coverage/CompoundTransform.java   | 213 ++++++++++++++
 .../internal/coverage/CompoundTransformOf1D.java   | 173 ++++++++++++
 .../sis/internal/coverage/RepeatedTransform.java   | 161 +++++++++++
 .../sis/internal/coverage/j2d/Colorizer.java       |  36 +--
 .../sis/internal/coverage/j2d/ImageLayout.java     |   9 +-
 .../sis/internal/coverage/j2d/ColorizerTest.java   |   6 -
 15 files changed, 1038 insertions(+), 256 deletions(-)

diff --git a/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/CoverageCanvas.java b/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/CoverageCanvas.java
index 2e3fa64..6dd0398 100644
--- a/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/CoverageCanvas.java
+++ b/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/CoverageCanvas.java
@@ -446,7 +446,7 @@ public class CoverageCanvas extends MapCanvasAWT {
         private final LinearTransform objectiveToDisplay;
 
         /**
-         * Whether the {@linkplain RenderingData#resample resampling operation} applied is different
+         * Whether the {@linkplain RenderingData#resampleAndRecolor resampling operation} applied is different
          * than the one used the last time that the image has been rendered, ignoring translations.
          * Translations do not require new resampling operations because we can manage translations
          * by changing {@link RenderedImage} coordinates.
@@ -531,9 +531,8 @@ public class CoverageCanvas extends MapCanvasAWT {
                             ~(AffineTransform.TYPE_IDENTITY | AffineTransform.TYPE_TRANSLATION)) != 0;
                 }
                 if (resamplingChanged) {
-                    final RenderedImage resampledImage = data.resample(objectiveCRS, objectiveToDisplay);
+                    recoloredImage = data.resampleAndRecolor(objectiveCRS, objectiveToDisplay);
                     resampledToDisplay = data.getTransform(objectiveToDisplay);
-                    recoloredImage = data.recolor(resampledImage);
                 }
                 prefetchedImage = data.prefetch(recoloredImage, resampledToDisplay, displayBounds);
             } finally {
diff --git a/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/ImagePropertyExplorer.java b/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/ImagePropertyExplorer.java
index 9fa669c..07dd06d 100644
--- a/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/ImagePropertyExplorer.java
+++ b/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/ImagePropertyExplorer.java
@@ -282,7 +282,7 @@ public class ImagePropertyExplorer extends Widget {
      */
     private static final class PropertyRow extends ImmutableObjectProperty<String> {
         /**
-         * Image image property.
+         * Image property value.
          */
         final ObjectProperty<Object> value;
 
@@ -522,7 +522,7 @@ public class ImagePropertyExplorer extends Widget {
 
     /**
      * Refresh all visual components except the tree of sources. This includes the table of
-     * image layout, the table of property values and the details of selected property value.
+     * image layouts, the table of property values and the details of selected property value.
      */
     private void refreshTables() {
         imageSelected(getSelectedImage());
@@ -530,7 +530,7 @@ public class ImagePropertyExplorer extends Widget {
 
     /**
      * Invoked when an image is selected in the tree of image sources. The selected image
-     * is not necessarily {@link #image} property value; it may be one if its sources.
+     * is not necessarily {@link #image} property value; it may be one of its sources.
      * If no image is explicitly selected, defaults to the root image.
      */
     private void imageSelected(final RenderedImage selected) {
diff --git a/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/RenderingData.java b/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/RenderingData.java
index 44e0cb4..49502f9 100644
--- a/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/RenderingData.java
+++ b/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/RenderingData.java
@@ -227,10 +227,13 @@ final class RenderingData implements Cloneable {
     }
 
     /**
-     * Creates the resampled image. This method will compute the {@link MathTransform} steps from image
-     * coordinate system to display coordinate system if those steps have not already been computed.
+     * Creates the resampled image, then optionally stretches the color map and applies an index color model.
+     * This method will compute the {@link MathTransform} steps from image coordinate system to display coordinate
+     * system if those steps have not already been computed.
+     *
+     * @return image with operation applied and color ramp stretched.
      */
-    final RenderedImage resample(final CoordinateReferenceSystem objectiveCRS,
+    final RenderedImage resampleAndRecolor(final CoordinateReferenceSystem objectiveCRS,
             final LinearTransform objectiveToDisplay) throws TransformException
     {
         if (changeOfCRS == null && objectiveCRS != null && dataGeometry.isDefined(GridGeometry.CRS)) {
@@ -279,16 +282,11 @@ final class RenderingData implements Cloneable {
         final PreferredSize bounds = (PreferredSize) Shapes2D.transform(
                 MathTransforms.bidimensional(cornerToDisplay),
                 ImageUtilities.getBounds(data), new PreferredSize());
-        return processor.resample(data, bounds, displayToCenter);
-    }
-
-    /**
-     * Optionally stretches the color map, then optionally applies an index color model.
-     *
-     * @param  resampledImage  the image computed by {@link #resample(CoordinateReferenceSystem, LinearTransform)}.
-     * @return image with operation applied and color ramp stretched. May be the same instance than given image.
-     */
-    final RenderedImage recolor(RenderedImage resampledImage) {
+        /*
+         * Apply a map projection on the image, then convert the result to an index color model.
+         */
+        RenderedImage resampledImage;
+        resampledImage = processor.resample(data, bounds, displayToCenter);
         if (selectedDerivative != Stretching.NONE) {
             final Map<String,Object> modifiers = new HashMap<>(4);
             /*
@@ -326,20 +324,20 @@ final class RenderingData implements Cloneable {
      * Computes immediately, possibly using many threads, the tiles that are going to be displayed.
      * The returned instance should be used only for current rendering event; it should not be cached.
      *
-     * @param  recoloredImage      the image computed by {@link #recolor(RenderedImage)}.
+     * @param  resampledImage      the image computed by {@link #resampleAndRecolor resampleAndRecolor(…)}.
      * @param  resampledToDisplay  the transform computed by {@link #getTransform(LinearTransform)}.
      * @param  displayBounds       size and location of the display device, in pixel units.
      * @return a temporary image with tiles intersecting the display region already computed.
      */
-    final RenderedImage prefetch(final RenderedImage recoloredImage, final AffineTransform resampledToDisplay,
+    final RenderedImage prefetch(final RenderedImage resampledImage, final AffineTransform resampledToDisplay,
                                  final Envelope2D displayBounds)
     {
         try {
-            return processor.prefetch(recoloredImage, (Rectangle) AffineTransforms2D.transform(
+            return processor.prefetch(resampledImage, (Rectangle) AffineTransforms2D.transform(
                         resampledToDisplay.createInverse(), displayBounds, new Rectangle()));
         } catch (NoninvertibleTransformException e) {
             recoverableException(e);
-            return recoloredImage;
+            return resampledImage;
         }
     }
 
diff --git a/application/sis-javafx/src/main/java/org/apache/sis/internal/gui/ImageConverter.java b/application/sis-javafx/src/main/java/org/apache/sis/internal/gui/ImageConverter.java
index 28fceff..af49353 100644
--- a/application/sis-javafx/src/main/java/org/apache/sis/internal/gui/ImageConverter.java
+++ b/application/sis-javafx/src/main/java/org/apache/sis/internal/gui/ImageConverter.java
@@ -144,7 +144,7 @@ final class ImageConverter extends Task<Statistics[]> {
     }
 
     /**
-     * If there is a mask that we can apply on the image, returns that mask. Otherwise returns 0.
+     * If there is a mask that we can apply on the image, returns that mask. Otherwise returns {@code null}.
      * Current implementation returns the mask as a transparent yellow image.
      */
     private RenderedImage getMask(final ImageProcessor processor) {
diff --git a/core/sis-feature/src/main/java/org/apache/sis/image/BandedSampleConverter.java b/core/sis-feature/src/main/java/org/apache/sis/image/BandedSampleConverter.java
index 9fcea1a..d95cc36 100644
--- a/core/sis-feature/src/main/java/org/apache/sis/image/BandedSampleConverter.java
+++ b/core/sis-feature/src/main/java/org/apache/sis/image/BandedSampleConverter.java
@@ -197,7 +197,7 @@ class BandedSampleConverter extends ComputedImage {
             source = ((RecoloredImage) source).source;
         }
         final int numBands = converters.length;
-        final BandedSampleModel sampleModel = layout.createBandedSampleModel(targetType, numBands, source);
+        final BandedSampleModel sampleModel = layout.createBandedSampleModel(targetType, numBands, source, null);
         /*
          * If the source image is writable, then changes in the converted image may be retro-propagated
          * to that source image. If we fail to compute the required inverse transforms, log a notice at
diff --git a/core/sis-feature/src/main/java/org/apache/sis/image/ComputedImage.java b/core/sis-feature/src/main/java/org/apache/sis/image/ComputedImage.java
index d64193b..4d32f7d 100644
--- a/core/sis-feature/src/main/java/org/apache/sis/image/ComputedImage.java
+++ b/core/sis-feature/src/main/java/org/apache/sis/image/ComputedImage.java
@@ -374,7 +374,7 @@ public abstract class ComputedImage extends PlanarImage implements Disposable {
             int min;
             ArgumentChecks.ensureBetween("tileX", (min = getMinTileX()), min + getNumXTiles() - 1, tileX);
             ArgumentChecks.ensureBetween("tileY", (min = getMinTileY()), min + getNumYTiles() - 1, tileY);
-            Exception error = null;
+            Throwable error = null;
             final Cache.Handler<Raster> handler = cache.lock(key);
             try {
                 tile = handler.peek();
@@ -395,11 +395,10 @@ public abstract class ComputedImage extends PlanarImage implements Disposable {
                 handler.putAndUnlock(tile);     // Must be invoked even if an exception occurred.
             }
             if (tile == null) {                 // Null in case of exception or if `computeTile(…)` returned null.
-                if (error instanceof ImagingOpException) {
-                    throw (ImagingOpException) error;
-                } else {
-                    throw (ImagingOpException) new ImagingOpException(key.error(Resources.Keys.CanNotComputeTile_2)).initCause(error);
+                if (!(error instanceof ImagingOpException)) {
+                    error = new ImagingOpException(key.error(Resources.Keys.CanNotComputeTile_2)).initCause(error);
                 }
+                throw (ImagingOpException) error;
             }
         }
         return tile;
diff --git a/core/sis-feature/src/main/java/org/apache/sis/image/ImageProcessor.java b/core/sis-feature/src/main/java/org/apache/sis/image/ImageProcessor.java
index e279937..34d2278 100644
--- a/core/sis-feature/src/main/java/org/apache/sis/image/ImageProcessor.java
+++ b/core/sis-feature/src/main/java/org/apache/sis/image/ImageProcessor.java
@@ -40,7 +40,6 @@ import org.opengis.referencing.operation.MathTransform1D;
 import org.opengis.referencing.operation.NoninvertibleTransformException;
 import org.apache.sis.referencing.operation.transform.MathTransforms;
 import org.apache.sis.coverage.SampleDimension;
-import org.apache.sis.internal.coverage.j2d.Colorizer;
 import org.apache.sis.internal.coverage.j2d.ImageLayout;
 import org.apache.sis.internal.coverage.j2d.ImageUtilities;
 import org.apache.sis.math.Statistics;
@@ -611,9 +610,9 @@ public class ImageProcessor implements Cloneable {
      * </table>
      *
      * <h4>Limitation</h4>
-     * Current implementation can stretch only gray scale images (it may be extended to indexed color models
-     * in a future version). If this method can not stretch the color ramp, for example because the given image
-     * is an RGB image, then the image is returned unchanged.
+     * Current implementation can stretch only gray scale images (a future version may extend support to images
+     * using {@linkplain java.awt.image.IndexColorModel index color models}). If this method can not stretch the
+     * color ramp, for example because the given image is an RGB image, then the image is returned unchanged.
      *
      * @param  source     the image to recolor.
      * @param  modifiers  modifiers for narrowing the range of values, or {@code null} if none.
@@ -809,7 +808,7 @@ public class ImageProcessor implements Cloneable {
         ArgumentChecks.ensureNonNull("source", source);
         ArgumentChecks.ensureNonNull("colors", colors);
         try {
-            return RecoloredImage.toIndexedColors(this, source, null, null, colors.entrySet());
+            return Visualization.create(this, null, source, null, null, colors.entrySet());
         } catch (IllegalStateException | NoninvertibleTransformException e) {
             throw new IllegalArgumentException(Resources.format(Resources.Keys.UnconvertibleSampleValues), e);
         }
@@ -843,21 +842,72 @@ public class ImageProcessor implements Cloneable {
      */
     public RenderedImage visualize(final RenderedImage source, final List<SampleDimension> ranges) {
         ArgumentChecks.ensureNonNull("source", source);
-        Function<Category,Color[]> colors;
-        synchronized (this) {
-            colors = this.colors;
-        }
-        if (colors == null) {
-            colors = Colorizer.GRAYSCALE;
+        try {
+            return Visualization.create(this, null, source, null, ranges, null);
+        } catch (IllegalStateException | NoninvertibleTransformException e) {
+            throw new IllegalArgumentException(Resources.format(Resources.Keys.UnconvertibleSampleValues), e);
         }
+    }
+
+    /**
+     * Returns an image as the resampling of the given image followed by a conversion to integer sample values.
+     * This is a combination of the following methods, as a single image operation for avoiding creation of an
+     * intermediate image step:
+     *
+     * <ol>
+     *   <li><code>{@linkplain #resample(RenderedImage, Rectangle, MathTransform) resample}(source, bounds, toSource)</code></li>
+     *   <li><code>{@linkplain #visualize(RenderedImage, List) visualize}(resampled, ranges)</code></li>
+     * </ol>
+     *
+     * The resulting image is suitable for visualization purposes, but should not be used for computation
+     * purposes. There is no guarantees about the number of bands in returned image and the formulas used
+     * for converting floating point values to integer values.
+     *
+     * @param  source    the image to be resampled and recolored.
+     * @param  bounds    domain of pixel coordinates of resampled image to create.
+     * @param  toSource  conversion of pixel coordinates from resampled image to {@code source} image.
+     * @param  ranges    description of {@code source} bands, or {@code null} if none. This is typically
+     *                   obtained by {@link org.apache.sis.coverage.grid.GridCoverage#getSampleDimensions()}.
+     * @return resampled and recolored image for visualization purposes only.
+     */
+    public RenderedImage visualize(final RenderedImage source, final Rectangle bounds, final MathTransform toSource,
+                                   final List<SampleDimension> ranges)
+    {
+        ArgumentChecks.ensureNonNull("source",   source);
+        ArgumentChecks.ensureNonNull("bounds",   bounds);
+        ArgumentChecks.ensureNonNull("toSource", toSource);
         try {
-            return RecoloredImage.toIndexedColors(this, source, ranges, colors, null);
+            return Visualization.create(this, bounds, source, toSource, ranges, null);
         } catch (IllegalStateException | NoninvertibleTransformException e) {
             throw new IllegalArgumentException(Resources.format(Resources.Keys.UnconvertibleSampleValues), e);
         }
     }
 
     /**
+     * Callback method for {@link Visualization}.
+     *
+     * @param  source      image to be resampled and converted.
+     * @param  toSource    conversion of pixel coordinates of this image to pixel coordinates of {@code source} image.
+     * @param  converters  transfer functions to apply on each band of the source image. This array is not cloned.
+     * @param  bounds      domain of pixel coordinates of this image, or {@code null} if same as {@code source} image.
+     * @param  colorModel  color model of the image to create.
+     */
+    final RenderedImage resampleAndConvert(final RenderedImage source, final MathTransform toSource,
+            final MathTransform1D[] converters, final Rectangle bounds, final ColorModel colorModel)
+    {
+        final Interpolation interpolation;
+        final Number[]      fillValues;
+        final Quantity<?>[] positionalAccuracyHints;
+        synchronized (this) {
+            interpolation           = this.interpolation;
+            fillValues              = this.fillValues;
+            positionalAccuracyHints = this.positionalAccuracyHints;
+        }
+        return unique(new Visualization(source, ImageLayout.DEFAULT, bounds, toSource, toSource.isIdentity(),
+                      interpolation, converters, fillValues, colorModel, positionalAccuracyHints));
+    }
+
+    /**
      * Returns {@code true} if the given object is an image processor
      * of the same class with the same configuration.
      *
diff --git a/core/sis-feature/src/main/java/org/apache/sis/image/RecoloredImage.java b/core/sis-feature/src/main/java/org/apache/sis/image/RecoloredImage.java
index 53f2ff6..bb5aafd 100644
--- a/core/sis-feature/src/main/java/org/apache/sis/image/RecoloredImage.java
+++ b/core/sis-feature/src/main/java/org/apache/sis/image/RecoloredImage.java
@@ -17,27 +17,15 @@
 package org.apache.sis.image;
 
 import java.util.Map;
-import java.util.List;
-import java.util.Collection;
-import java.util.function.Function;
 import java.awt.Shape;
-import java.awt.Color;
 import java.awt.image.ColorModel;
 import java.awt.image.IndexColorModel;
 import java.awt.image.SampleModel;
 import java.awt.image.RenderedImage;
-import java.awt.image.DataBuffer;
-import org.opengis.referencing.operation.MathTransform1D;
-import org.opengis.referencing.operation.NoninvertibleTransformException;
-import org.apache.sis.coverage.Category;
-import org.apache.sis.coverage.SampleDimension;
 import org.apache.sis.internal.coverage.j2d.ColorModelFactory;
-import org.apache.sis.internal.coverage.j2d.Colorizer;
 import org.apache.sis.internal.coverage.j2d.ImageUtilities;
-import org.apache.sis.internal.feature.Resources;
 import org.apache.sis.util.resources.Errors;
 import org.apache.sis.util.ArgumentChecks;
-import org.apache.sis.measure.NumberRange;
 import org.apache.sis.math.Statistics;
 
 
@@ -93,9 +81,9 @@ final class RecoloredImage extends ImageAdapter {
      * between specified or inferred bounds. The mapping applied by this method is conceptually a linear
      * transform applied on sample values before they are mapped to their colors.
      *
-     * <p>Current implementation can stretch only gray scale images (it may be extended to indexed color models
-     * in a future version). If this method can not stretch the color ramp, for example because the given image
-     * is an RGB image, then the image is returned unchanged.</p>
+     * <p>Current implementation can stretch only gray scale images (a future version may extend support to images
+     * using {@linkplain IndexColorModel index color models}). If this method can not stretch the color ramp,
+     * for example because the given image is an RGB image, then the image is returned unchanged.</p>
      *
      * @param  processor  the processor to use for computing statistics if needed.
      * @param  source     the image to recolor (can be {@code null}).
@@ -105,177 +93,99 @@ final class RecoloredImage extends ImageAdapter {
      *
      * @see ImageProcessor#stretchColorRamp(RenderedImage, Map)
      */
-    static RenderedImage stretchColorRamp(final ImageProcessor processor, final RenderedImage source, final Map<String,?> modifiers) {
-        final int visibleBand = ImageUtilities.getVisibleBand(source);
-        if (visibleBand >= 0) {
-            RenderedImage statsSource   = source;
-            Statistics[]  statsAllBands = null;
-            Statistics    statistics    = null;
-            double        minimum       = Double.NaN;
-            double        maximum       = Double.NaN;
-            double        deviations    = Double.POSITIVE_INFINITY;
-            /*
-             * Extract and validate parameter values.
-             * No calculation started at this stage.
-             */
-            if (modifiers != null) {
-                final Object minValue = modifiers.get("minimum");
-                if (minValue instanceof Number) {
-                    minimum = ((Number) minValue).doubleValue();
-                }
-                final Object maxValue = modifiers.get("maximum");
-                if (maxValue instanceof Number) {
-                    maximum = ((Number) maxValue).doubleValue();
-                }
-                if (minimum >= maximum) {
-                    throw new IllegalArgumentException(Errors.format(Errors.Keys.IllegalRange_2, minValue, maxValue));
-                }
-                Object value = modifiers.get("multStdDev");
-                if (value instanceof Number) {
-                    deviations = ((Number) value).doubleValue();
-                    ArgumentChecks.ensureStrictlyPositive("multStdDev", deviations);
-                }
-                value = modifiers.get("statistics");
-                if (value instanceof RenderedImage) {
-                    statsSource = (RenderedImage) value;
-                } else if (value instanceof Statistics) {
-                    statistics = (Statistics) value;
-                } else if (value instanceof Statistics[]) {
-                    statsAllBands = (Statistics[]) value;
-                }
-            }
-            /*
-             * If minimum and maximum values were not explicitly specified,
-             * compute them from statistics.
-             */
-            if (Double.isNaN(minimum) || Double.isNaN(maximum)) {
-                if (statistics == null) {
-                    if (statsAllBands == null) {
-                        final Object areaOfInterest = modifiers.get("areaOfInterest");
-                        statsAllBands = processor.getStatistics(statsSource,
-                                (areaOfInterest instanceof Shape) ? (Shape) areaOfInterest : null);
-                    }
-                    if (statsAllBands != null && visibleBand < statsAllBands.length) {
-                        statistics = statsAllBands[visibleBand];
-                    }
-                }
-                if (statistics != null) {
-                    deviations *= statistics.standardDeviation(true);
-                    final double mean = statistics.mean();
-                    if (Double.isNaN(minimum)) minimum = Math.max(statistics.minimum(), mean - deviations);
-                    if (Double.isNaN(maximum)) maximum = Math.min(statistics.maximum(), mean + deviations);
-                }
-            }
-            /*
-             * Wraps the given image with its colors ramp scaled between the given bounds. If the given image is
-             * already using a color ramp for the given range of values, then that image is returned unchanged.
-             */
-            if (minimum < maximum) {
-                final SampleModel sm = source.getSampleModel();
-                return create(source, ColorModelFactory.createGrayScale(
-                        sm.getDataType(), sm.getNumBands(), visibleBand, minimum, maximum));
-            }
-        }
-        return source;
-    }
-
-    /**
-     * Returns an image where all sample values are indices of colors in an {@link IndexColorModel}.
-     * If the given image stores sample values as unsigned bytes or short integers, then those values
-     * are used as-is (they are not copied or converted). Otherwise this operation will convert sample
-     * values to unsigned bytes in order to enable the use of {@link IndexColorModel}.
-     *
-     * <p>This method accepts two kinds of input. Use only one of the followings:</p>
-     * <ul>
-     *   <li>Non-null {@code sourceBands} and {@code colors}.</li>
-     *   <li>Non-null {@code rangesAndColors}.</li>
-     * </ul>
-     *
-     * The resulting image is suitable for visualization purposes but should not be used for computation purposes.
-     * There is no guarantees about the number of bands in returned image and the formulas used for converting
-     * floating point values to integer values.
-     *
-     * @param  processor        the processor to use for computing statistics if needed.
-     * @param  source           the image for which to replace the color model.
-     * @param  sourceBands      description of {@code source} bands, or {@code null} if none.
-     * @param  colors           the colors to use for each category. The function may return {@code null}, which
-     *                          means transparent. This parameter is used only if {@code rangesAndColors} is null.
-     * @param  rangesAndColors  range of sample values in source image associated to colors to apply,
-     *                          or {@code null} for using {@code sourceBands} and {@code colors} instead.
-     * @return recolored image for visualization purposes only.
-     * @throws NoninvertibleTransformException if sample values in source image can not be converted
-     *         to sample values in the recolored image.
-     *
-     * @see ImageProcessor#visualize(RenderedImage, Map)
-     */
-    static RenderedImage toIndexedColors(final ImageProcessor processor, RenderedImage source,
-            final List<SampleDimension> sourceBands, final Function<Category,Color[]> colors,
-            final Collection<Map.Entry<NumberRange<?>,Color[]>> rangesAndColors)
-            throws NoninvertibleTransformException
+    static RenderedImage stretchColorRamp(final ImageProcessor processor, final RenderedImage source,
+                                          final Map<String,?> modifiers)
     {
+        /*
+         * Current implementation do not stretch index color models because we do not know which pixel values
+         * are "quantitative" values to associate to new colors and which pixel values are "no data" values to
+         * keep at a constant color. Resolving this ambiguity would require the `SampleDimension` objects.
+         */
+        if (source.getColorModel() instanceof IndexColorModel) {
+            return source;
+        }
+        /*
+         * Images having more than one band (without any band marked as the single band to show) are probably
+         * RGB images. It would be possible to stretch the Red, Green and Blue bands separately, but current
+         * implementation don't do that since we do not have yet a clear use case.
+         */
         final int visibleBand = ImageUtilities.getVisibleBand(source);
         if (visibleBand < 0) {
-            throw new IllegalArgumentException(Resources.format(Resources.Keys.OperationRequiresSingleBand));
+            return source;
         }
-        boolean initialized;
-        final Colorizer colorizer;
-        if (rangesAndColors != null) {
-            colorizer = new Colorizer(rangesAndColors);
-            initialized = true;
-        } else {
-            /*
-             * Ranges of sample values were not specified explicitly. Instead we will try to infer them
-             * in various ways: sample dimensions, scaled color model, statistics in last resort.
-             */
-            colorizer = new Colorizer(colors);
-            initialized = (sourceBands != null) && colorizer.initialize(sourceBands.get(visibleBand));
-            if (initialized) {
-                /*
-                 * If we have been able to configure Colorizer using the SampleModel, apply an adjustment based
-                 * on the ScaledColorModel if it exists.  Use case: an image is created with an IndexColorModel
-                 * determined by the SampleModel, then user enhanced contrast by a call to `stretchColorRamp(…)`
-                 * above. We want to preserve that contrast enhancement.
-                 */
-                colorizer.rescaleMainRange(source.getColorModel());
-            } else {
-                /*
-                 * If we have not been able to use the SampleDimension, try to use the ColorModel or SampleModel.
-                 * There is no call to `rescaleMainRange(…)` because the following code already uses the range
-                 * specified by the ColorModel, if available.
-                 */
-                initialized = colorizer.initialize(source.getColorModel()) ||
-                              colorizer.initialize(source.getSampleModel(), visibleBand);
+        /*
+         * Main use case: color model is (probably) a ScaledColorModel instance, or something we can handle
+         * in the same way.
+         */
+        RenderedImage statsSource   = source;
+        Statistics[]  statsAllBands = null;
+        Statistics    statistics    = null;
+        double        minimum       = Double.NaN;
+        double        maximum       = Double.NaN;
+        double        deviations    = Double.POSITIVE_INFINITY;
+        /*
+         * Extract and validate parameter values.
+         * No calculation started at this stage.
+         */
+        if (modifiers != null) {
+            final Object minValue = modifiers.get("minimum");
+            if (minValue instanceof Number) {
+                minimum = ((Number) minValue).doubleValue();
+            }
+            final Object maxValue = modifiers.get("maximum");
+            if (maxValue instanceof Number) {
+                maximum = ((Number) maxValue).doubleValue();
+            }
+            if (minimum >= maximum) {
+                throw new IllegalArgumentException(Errors.format(Errors.Keys.IllegalRange_2, minValue, maxValue));
+            }
+            Object value = modifiers.get("multStdDev");
+            if (value instanceof Number) {
+                deviations = ((Number) value).doubleValue();
+                ArgumentChecks.ensureStrictlyPositive("multStdDev", deviations);
+            }
+            value = modifiers.get("statistics");
+            if (value instanceof RenderedImage) {
+                statsSource = (RenderedImage) value;
+            } else if (value instanceof Statistics) {
+                statistics = (Statistics) value;
+            } else if (value instanceof Statistics[]) {
+                statsAllBands = (Statistics[]) value;
             }
-        }
-        source = BandSelectImage.create(source, new int[] {visibleBand});               // Make single-banded.
-        if (!initialized) {
-            /*
-             * If none of above Colorizer configurations worked, use statistics in last resort. We do that
-             * after we reduced the image to a single band, in order to reduce the amount of calculations.
-             */
-            final Statistics statistics = processor.getStatistics(source, null)[0];
-            colorizer.initialize(statistics.minimum(), statistics.maximum());
         }
         /*
-         * If the source image uses unsigned integer types, we can update the color model without changing
-         * the sample values. This is much cheaper and as accurate.
+         * If minimum and maximum values were not explicitly specified,
+         * compute them from statistics.
          */
-        final int dataType = ImageUtilities.getDataType(source);
-        if (dataType == DataBuffer.TYPE_BYTE || dataType == DataBuffer.TYPE_USHORT) {
-            return create(source, colorizer.createColorModel(dataType, 1, 0));
+        if (Double.isNaN(minimum) || Double.isNaN(maximum)) {
+            if (statistics == null) {
+                if (statsAllBands == null) {
+                    final Object areaOfInterest = modifiers.get("areaOfInterest");
+                    statsAllBands = processor.getStatistics(statsSource,
+                            (areaOfInterest instanceof Shape) ? (Shape) areaOfInterest : null);
+                }
+                if (statsAllBands != null && visibleBand < statsAllBands.length) {
+                    statistics = statsAllBands[visibleBand];
+                }
+            }
+            if (statistics != null) {
+                deviations *= statistics.standardDeviation(true);
+                final double mean = statistics.mean();
+                if (Double.isNaN(minimum)) minimum = Math.max(statistics.minimum(), mean - deviations);
+                if (Double.isNaN(maximum)) maximum = Math.min(statistics.maximum(), mean + deviations);
+            }
         }
         /*
-         * Sample values can not be reused as-is; we need to convert them to integers in [0 … 255] range.
-         * Skip any previous `RecoloredImage` since we are replacing the `ColorModel` by a new one.
+         * Wraps the given image with its colors ramp scaled between the given bounds. If the given image is
+         * already using a color ramp for the given range of values, then that image is returned unchanged.
          */
-        while (source instanceof RecoloredImage) {
-            source = ((RecoloredImage) source).source;
+        if (minimum < maximum) {
+            final SampleModel sm = source.getSampleModel();
+            return create(source, ColorModelFactory.createGrayScale(
+                    sm.getDataType(), sm.getNumBands(), visibleBand, minimum, maximum));
+        } else {
+            return source;
         }
-        final ColorModel      colorModel = colorizer.compactColorModel(1, 0);           // Must be first.
-        final MathTransform1D converter  = colorizer.getSampleToIndexValues();
-        final NumberRange<?>  range      = colorizer.getRepresentativeRange();
-        return processor.convert(source, new NumberRange<?>[] {range},
-                new MathTransform1D[] {converter}, Colorizer.TYPE_COMPACT, colorModel);
     }
 
     /**
diff --git a/core/sis-feature/src/main/java/org/apache/sis/image/Visualization.java b/core/sis-feature/src/main/java/org/apache/sis/image/Visualization.java
new file mode 100644
index 0000000..6377380
--- /dev/null
+++ b/core/sis-feature/src/main/java/org/apache/sis/image/Visualization.java
@@ -0,0 +1,308 @@
+/*
+ * 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.util.Map;
+import java.util.List;
+import java.util.Collection;
+import java.awt.Color;
+import java.awt.Dimension;
+import java.awt.Rectangle;
+import java.awt.image.ColorModel;
+import java.awt.image.IndexColorModel;
+import java.awt.image.Raster;
+import java.awt.image.WritableRaster;
+import java.awt.image.RenderedImage;
+import java.awt.image.DataBuffer;
+import java.nio.DoubleBuffer;
+import javax.measure.Quantity;
+import org.opengis.referencing.operation.MathTransform;
+import org.opengis.referencing.operation.MathTransform1D;
+import org.opengis.referencing.operation.TransformException;
+import org.opengis.referencing.operation.NoninvertibleTransformException;
+import org.apache.sis.referencing.operation.transform.MathTransforms;
+import org.apache.sis.internal.coverage.CompoundTransform;
+import org.apache.sis.internal.coverage.j2d.Colorizer;
+import org.apache.sis.internal.coverage.j2d.ImageLayout;
+import org.apache.sis.internal.coverage.j2d.ImageUtilities;
+import org.apache.sis.internal.feature.Resources;
+import org.apache.sis.coverage.SampleDimension;
+import org.apache.sis.measure.NumberRange;
+import org.apache.sis.math.Statistics;
+import org.apache.sis.util.collection.BackingStoreException;
+
+
+/**
+ * Image generated for visualization purposes only (not to be used for computation purposes).
+ * This class merges {@link ResampledImage}, {@link BandedSampleConverter} and {@link RecoloredImage} operations
+ * in a single operation for efficiency. This merge avoids creating intermediate tiles of {@code float} values.
+ * By writing directly {@code byte} values, we save memory and CPU since {@link WritableRaster#setPixel(int, int, int[])}
+ * has more efficient implementations for integers.
+ *
+ * @author  Martin Desruisseaux (Geomatys)
+ * @version 1.1
+ * @since   1.1
+ * @module
+ */
+final class Visualization extends ResampledImage {
+    /**
+     * Transfer functions to apply on each band of the source image, or {@code null} if those conversions are done
+     * by {@link InterpConvert}. Non-null array is used for allowing {@link #computeTile(int, int, WritableRaster)}
+     * to use a shortcut avoiding {@link ResampledImage} cost. Outputs should be values in the [0 … 255] range;
+     * values outside that ranges will be clamped.
+     */
+    private final MathTransform1D[] converters;
+
+    /**
+     * The color model for the expected range of values. Typically an {@link IndexColorModel} for byte values.
+     * May be {@code null} if the color model is unknown.
+     */
+    private final ColorModel colorModel;
+
+    /**
+     * Creates a new image which will resample and convert values of the given image.
+     * See parent class for more details on arguments.
+     *
+     * @param source         image to be resampled and converted.
+     * @param layout         computer of tile size.
+     * @param bounds         domain of pixel coordinates of this image, or {@code null} if same as {@code source} image.
+     * @param toSource       conversion of pixel coordinates of this image to pixel coordinates of {@code source} image.
+     * @param isIdentity     value of {@code toSource.isIdentity()}.
+     * @param interpolation  object to use for performing interpolations.
+     * @param converters     transfer functions to apply on each band of the source image. This array is not cloned.
+     * @param fillValues     values to use for pixels in this image that can not be mapped to pixels in source image.
+     * @param colorModel     color model of this image.
+     * @param accuracy       values of {@value #POSITIONAL_ACCURACY_KEY} property, or {@code null} if none.
+     */
+    Visualization(final RenderedImage source,         final ImageLayout layout,  final Rectangle bounds,
+                  final MathTransform toSource,       final boolean isIdentity,  final Interpolation interpolation,
+                  final MathTransform1D[] converters, final Number[] fillValues, final ColorModel colorModel,
+                  final Quantity<?>[] accuracy)
+    {
+        super(source,
+              layout.createBandedSampleModel(Colorizer.TYPE_COMPACT, converters.length, source, bounds),
+              (bounds != null) ? bounds : ImageUtilities.getBounds(source),
+              toSource,
+              isIdentity ? Interpolation.NEAREST : new InterpConvert(interpolation, converters).simplify(),
+              fillValues,
+              accuracy);
+
+        this.colorModel = colorModel;
+        this.converters = isIdentity ? converters : null;
+    }
+
+    /**
+     * Interpolation followed by conversion from floating point values to the values to store as integers in the
+     * destination image. This class is used for combining {@link ResampledImage} and {@link BandedSampleConverter}
+     * in a single operation.
+     */
+    private static final class InterpConvert implements Interpolation {
+        /**
+         * The object to use for performing interpolations.
+         *
+         * @see ResampledImage#interpolation
+         */
+        private final Interpolation interpolation;
+
+        /**
+         * Conversion from floating point values resulting from interpolations to values to store as integers
+         * in the destination image. This transform shall operate on all bands in one {@code transform(…)} call.
+         */
+        private final MathTransform converter;
+
+        /**
+         * Creates a new object combining the given interpolation with the given conversion of sample values.
+         */
+        InterpConvert(final Interpolation interpolation, final MathTransform1D[] converters) {
+            this.interpolation = interpolation;
+            converter = CompoundTransform.create(converters);
+        }
+
+        /**
+         * Returns a more direct {@code Interpolation} object if possible, or {@code this} otherwise.
+         */
+        Interpolation simplify() {
+            return converter.isIdentity() ? interpolation : this;
+        }
+
+        /**
+         * Delegates to {@link Interpolation#getSupportSize()}.
+         */
+        @Override
+        public Dimension getSupportSize() {
+            return interpolation.getSupportSize();
+        }
+
+        /**
+         * Delegates to {@link #interpolation}, then convert sample values in all bands.
+         *
+         * @throws BackingStoreException if an error occurred while converting sample values.
+         *         This exception should be unwrapped by {@link #computeTile(int, int, WritableRaster)}.
+         */
+        @Override
+        public void interpolate(final DoubleBuffer source, final int numBands,
+                                final double xfrac, final double yfrac,
+                                final double[] writeTo, final int writeToOffset)
+        {
+            interpolation.interpolate(source, numBands, xfrac, yfrac, writeTo, writeToOffset);
+            try {
+                converter.transform(writeTo, writeToOffset, writeTo, writeToOffset, 1);
+            } catch (TransformException e) {
+                throw new BackingStoreException(e);     // Will be unwrapped by computeTile(…).
+            }
+        }
+    }
+
+    /**
+     * Returns an image where all sample values are indices of colors in an {@link IndexColorModel}.
+     * If the given image stores sample values as unsigned bytes or short integers, then those values
+     * are used as-is (they are not copied or converted). Otherwise this operation will convert sample
+     * values to unsigned bytes in order to enable the use of {@link IndexColorModel}.
+     *
+     * <p>This method accepts two kinds of input. Use only one of the followings:</p>
+     * <ul>
+     *   <li>Non-null {@code sourceBands} and {@link ImageProcessor#getCategoryColors()}.</li>
+     *   <li>Non-null {@code rangesAndColors}.</li>
+     * </ul>
+     *
+     * The resulting image is suitable for visualization purposes but should not be used for computation purposes.
+     * There is no guarantees about the number of bands in returned image and the formulas used for converting
+     * floating point values to integer values.
+     *
+     * <h4>Resampling</h4>
+     * This operation can optionally be combined with a {@link ResampledImage} operation.
+     * This can be done by providing a non-null value to the {@code toSource} argument.
+     *
+     * @param  processor        the processor invoking this method.
+     * @param  bounds           desired domain of pixel coordinates, or {@code null} if same as {@code source} image.
+     * @param  source           the image for which to replace the color model.
+     * @param  toSource         pixel coordinates conversion to {@code source} image, or {@code null} if none.
+     * @param  sourceBands      description of {@code source} bands, or {@code null} if none.
+     * @param  rangesAndColors  range of sample values in source image associated to colors to apply,
+     *                          or {@code null} for using {@code sourceBands} instead.
+     * @return resampled and recolored image for visualization purposes only.
+     * @throws NoninvertibleTransformException if sample values in source image can not be converted
+     *         to sample values in the recolored image.
+     *
+     * @see ImageProcessor#visualize(RenderedImage, Map)
+     */
+    static RenderedImage create(final ImageProcessor processor, final Rectangle bounds,
+                                RenderedImage source, MathTransform toSource,
+                                final List<SampleDimension> sourceBands,
+                                final Collection<Map.Entry<NumberRange<?>,Color[]>> rangesAndColors)
+            throws NoninvertibleTransformException
+    {
+        final int visibleBand = ImageUtilities.getVisibleBand(source);
+        if (visibleBand < 0) {
+            // This restriction may be relaxed in a future version if we implement conversion to RGB images.
+            throw new IllegalArgumentException(Resources.format(Resources.Keys.OperationRequiresSingleBand));
+        }
+        boolean initialized;
+        final Colorizer colorizer;
+        if (rangesAndColors != null) {
+            colorizer = new Colorizer(rangesAndColors);
+            initialized = true;
+        } else {
+            /*
+             * Ranges of sample values were not specified explicitly. Instead we will try to infer them
+             * in various ways: sample dimensions, scaled color model, statistics in last resort.
+             */
+            colorizer = new Colorizer(processor.getCategoryColors());
+            initialized = (sourceBands != null) && colorizer.initialize(sourceBands.get(visibleBand));
+            if (initialized) {
+                /*
+                 * If we have been able to configure Colorizer using the SampleModel, apply an adjustment based
+                 * on the ScaledColorModel if it exists.  Use case: an image is created with an IndexColorModel
+                 * determined by the SampleModel, then user enhanced contrast by a call to `stretchColorRamp(…)`
+                 * above. We want to preserve that contrast enhancement.
+                 */
+                colorizer.rescaleMainRange(source.getColorModel());
+            } else {
+                /*
+                 * If we have not been able to use the SampleDimension, try to use the ColorModel or SampleModel.
+                 * There is no call to `rescaleMainRange(…)` because the following code already uses the range
+                 * specified by the ColorModel, if available.
+                 */
+                initialized = colorizer.initialize(source.getColorModel()) ||
+                              colorizer.initialize(source.getSampleModel(), visibleBand);
+            }
+        }
+        source = BandSelectImage.create(source, new int[] {visibleBand});               // Make single-banded.
+        if (!initialized) {
+            /*
+             * If none of above Colorizer configurations worked, use statistics in last resort. We do that
+             * after we reduced the image to a single band, in order to reduce the amount of calculations.
+             */
+            final Statistics statistics = processor.getStatistics(source, null)[0];
+            colorizer.initialize(statistics.minimum(), statistics.maximum());
+        }
+        /*
+         * If the source image uses unsigned integer types and there is no resampling operation, we can
+         * update the color model without changing sample values. This is much cheaper and as accurate.
+         */
+        final int dataType = ImageUtilities.getDataType(source);
+        if (dataType == DataBuffer.TYPE_BYTE || dataType == DataBuffer.TYPE_USHORT) {
+            if (toSource != null && !toSource.isIdentity()) {
+                source = processor.resample(source, bounds, toSource);
+            }
+            return RecoloredImage.create(source, colorizer.createColorModel(dataType, 1, 0));
+        }
+        /*
+         * If we reach this point, sample values need to be converted to integers in [0 … 255] range.
+         * Skip any previous `RecoloredImage` since we are replacing the `ColorModel` by a new one.
+         */
+        while (source instanceof RecoloredImage) {
+            source = ((RecoloredImage) source).source;
+        }
+        if (toSource == null) {
+            toSource = MathTransforms.identity(BIDIMENSIONAL);
+        }
+        final ColorModel      colorModel = colorizer.compactColorModel(1, 0);           // Must be first.
+        final MathTransform1D converter  = colorizer.getSampleToIndexValues();
+        return processor.resampleAndConvert(source, toSource,
+                new MathTransform1D[] {converter}, bounds, colorModel);
+    }
+
+    /**
+     * Returns the color model associated with all rasters of this image.
+     */
+    @Override
+    public ColorModel getColorModel() {
+        return colorModel;
+    }
+
+    /**
+     * Invoked when a tile need to be computed or updated.
+     *
+     * @throws TransformException if an error occurred while computing pixel coordinates or converting sample values.
+     */
+    @Override
+    protected Raster computeTile(final int tileX, final int tileY, WritableRaster tile) throws TransformException {
+        if (converters == null) try {
+            // Most expansive operation (resampling + conversion).
+            return super.computeTile(tileX, tileY, tile);
+        } catch (BackingStoreException e) {
+            throw e.unwrapOrRethrow(TransformException.class);
+        }
+        if (tile == null) {
+            tile = createTile(tileX, tileY);
+        }
+        // Conversion only, when no resampling is needed.
+        Transferer.create(getSource(), tile).compute(converters);
+        return tile;
+    }
+}
diff --git a/core/sis-feature/src/main/java/org/apache/sis/internal/coverage/CompoundTransform.java b/core/sis-feature/src/main/java/org/apache/sis/internal/coverage/CompoundTransform.java
new file mode 100644
index 0000000..bb989a1
--- /dev/null
+++ b/core/sis-feature/src/main/java/org/apache/sis/internal/coverage/CompoundTransform.java
@@ -0,0 +1,213 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.sis.internal.coverage;
+
+import java.util.Arrays;
+import org.opengis.referencing.operation.MathTransform;
+import org.opengis.referencing.operation.MathTransform1D;
+import org.opengis.referencing.operation.MathTransformFactory;
+import org.opengis.referencing.operation.NoninvertibleTransformException;
+import org.opengis.util.FactoryException;
+import org.apache.sis.referencing.operation.transform.AbstractMathTransform;
+import org.apache.sis.referencing.operation.transform.MathTransforms;
+import org.apache.sis.util.ArgumentChecks;
+import org.apache.sis.util.ComparisonMode;
+import org.apache.sis.util.Utilities;
+import org.apache.sis.util.ArraysExt;
+
+
+/**
+ * A transform composed of an arbitrary amount of juxtaposed transforms.
+ *
+ * @author  Martin Desruisseaux (Geomatys)
+ * @version 1.1
+ * @since   1.1
+ */
+public abstract class CompoundTransform extends AbstractMathTransform {
+    /**
+     * The inverse, created when first needed.
+     */
+    private transient MathTransform inverse;
+
+    /**
+     * Creates a new compound transforms.
+     */
+    CompoundTransform() {
+    }
+
+    /**
+     * Returns the component transforms that are juxtaposed in this compound transform.
+     * This method may return a direct reference to an internal array; callers shall not modify that array.
+     */
+    abstract MathTransform[] components();
+
+    /**
+     * Creates a new transform made of the given components.
+     *
+     * @todo Current implementation requires that given transforms are {@link MathTransform1D} instances.
+     *
+     * @param  components  transforms to juxtapose for defining a new transform.
+     * @return compound transforms with the given components.
+     */
+    public static MathTransform create(final MathTransform[] components) {
+        ArgumentChecks.ensureNonNull("components", components);
+        final int n = components.length;
+        if (n == 0) {
+            return MathTransforms.identity(0);
+        }
+        final MathTransform first = components[0];
+        ArgumentChecks.ensureNonNullElement("components", 0, first);
+        if (n == 1) {
+            return first;
+        }
+        /*
+         * TODO: we should check here if there is consecutive linear transforms that we can combine in a single matrix.
+         *       Code for doing that may be found in PassthroughTransform. Doing this optimization requires a general
+         *       (non 1D) implementation of CompoundTransform.
+         */
+        if (ArraysExt.allEquals(components, first)) {
+            return new RepeatedTransform(first, n);
+        }
+        final MathTransform1D[] as1D = new MathTransform1D[n];
+        for (int i=0; i<n; i++) {
+            final MathTransform c = components[i];
+            ArgumentChecks.ensureNonNullElement("components", i, c);
+            if (c instanceof MathTransform1D) {
+                as1D[i] = (MathTransform1D) c;
+            } else {
+                /*
+                 * TODO: if we have a nested CompoundTransform, we need to unwrap its components.
+                 *       For all other types, we need a general (non 1D) implementation.
+                 */
+                throw new UnsupportedOperationException("Non 1D-case not yet implemented.");
+            }
+        }
+        return new CompoundTransformOf1D(as1D);
+    }
+
+    /**
+     * Returns the number of source dimensions of this compound transform.
+     * This is the sum of the number of source dimensions of all components.
+     */
+    @Override
+    public int getSourceDimensions() {
+        int dim = 0;
+        for (final MathTransform c : components()) {
+            dim += c.getSourceDimensions();
+        }
+        return dim;
+    }
+
+    /**
+     * Returns the number of target dimensions of this compound transform.
+     * This is the sum of the number of target dimensions of all components.
+     */
+    @Override
+    public int getTargetDimensions() {
+        int dim = 0;
+        for (final MathTransform c : components()) {
+            dim += c.getTargetDimensions();
+        }
+        return dim;
+    }
+
+    /**
+     * Tests whether this transform does not move any points.
+     *
+     * @return {@code true} if all transform components are identity.
+     */
+    @Override
+    public boolean isIdentity() {
+        for (final MathTransform c : components()) {
+            if (!c.isIdentity()) return false;
+        }
+        return true;
+    }
+
+    /**
+     * Returns the inverse transform of this transform.
+     *
+     * @return the inverse of this transform.
+     * @throws NoninvertibleTransformException if at least one component transform can not be inverted.
+     */
+    @Override
+    public final synchronized MathTransform inverse() throws NoninvertibleTransformException {
+        if (inverse == null) {
+            final MathTransform[] components = components();
+            final MathTransform[] inverses = new MathTransform1D[components.length];
+            for (int i=0; i<components.length; i++) {
+                inverses[i] = components[i].inverse();
+            }
+            inverse = create(inverses);
+        }
+        return inverse;
+    }
+
+    /**
+     * Concatenates or pre-concatenates in an optimized way this math transform with the given one, if possible.
+     */
+    @Override
+    protected final MathTransform tryConcatenate(final boolean applyOtherFirst, final MathTransform other,
+                                                 final MathTransformFactory factory) throws FactoryException
+    {
+build:  if (other instanceof CompoundTransform) {
+            final MathTransform[] components = components();
+            final MathTransform[] toConcatenate = ((CompoundTransform) other).components();
+            final int n = components.length;
+            if (toConcatenate.length == n) {
+                final MathTransform[] concatenated = new MathTransform1D[n];
+                for (int i=0; i<n; i++) {
+                    MathTransform c1 = components[i];
+                    MathTransform c2 = toConcatenate[i];
+                    if (applyOtherFirst) {
+                        c1 = c2;
+                        c2 = components[i];
+                    }
+                    if (c1.getTargetDimensions() != c2.getSourceDimensions()) {
+                        /*
+                         * TODO: if c1 or c2 are linear transforms, we could take sub-matrices.
+                         */
+                        break build;
+                    }
+                    concatenated[i] = factory.createConcatenatedTransform(c1, c2);
+                }
+                return create(concatenated);
+            }
+        }
+        return super.tryConcatenate(applyOtherFirst, other, factory);
+    }
+
+    /**
+     * Computes a hash value for this transform. This method is invoked by {@link #hashCode()} when first needed.
+     */
+    @Override
+    protected final int computeHashCode() {
+        return super.hashCode() + Arrays.hashCode(components());
+    }
+
+    /**
+     * Compares the specified object with this math transform for equality.
+     */
+    @Override
+    public final boolean equals(final Object object, final ComparisonMode mode) {
+        if (object == this) {
+            return true;
+        }
+        return super.equals(object, mode) &&
+                Utilities.deepEquals(components(), ((CompoundTransform) object).components(), mode);
+    }
+}
diff --git a/core/sis-feature/src/main/java/org/apache/sis/internal/coverage/CompoundTransformOf1D.java b/core/sis-feature/src/main/java/org/apache/sis/internal/coverage/CompoundTransformOf1D.java
new file mode 100644
index 0000000..db2ba82
--- /dev/null
+++ b/core/sis-feature/src/main/java/org/apache/sis/internal/coverage/CompoundTransformOf1D.java
@@ -0,0 +1,173 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.sis.internal.coverage;
+
+import org.opengis.referencing.operation.Matrix;
+import org.opengis.referencing.operation.MathTransform;
+import org.opengis.referencing.operation.MathTransform1D;
+import org.opengis.referencing.operation.TransformException;
+import org.apache.sis.referencing.operation.matrix.Matrices;
+import org.apache.sis.referencing.operation.matrix.MatrixSIS;
+import org.apache.sis.referencing.operation.transform.IterationStrategy;
+
+
+/**
+ * A transform composed of an arbitrary amount of juxtaposed one-dimensional transforms.
+ * This is an optimization for a common case when using transforms as transfer functions
+ * in grid coverages.
+ *
+ * @author  Martin Desruisseaux (Geomatys)
+ * @version 1.1
+ * @since   1.1
+ */
+final class CompoundTransformOf1D extends CompoundTransform {
+    /**
+     * The transforms to juxtapose for defining a new transform.
+     * The length of this array should be greater then 1.
+     */
+    private final MathTransform1D[] components;
+
+    /**
+     * Creates a new compound transforms with the given components.
+     */
+    CompoundTransformOf1D(final MathTransform1D[] components) {
+        this.components = components;
+    }
+
+    /**
+     * Returns the components of this compound transform.
+     * This is a direct reference to internal array; callers shall not modify.
+     */
+    @Override
+    @SuppressWarnings("ReturnOfCollectionOrArrayField")
+    final MathTransform[] components() {
+        return components;
+    }
+
+    /**
+     * Returns the number of source dimensions of this compound transform.
+     * This is the sum of the number of source dimensions of all components.
+     */
+    @Override
+    public int getSourceDimensions() {
+        return components.length;
+    }
+
+    /**
+     * Returns the number of target dimensions of this compound transform.
+     * This is the sum of the number of target dimensions of all components.
+     */
+    @Override
+    public int getTargetDimensions() {
+        return components.length;
+    }
+
+    /**
+     * Transforms a single coordinate point in an array, and optionally computes the transform derivative
+     * at that location.
+     */
+    @Override
+    public Matrix transform(double[] srcPts, int srcOff, final double[] dstPts, int dstOff, final boolean derivate)
+            throws TransformException
+    {
+        /*
+         * If the arrays may overlap, we need to protect the source coordinates
+         * before we start writing destination coordinates.
+         */
+        if (srcPts == dstPts && dstOff > srcOff) {
+            System.arraycopy(srcPts, srcOff, dstPts, dstOff, getSourceDimensions());
+            srcPts = dstPts;
+            srcOff = dstOff;
+        }
+        if (!derivate) {
+            for (final MathTransform1D c : components) {
+                dstPts[dstOff++] = c.transform(srcPts[srcOff++]);
+            }
+            return null;
+        } else {
+            final int n = components.length;
+            final MatrixSIS m = Matrices.createZero(n, n);
+            for (int i=0; i<n; i++) {
+                final MathTransform1D c = components[i];
+                final double x = srcPts[srcOff++];
+                dstPts[dstOff++] = c.transform(x);
+                m.setElement(i, i, c.derivative(x));
+            }
+            return m;
+        }
+    }
+
+    /**
+     * Transforms a list of coordinate points.
+     */
+    @Override
+    public void transform(double[] srcPts, int srcOff, double[] dstPts, int dstOff, int numPts) throws TransformException {
+        final int n = components.length;
+        if (IterationStrategy.suggest(srcOff, n, dstOff, n, numPts) != IterationStrategy.ASCENDING) {
+            System.arraycopy(srcPts, srcOff, dstPts, dstOff, numPts * n);
+            srcPts = dstPts;
+            srcOff = dstOff;
+        }
+        while (--numPts >= 0) {
+            for (final MathTransform1D c : components) {
+                dstPts[dstOff++] = c.transform(srcPts[srcOff++]);
+            }
+        }
+    }
+
+    /**
+     * Transforms a list of coordinate points.
+     */
+    @Override
+    public void transform(float[] srcPts, int srcOff, float[] dstPts, int dstOff, int numPts) throws TransformException {
+        final int n = components.length;
+        if (IterationStrategy.suggest(srcOff, n, dstOff, n, numPts) != IterationStrategy.ASCENDING) {
+            System.arraycopy(srcPts, srcOff, dstPts, dstOff, numPts * n);
+            srcPts = dstPts;
+            srcOff = dstOff;
+        }
+        while (--numPts >= 0) {
+            for (final MathTransform1D c : components) {
+                dstPts[dstOff++] = (float) c.transform(srcPts[srcOff++]);
+            }
+        }
+    }
+
+    /**
+     * Transforms a list of coordinate points.
+     */
+    @Override
+    public void transform(double[] srcPts, int srcOff, float[] dstPts, int dstOff, int numPts) throws TransformException {
+        while (--numPts >= 0) {
+            for (final MathTransform1D c : components) {
+                dstPts[dstOff++] = (float) c.transform(srcPts[srcOff++]);
+            }
+        }
+    }
+
+    /**
+     * Transforms a list of coordinate points.
+     */
+    @Override
+    public void transform(float[] srcPts, int srcOff, double[] dstPts, int dstOff, int numPts) throws TransformException {
+        while (--numPts >= 0) {
+            for (final MathTransform1D c : components) {
+                dstPts[dstOff++] = c.transform(srcPts[srcOff++]);
+            }
+        }
+    }
+}
diff --git a/core/sis-feature/src/main/java/org/apache/sis/internal/coverage/RepeatedTransform.java b/core/sis-feature/src/main/java/org/apache/sis/internal/coverage/RepeatedTransform.java
new file mode 100644
index 0000000..29f9aa4
--- /dev/null
+++ b/core/sis-feature/src/main/java/org/apache/sis/internal/coverage/RepeatedTransform.java
@@ -0,0 +1,161 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.sis.internal.coverage;
+
+import java.util.Arrays;
+import org.opengis.referencing.operation.Matrix;
+import org.opengis.referencing.operation.MathTransform;
+import org.opengis.referencing.operation.MathTransform1D;
+import org.opengis.referencing.operation.TransformException;
+import org.apache.sis.referencing.operation.matrix.Matrices;
+import org.apache.sis.referencing.operation.matrix.MatrixSIS;
+
+
+/**
+ * An special case of {@link CompoundTransform} where the components are the same transform repeated many times.
+ * This optimization allows to replace many single {@code transform(…)} calls by a single {@code transform(…)}
+ * call on a larger array segment.
+ *
+ * @author  Martin Desruisseaux (Geomatys)
+ * @version 1.1
+ * @since   1.1
+ */
+final class RepeatedTransform extends CompoundTransform {
+    /**
+     * The transform which is repeated.
+     */
+    private final MathTransform component;
+
+    /**
+     * Number of times that the {@linkplain #component} is repeated.
+     * Should be greater than 1 (otherwise the use of this class is pointless).
+     */
+    private final int repetition;
+
+    /**
+     * Creates a new compound transform.
+     *
+     * @param  component   the transform which is repeated.
+     * @param  repetition  number of times that the component is repeated.
+     */
+    RepeatedTransform(final MathTransform component, final int repetition) {
+        this.component  = component;
+        this.repetition = repetition;
+    }
+
+    /**
+     * Returns the components of this compound transform.
+     */
+    @Override
+    final MathTransform[] components() {
+        final MathTransform[] components = new MathTransform[repetition];
+        Arrays.fill(components, component);
+        return components;
+    }
+
+    /**
+     * Returns the number of source dimensions of this compound transform.
+     * This is the sum of the number of source dimensions of all components.
+     */
+    @Override
+    public int getSourceDimensions() {
+        return component.getSourceDimensions() * repetition;
+    }
+
+    /**
+     * Returns the number of target dimensions of this compound transform.
+     * This is the sum of the number of target dimensions of all components.
+     */
+    @Override
+    public int getTargetDimensions() {
+        return component.getTargetDimensions() * repetition;
+    }
+
+    /**
+     * Tests whether this transform does not move any points.
+     */
+    @Override
+    public boolean isIdentity() {
+        return component.isIdentity();
+    }
+
+    /**
+     * Transforms a single coordinate point in an array, and optionally computes the transform derivative
+     * at that location.
+     */
+    @Override
+    public Matrix transform(final double[] srcPts, final int srcOff,
+                            final double[] dstPts, final int dstOff,
+                            final boolean derivate) throws TransformException
+    {
+        final MatrixSIS m;
+        if (derivate) {
+            m = Matrices.createZero(repetition, repetition);
+            for (int i=0; i<repetition; i++) {
+                /*
+                 * TODO: implementation restricted to MathTransform1D component for now.
+                 *       In a future version, we should port code from PassthroughTransform.
+                 */
+                m.setElement(i, i, ((MathTransform1D) component).derivative(srcPts[srcOff + i]));
+            }
+        } else {
+            m = null;
+        }
+        component.transform(srcPts, srcOff, dstPts, dstOff, repetition);
+        return m;
+    }
+
+    /**
+     * Transforms a list of coordinate points.
+     */
+    @Override
+    public void transform(final double[] srcPts, final int srcOff,
+                          final double[] dstPts, final int dstOff, final int numPts) throws TransformException
+    {
+        component.transform(srcPts, srcOff, dstPts, dstOff, numPts * repetition);
+    }
+
+    /**
+     * Transforms a list of coordinate points.
+     */
+    @Override
+    public void transform(final float[] srcPts, final int srcOff,
+                          final float[] dstPts, final int dstOff, final int numPts) throws TransformException
+    {
+        component.transform(srcPts, srcOff, dstPts, dstOff, numPts * repetition);
+    }
+
+    /**
+     * Transforms a list of coordinate points.
+     */
+    @Override
+    public void transform(final double[] srcPts, final int srcOff,
+                          final float [] dstPts, final int dstOff, final int numPts) throws TransformException
+    {
+        component.transform(srcPts, srcOff, dstPts, dstOff, numPts * repetition);
+    }
+
+    /**
+     * Transforms a list of coordinate points.
+     */
+    @Override
+    public void transform(final float [] srcPts, final int srcOff,
+                          final double[] dstPts, final int dstOff, final int numPts) throws TransformException
+    {
+        component.transform(srcPts, srcOff, dstPts, dstOff, numPts * repetition);
+    }
+}
diff --git a/core/sis-feature/src/main/java/org/apache/sis/internal/coverage/j2d/Colorizer.java b/core/sis-feature/src/main/java/org/apache/sis/internal/coverage/j2d/Colorizer.java
index 52c3ef2..6caa6d0 100644
--- a/core/sis-feature/src/main/java/org/apache/sis/internal/coverage/j2d/Colorizer.java
+++ b/core/sis-feature/src/main/java/org/apache/sis/internal/coverage/j2d/Colorizer.java
@@ -31,7 +31,6 @@ import java.awt.image.SampleModel;
 import org.opengis.referencing.operation.MathTransform1D;
 import org.opengis.referencing.operation.NoninvertibleTransformException;
 import org.apache.sis.referencing.operation.transform.MathTransforms;
-import org.apache.sis.image.DataType;
 import org.apache.sis.coverage.Category;
 import org.apache.sis.coverage.SampleDimension;
 import org.apache.sis.internal.feature.Resources;
@@ -85,8 +84,9 @@ public final class Colorizer {
 
     /**
      * The type resulting from sample values conversion applied by {@link #compactColorModel(int, int)}.
+     * Current value is {@link DataBuffer#TYPE_BYTE}.
      */
-    public static final DataType TYPE_COMPACT = DataType.BYTE;
+    public static final int TYPE_COMPACT = DataBuffer.TYPE_BYTE;
 
     /**
      * Applies a gray scale to quantitative category and transparent colors to qualitative categories.
@@ -149,12 +149,11 @@ public final class Colorizer {
      * Creates a new colorizer which will use the given function for determining the colors to apply.
      * Callers need to invoke an {@code initialize(…)} method after this constructor.
      *
-     * @param  colors  the colors to use for each category.
+     * @param  colors  the colors to use for each category, or {@code null} for default.
      *                 The function may return {@code null}, which means transparent.
      */
     public Colorizer(final Function<Category,Color[]> colors) {
-        ArgumentChecks.ensureNonNull("colors", colors);
-        this.colors = colors;
+        this.colors = (colors != null) ? colors : GRAYSCALE;
     }
 
     /**
@@ -496,32 +495,7 @@ reuse:  if (source != null) {
     public ColorModel compactColorModel(final int numBands, final int visibleBand) {
         checkInitializationStatus(true);
         compact();
-        return createColorModel(TYPE_COMPACT.ordinal(), numBands, visibleBand);
-    }
-
-    /**
-     * Returns the largest range of sample values in target image, ignoring all ranges for NaN values.
-     * This method does <strong>not</strong> compute the union of all ranges, because callers may take
-     * for example the {@linkplain NumberRange#getMedian() median} value as the most typical value.
-     * If the returned range was the union of distinct ranges, then we would have no guarantees
-     * that the median value is a valid value.
-     *
-     * @return largest range of sample values in target image, or {@code null} if unknown.
-     */
-    public NumberRange<?> getRepresentativeRange() {
-        checkInitializationStatus(true);
-        NumberRange<?> largest = null;
-        double span = 0;
-        for (final ColorsForRange entry : entries) {
-            if (entry.isData()) {
-                final double s = entry.sampleRange.getSpan();
-                if (s > span) {
-                    span = s;
-                    largest = entry.sampleRange;
-                }
-            }
-        }
-        return largest;
+        return createColorModel(TYPE_COMPACT, numBands, visibleBand);
     }
 
     /**
diff --git a/core/sis-feature/src/main/java/org/apache/sis/internal/coverage/j2d/ImageLayout.java b/core/sis-feature/src/main/java/org/apache/sis/internal/coverage/j2d/ImageLayout.java
index 10e8cd8..a76b089 100644
--- a/core/sis-feature/src/main/java/org/apache/sis/internal/coverage/j2d/ImageLayout.java
+++ b/core/sis-feature/src/main/java/org/apache/sis/internal/coverage/j2d/ImageLayout.java
@@ -187,7 +187,7 @@ public class ImageLayout {
      * method will not return a size that may result in the creation of partially empty tiles.</p>
      *
      * @param  image   the image for which to derive a tile size, or {@code null}.
-     * @param  bounds  the bounds of the image to create, or {@code null} is same as {@code image}.
+     * @param  bounds  the bounds of the image to create, or {@code null} if same as {@code image}.
      * @return suggested tile size for the given image.
      */
     public Dimension suggestTileSize(final RenderedImage image, final Rectangle bounds) {
@@ -240,10 +240,13 @@ public class ImageLayout {
      * @param  type      desired data type as a {@link java.awt.image.DataBuffer} constant.
      * @param  numBands  desired number of bands.
      * @param  image     the image which will be the source of the image for which a sample model is created.
+     * @param  bounds    the bounds of the image to create, or {@code null} if same as {@code image}.
      * @return a banded sample model of the given type with the given number of bands.
      */
-    public BandedSampleModel createBandedSampleModel(final int type, final int numBands, final RenderedImage image) {
-        final Dimension tile = suggestTileSize(image, null);
+    public BandedSampleModel createBandedSampleModel(final int type, final int numBands,
+            final RenderedImage image, final Rectangle bounds)
+    {
+        final Dimension tile = suggestTileSize(image, bounds);
         return RasterFactory.unique(new BandedSampleModel(type, tile.width, tile.height, numBands));
     }
 
diff --git a/core/sis-feature/src/test/java/org/apache/sis/internal/coverage/j2d/ColorizerTest.java b/core/sis-feature/src/test/java/org/apache/sis/internal/coverage/j2d/ColorizerTest.java
index 098fc3d..5d37bb2 100644
--- a/core/sis-feature/src/test/java/org/apache/sis/internal/coverage/j2d/ColorizerTest.java
+++ b/core/sis-feature/src/test/java/org/apache/sis/internal/coverage/j2d/ColorizerTest.java
@@ -59,7 +59,6 @@ public final strictfp class ColorizerTest extends TestCase {
          * above-given ranges already fit in a 4-bits IndexColormodel.
          */
         assertTrue("isIdentity", colorizer.getSampleToIndexValues().isIdentity());
-        assertEquals("range", NumberRange.create(2, true, 15, true), colorizer.getRepresentativeRange());
         final IndexColorModel cm = (IndexColorModel) colorizer.createColorModel(DataBuffer.TYPE_BYTE, 1, 0);
         final int[] expected = {
             0xFF808080,     // Color.GRAY
@@ -105,11 +104,6 @@ public final strictfp class ColorizerTest extends TestCase {
         assertTrue("initialize", colorizer.initialize(sd));
         final IndexColorModel cm = (IndexColorModel) colorizer.compactColorModel(1, 0);     // Must be first.
         /*
-         * There is two ranges of real values (excluding ranges for NaN values). But `getSampleToIndexValues()`
-         * intentionally returns only one of those ranges, not the union of them. See its javadoc for rational.
-         */
-        assertEquals("range", NumberRange.create(2, true, 169, false), colorizer.getRepresentativeRange());
-        /*
          * Test conversion of a few sample values to packed values.
          */
         final MathTransform1D tr = colorizer.getSampleToIndexValues();


Mime
View raw message