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 attempt to reproject `RenderedImage` that are slices in a n-dimensional data cube. This commit contains work in two areas:
Date Thu, 14 May 2020 16:19:59 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 3f355f4  First attempt to reproject `RenderedImage` that are slices in a n-dimensional data cube. This commit contains work in two areas:
3f355f4 is described below

commit 3f355f480ee86bd1bb2fb277b784b12dc3886154
Author: Martin Desruisseaux <martin.desruisseaux@geomatys.com>
AuthorDate: Sun May 10 18:44:58 2020 +0200

    First attempt to reproject `RenderedImage` that are slices in a n-dimensional data cube.
    This commit contains work in two areas:
    
    1) A refactoring of the `GridGeometry.reduce(int...)` method in a separated SliceGeometry class.
       We reuse this class for computing an "image geometry" property in the RenderingImage resulting
       from a call to `GridCoverage.render(SliceExtent)` method.
    
    2) A rewrite of CoverageCanvas for testing this work. The CoverageControls do not yet allow users
       to navigate in other dimensions, but we can check with the debugger that transforms computed
       in a generic way are correct.
---
 .../apache/sis/gui/coverage/CoverageCanvas.java    | 397 +++++++++------------
 .../apache/sis/gui/coverage/CoverageControls.java  |  13 +-
 .../org/apache/sis/gui/coverage/RenderingData.java | 291 +++++++++++++++
 .../java/org/apache/sis/gui/map/MapCanvas.java     |  34 +-
 .../java/org/apache/sis/gui/map/MapCanvasAWT.java  |  33 +-
 .../apache/sis/internal/gui/ImageRenderings.java   |  73 ----
 .../apache/sis/coverage/grid/GridCoverage2D.java   |  18 +-
 .../org/apache/sis/coverage/grid/GridExtent.java   |   5 +-
 .../org/apache/sis/coverage/grid/GridGeometry.java | 204 ++---------
 .../apache/sis/coverage/grid/ImageRenderer.java    | 241 ++++++++++++-
 .../sis/coverage/grid/ResampledGridCoverage.java   |   4 +-
 .../apache/sis/coverage/grid/SliceGeometry.java    | 344 ++++++++++++++++++
 .../java/org/apache/sis/image/ImageProcessor.java  |  58 +--
 .../java/org/apache/sis/image/PlanarImage.java     |  21 ++
 .../java/org/apache/sis/image/RecoloredImage.java  |  84 ++++-
 .../sis/internal/coverage/j2d/ImageLayout.java     |  95 ++---
 .../sis/internal/coverage/j2d/ImageUtilities.java  |  40 +++
 .../sis/internal/coverage/j2d/PreferredSize.java   |  67 ++++
 .../internal/coverage/j2d/ScaledColorModel.java    |   8 +
 .../apache/sis/internal/coverage/package-info.java |  31 ++
 .../apache/sis/coverage/grid/GridGeometryTest.java |   3 +-
 .../sis/internal/coverage/j2d/ImageLayoutTest.java |  44 +++
 .../internal/coverage/j2d/ImageUtilitiesTest.java  |  24 ++
 .../apache/sis/test/suite/FeatureTestSuite.java    |   1 +
 .../java/org/apache/sis/metadata/SpecialCases.java |   8 +-
 .../main/java/org/apache/sis/portrayal/Canvas.java |   3 +-
 .../org/apache/sis/portrayal/CanvasContext.java    |  38 +-
 .../org/apache/sis/portrayal/PlanarCanvas.java     |   4 +-
 .../gazetteer/MilitaryGridReferenceSystem.java     |   3 +-
 .../java/org/apache/sis/geometry/Shapes2D.java     |  10 +-
 .../operation/matrix/AffineTransforms2D.java       |   1 +
 .../operation/transform/MathTransforms.java        |  23 +-
 .../operation/transform/TransformAdapter2D.java    | 107 ++++++
 .../operation/matrix/AffineTransforms2DTest.java   |   2 +-
 34 files changed, 1684 insertions(+), 648 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 2492e7f..e886291 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
@@ -18,10 +18,9 @@ package org.apache.sis.gui.coverage;
 
 import java.util.Locale;
 import java.util.EnumMap;
-import java.util.Objects;
 import java.awt.Graphics2D;
-import java.awt.geom.AffineTransform;
 import java.awt.image.RenderedImage;
+import java.awt.geom.AffineTransform;
 import javafx.scene.paint.Color;
 import javafx.scene.layout.Region;
 import javafx.scene.layout.Background;
@@ -30,16 +29,20 @@ import javafx.beans.property.ObjectProperty;
 import javafx.beans.property.SimpleObjectProperty;
 import javafx.concurrent.Task;
 import org.opengis.geometry.Envelope;
-import org.opengis.referencing.datum.PixelInCell;
+import org.opengis.util.FactoryException;
 import org.opengis.referencing.operation.TransformException;
+import org.opengis.referencing.crs.CoordinateReferenceSystem;
 import org.apache.sis.coverage.grid.GridCoverage;
 import org.apache.sis.coverage.grid.GridExtent;
 import org.apache.sis.coverage.grid.GridGeometry;
-import org.apache.sis.internal.gui.ImageRenderings;
+import org.apache.sis.coverage.grid.ImageRenderer;
 import org.apache.sis.internal.gui.ExceptionReporter;
-import org.apache.sis.referencing.operation.matrix.AffineTransforms2D;
-import org.apache.sis.referencing.operation.transform.MathTransforms;
+import org.apache.sis.referencing.operation.transform.LinearTransform;
+import org.apache.sis.referencing.IdentifiedObjects;
+import org.apache.sis.image.PlanarImage;
+import org.apache.sis.gui.map.MapCanvas;
 import org.apache.sis.gui.map.MapCanvasAWT;
+import org.apache.sis.internal.gui.Resources;
 
 
 /**
@@ -77,56 +80,35 @@ public class CoverageCanvas extends MapCanvasAWT {
     public final ObjectProperty<GridExtent> sliceExtentProperty;
 
     /**
-     * Different ways to represent the data. The {@link #data} field shall be one value from this map.
+     * The {@code RenderedImage} to draw together with transform from pixel coordinates to display coordinates.
+     * Shall never be {@code null} but may be {@linkplain RenderingData#isEmpty() empty}. This instance shall be
+     * read and modified in JavaFX thread only and cloned if those data needed by a background thread.
      *
-     * @see #setDerivedImage(Stretching, RenderedImage)
-     */
-    private final EnumMap<Stretching,RenderedImage> stretchedColorRamps;
-
-    /**
-     * Key of the currently selected alternative in {@link #stretchedColorRamps} map.
-     *
-     * @see #setDerivedImage(Stretching, RenderedImage)
-     */
-    private Stretching currentDataAlternative;
-
-    /**
-     * The data to show, or {@code null} if not yet specified. This image may be tiled,
-     * and fetching tiles may require computations to be performed in background thread.
-     * The size of this {@code RenderedImage} is not necessarily the {@link #image} size.
-     * In particular {@code data} way cover a larger area.
+     * @see Worker
      */
-    private RenderedImage data;
+    private RenderingData data;
 
     /**
-     * The {@link GridGeometry#getGridToCRS(PixelInCell)} conversion of rendered {@linkplain #data}
-     * as an affine transform. This is often an immutable instance. A null value is synonymous to
-     * identity transform.
+     * The {@link #data} resampled to a CRS which can easily be mapped to {@linkplain #getDisplayCRS() display CRS}.
+     * The different values are slight variants of the values associated to {@link Stretching#NONE}, with only the
+     * color map changed.
      */
-    private AffineTransform gridToCRS;
+    private final EnumMap<Stretching,RenderedImage> resampledImages;
 
     /**
      * Creates a new two-dimensional canvas for {@link RenderedImage}.
      */
     public CoverageCanvas() {
         super(Locale.getDefault());
-        coverageProperty       = new SimpleObjectProperty<>(this, "coverage");
-        sliceExtentProperty    = new SimpleObjectProperty<>(this, "sliceExtent");
-        stretchedColorRamps    = new EnumMap<>(Stretching.class);
-        currentDataAlternative = Stretching.NONE;
+        coverageProperty    = new SimpleObjectProperty<>(this, "coverage");
+        sliceExtentProperty = new SimpleObjectProperty<>(this, "sliceExtent");
+        resampledImages     = new EnumMap<>(Stretching.class);
+        data                = new RenderingData();
         coverageProperty   .addListener((p,o,n) -> onImageSpecified());
         sliceExtentProperty.addListener((p,o,n) -> onImageSpecified());
     }
 
     /**
-     * Returns the data which are the source of all alternative images that may be stored in the
-     * {@link #stretchedColorRamps} map. All alternative images are computed from this source.
-     */
-    private RenderedImage getSourceData() {
-        return stretchedColorRamps.get(Stretching.NONE);
-    }
-
-    /**
      * Returns the region containing the image view.
      * The subclass is implementation dependent and may change in any future version.
      *
@@ -202,249 +184,217 @@ public class CoverageCanvas extends MapCanvasAWT {
         if (coverage == null) {
             clear();
         } else {
-            execute(new Process(coverage, currentDataAlternative));
+            final GridExtent sliceExtent = getSliceExtent();
+            execute(new Task<RenderedImage>() {
+                /**
+                 * The coverage geometry reduced to two dimensions and with a translation taking in account
+                 * the {@code sliceExtent}. That value will be stored in {@link CoverageCanvas#dataGeometry}.
+                 */
+                private GridGeometry imageGeometry;
+
+                /**
+                 * Invoked in a background thread for fetching the image and computing its geometry. The image
+                 * geometry should be provided by {@value PlanarImage#GRID_GEOMETRY_KEY} property. But if that
+                 * property is not provided, {@link ImageRenderer} is used as a fallback for computing it.
+                 */
+                @Override protected RenderedImage call() throws FactoryException {
+                    final RenderedImage image = coverage.render(sliceExtent);
+                    final Object value = image.getProperty(PlanarImage.GRID_GEOMETRY_KEY);
+                    imageGeometry = (value instanceof GridGeometry) ? (GridGeometry) value
+                                  : new ImageRenderer(coverage, sliceExtent).getImageGeometry(BIDIMENSIONAL);
+                    return image;
+                }
+
+                /**
+                 * Invoked when an error occurred while loading an image or processing it.
+                 * This method popups the dialog box immediately because it is considered
+                 * an important error.
+                 */
+                @Override protected void failed() {
+                    final Throwable ex = getException();
+                    errorOccurred(ex);
+                    ExceptionReporter.canNotUseResource(ex);
+                }
+
+                /**
+                 * Invoked in JavaFX thread for setting the image to the instance we juste fetched.
+                 */
+                @Override protected void succeeded() {
+                    setRawImage(getValue(), imageGeometry);
+                }
+            });
         }
     }
 
     /**
-     * Invoked when the user selected a new color stretching mode. Also invoked {@linkplain #setRawImage after
-     * loading a new image or a new slice} for switching the new image to the same type of range as previously
-     * selected. If the image for the specified type is not already available, then this method computes the
-     * image in a background thread and refreshes the view after the computation completed.
+     * Invoked when a new image has been successfully loaded. The given image must be the "raw" image,
+     * without resampling and without color ramp stretching. The call to this method is followed by a
+     * a repaint event, which will cause the image to be resampled in a background thread.
      */
-    final void setStretching(final Stretching type) {
-        currentDataAlternative = type;
-        final RenderedImage alt = stretchedColorRamps.get(type);
-        if (alt != null) {
-            setDerivedImage(type, alt);
-        } else {
-            final RenderedImage source = getSourceData();
-            if (source != null) {
-                execute(new Process(source, type));
-            }
+    private void setRawImage(final RenderedImage image, final GridGeometry imageGeometry) {
+        resampledImages.clear();
+        data.setImage(image, imageGeometry);
+        Envelope bounds = null;
+        if (imageGeometry != null && imageGeometry.isDefined(GridGeometry.ENVELOPE)) {
+            bounds = imageGeometry.getEnvelope();
         }
+        setObjectiveBounds(bounds);
+        requestRepaint();   // Cause `Worker` class to be executed.
+    }
+
+    /**
+     * Invoked in JavaFX thread for creating a renderer to be executed in a background thread.
+     * This method prepares the information needed but does not start the rendering itself.
+     * The rendering will be done later by a call to {@link Renderer#paint(Graphics2D)}.
+     */
+    @Override
+    protected Renderer createRenderer() {
+        return data.isEmpty() ? null : new Worker(this);
     }
 
     /**
-     * Loads or resample images before to show them in the canvas. This class performs some or all of
-     * the following tasks, in order. It is possible to skip the first tasks if they are already done,
-     * but after the work started at some point all remaining points are executed:
+     * Resample and paint image in the canvas. This class performs some or all of the following tasks, in order.
+     * It is possible to skip the first tasks if they are already done, but after the work started at some point
+     * all remaining points are executed:
      *
      * <ol>
-     *   <li>Loads the image.</li>
      *   <li>Compute statistics on sample values (if needed).</li>
-     *   <li>Reproject the image (if needed).</li>
+     *   <li>Resample the image (if needed).</li>
+     *   <li>Paint the image.</li>
      * </ol>
      */
-    private final class Process extends Task<RenderedImage> {
+    private static final class Worker extends Renderer {
         /**
-         * The coverage from which to fetch an image, or {@code null} if the {@link #source} is already known.
+         * Value of {@link CoverageCanvas#data} at the time this worker has been initialized.
          */
-        private final GridCoverage coverage;
+        private final RenderingData data;
 
         /**
-         * The {@linkplain #coverage} slice to fetch, or {@code null} if {@link #coverage} is null
-         * or for loading the whole coverage extent.
+         * The coordinate reference system in which to reproject the data.
          */
-        private final GridExtent sliceExtent;
+        private final CoordinateReferenceSystem objectiveCRS;
 
         /**
-         * The source image, or {@code null} if it will be the result of fetching an image from
-         * the {@linkplain #coverage}. If non-null then it should be {@link #getSourceData()}.
+         * The conversion from {@link #objectiveCRS} to the canvas display CRS.
          */
-        private RenderedImage source;
+        private final LinearTransform objectiveToDisplay;
 
         /**
-         * The color ramp stretching to apply, or {@link Stretching#NONE} if none.
+         * The source image after resampling.
          */
-        private final Stretching stretching;
+        private RenderedImage resampledImage;
 
         /**
-         * Creates a new process which will load data from the specified coverage.
+         * The resampled image after stretching.
          */
-        Process(final GridCoverage coverage, final Stretching stretching) {
-            this.coverage    = coverage;
-            this.sliceExtent = getSliceExtent();
-            this.stretching  = stretching;
-        }
+        private RenderedImage stretchedImage;
 
         /**
-         * Creates a new process which will resample the given image.
+         * Conversion from {@link #resampledImage} (also {@link #stretchedImage}) pixel coordinates
+         * to display coordinates.
          */
-        Process(final RenderedImage source, final Stretching stretching) {
-            this.coverage    = null;
-            this.sliceExtent = null;
-            this.source      = source;
-            this.stretching  = stretching;
+        private AffineTransform resampledToDisplay;
+
+        /**
+         * Creates a new renderer.
+         */
+        Worker(final CoverageCanvas canvas) {
+            data               = canvas.data.clone();
+            objectiveCRS       = canvas.getObjectiveCRS();
+            objectiveToDisplay = canvas.getObjectiveToDisplay();
+            resampledImage     = canvas.resampledImages.get(Stretching.NONE);
+            stretchedImage     = canvas.resampledImages.get(data.selectedStretching);
         }
 
         /**
-         * Invoked in background thread for fetching the image, stretching the color ramp or resampling.
-         * This method performs some or all steps documented in class Javadoc, with possibility to skip
-         * the first step is required source image is already loaded.
+         * Invoked in background thread for resampling the image or stretching the color ramp.
+         * This method performs some of the steps documented in class Javadoc, with possibility
+         * to skip the first step if the required source image is already resampled.
          */
-        @Override protected RenderedImage call() {
-            if (source == null) {
-                source = coverage.render(sliceExtent);
+        @Override
+        @SuppressWarnings("PointlessBitwiseExpression")
+        protected void render() throws TransformException {
+            boolean isResampled = (resampledImage != null);
+            if (isResampled) {
+                resampledToDisplay = data.getTransform(objectiveToDisplay);
+                // Recompute if anything else than identity or translation.
+                isResampled = (resampledToDisplay.getType()
+                        & ~(AffineTransform.TYPE_IDENTITY | AffineTransform.TYPE_TRANSLATION)) == 0;
             }
-            final RenderedImage derived;
-            switch (stretching) {
-                case VALUE_RANGE: derived = ImageRenderings.valueRangeStretching(source); break;
-                case AUTOMATIC:   derived = ImageRenderings. automaticStretching(source); break;
-                default:          derived = source; break;
+            if (!isResampled) {
+                stretchedImage = null;
+                resampledImage = data.resample(objectiveCRS, objectiveToDisplay);
+                resampledToDisplay = data.getTransform(objectiveToDisplay);
+            }
+            if (stretchedImage == null) {
+                stretchedImage = data.stretch(resampledImage);
             }
-            return derived;
         }
 
         /**
-         * Invoked in JavaFX thread on success. This method stores the computation results, provided that
-         * the settings ({@link #coverage}, source image, <i>etc.</i>) are still the ones for which the
-         * computation has been launched.
+         * Draws the image in a background buffer after {@link #render()} finished to prepare data.
          */
-        @Override protected void succeeded() {
-            /*
-             * The image is shown only if the coverage and extent did not changed during the time we were
-             * loading in background thread (if they changed, another thread is probably running for them).
-             * After `setRawImage(…)` execution, `getSourceData()` should return the given `source`.
-             */
-            if (coverage != null && coverage.equals(getCoverage()) && Objects.equals(sliceExtent, getSliceExtent())) {
-                setRawImage(source, coverage.getGridGeometry(), sliceExtent);
-            }
-            /*
-             * The stretching result is stored only if the user did not changed the image while we were computing
-             * statistics in background thread. This method does not verify if user changed the stretching mode;
-             * this check will be done by `setDerivedImage(…)`.
-             */
-            if (source.equals(getSourceData())) {
-                setDerivedImage(stretching, getValue());
-            }
+        @Override
+        protected void paint(final Graphics2D gr) {
+            gr.drawRenderedImage(stretchedImage, resampledToDisplay);
         }
 
         /**
-         * Invoked when an error occurred while loading an image or processing it.
-         * This method popups the dialog box immediately because it is considered
-         * an important error.
+         * Invoked in JavaFX thread after {@link #paint(Graphics2D)} completion. This method stores
+         * the computation results.
          */
-        @Override protected void failed() {
-            final Throwable ex = getException();
-            errorOccurred(ex);
-            ExceptionReporter.canNotUseResource(ex);
+        @Override
+        protected boolean commit(final MapCanvas canvas) {
+            ((CoverageCanvas) canvas).cacheRenderingData(this);
+            return super.commit(canvas);
         }
     }
 
     /**
-     * Invoked when a new image has been successfully loaded. The given image must the the "raw" image,
-     * without resampling and without color ramp stretching. The {@link #setDerivedImage} method may
-     * be invoked after this method for specifying image derived from this raw image.
-     *
-     * @todo Needs to handle non-affine transform.
-     *
-     * @param  image        the image to load.
-     * @param  geometry     the grid geometry of the coverage that produced the image.
-     * @param  sliceExtent  the extent that was requested.
+     * Invoked after a paint event for caching rendering data.
+     * If the resampled image changed, all previously cached images are discarded.
      */
-    private void setRawImage(final RenderedImage image, final GridGeometry geometry, GridExtent sliceExtent) {
-        data = null;
-        stretchedColorRamps.clear();
-        setDerivedImage(Stretching.NONE, image);
-        try {
-            gridToCRS = AffineTransforms2D.castOrCopy(geometry.getGridToCRS(PixelInCell.CELL_CENTER));
-        } catch (RuntimeException e) {                      // Conversion not defined or not affine.
-            gridToCRS = null;
-            errorOccurred(e);
-        }
-        /*
-         * If the user did not specified a sub-region, set the initial visible area to the envelope
-         * of the whole coverage. The `setObjectiveBounds(…)` method will take care of computing an
-         * initial "objective to display" transform from that information.
-         */
-        Envelope visibleArea = null;
-        if (sliceExtent == null) {
-            if (gridToCRS != null && geometry.isDefined(GridGeometry.ENVELOPE)) {
-                // This envelope is valid only if we are able to use the `gridToCRS`.
-                visibleArea = geometry.getEnvelope();
-            }
-            if (geometry.isDefined(GridGeometry.EXTENT)) {
-                sliceExtent = geometry.getExtent();
-            }
-        }
-        /*
-         * If geospatial area declared in grid geometry can not be used, compute it from grid extent.
-         * It is the case for example when only a sub-region has been fetched.
-         */
-        if (sliceExtent != null) {
-            if (visibleArea == null) try {
-                visibleArea = sliceExtent.toEnvelope((gridToCRS != null)
-                                ? AffineTransforms2D.toMathTransform(gridToCRS)
-                                : MathTransforms.identity(sliceExtent.getDimension()));
-            } catch (TransformException e) {
-                // Should never happen because we used an affine transform.
-                errorOccurred(e);
-            }
-            /*
-             * Coordinate (0,0) in the image corresponds to the lowest coordinates requested.
-             * For taking that offset in account, we need to apply a translation.
-             */
-            if (gridToCRS != null) {
-                final int[] dimensions = sliceExtent.getSubspaceDimensions(BIDIMENSIONAL);
-                final long tx = sliceExtent.getLow(dimensions[0]);
-                final long ty = sliceExtent.getLow(dimensions[1]);
-                if ((tx | ty) != 0) {
-                    gridToCRS = new AffineTransform(gridToCRS);
-                    gridToCRS.translate(tx, ty);
-                }
-            }
+    private void cacheRenderingData(final Worker worker) {
+        data = worker.data;
+        final RenderedImage newValue = worker.resampledImage;
+        final RenderedImage oldValue = resampledImages.put(Stretching.NONE, newValue);
+        if (oldValue != newValue && oldValue != null) {
+            resampledImages.clear();
+            resampledImages.put(Stretching.NONE, newValue);
         }
-        setObjectiveBounds(visibleArea);
+        resampledImages.put(data.selectedStretching, worker.stretchedImage);
     }
 
     /**
-     * Invoked in JavaFX thread for setting the image to show. The given image should be a slice
-     * produced by current value of {@link #coverageProperty} (should be verified by the caller).
-     *
-     * @param  type  the type of range used for scaling the color ramp of given image.
-     * @param  alt   the image or alternative image to show (can be {@code null}).
+     * Invoked when the user selected a new color stretching mode.
      */
-    private void setDerivedImage(final Stretching type, RenderedImage alt) {
-        /*
-         * Store the result but do not necessarily show it because maybe the user changed the
-         * `Stretching` during the time the background thread was working. If the user did not
-         * changed the type, then the `alt` variable below will stay unchanged.
-         */
-        stretchedColorRamps.put(type, alt);
-        alt = stretchedColorRamps.get(currentDataAlternative);
-        if (!Objects.equals(alt, data)) {
-            data = alt;
-            requestRepaint();
-        }
+    final void setStretching(final Stretching type) {
+        data.selectedStretching = type;
+        requestRepaint();
     }
 
     /**
-     * Invoked in JavaFX thread for creating a renderer to be executed in a background thread.
-     * This method prepares the information needed but does not start the rendering itself.
-     * The rendering will be done later by a call to {@link Renderer#paint(Graphics2D)}.
+     * Sets the Coordinate Reference System in which all data are transformed before displaying.
+     * The new CRS must be compatible with the previous CRS, i.e. a coordinate operation between
+     * the two CRSs shall exist. If the CRS can not be set to the specified value, then an error
+     * message is shown in the status bar.
+     *
+     * @param  crs  the new Coordinate Reference System in which to transform all data before displaying.
      */
     @Override
-    protected Renderer createRenderer() {
-        final RenderedImage data = this.data;       // Need to copy this reference here before background task.
-        if (data == null) {
-            return null;
-        }
-        /*
-         * At each rendering operation, compute the transform from `data` cell coordinates to pixel coordinates
-         * of the image shown in this view. We do this computation every times because `objectiveToDisplay` may
-         * vary at any time, and also because we need a new `AffineTransform` instance anyway (we can not reuse
-         * an existing instance, because it needs to be stable for use by the background thread).
-         */
-        final AffineTransform gridToDisplay = new AffineTransform(objectiveToDisplay);
-        if (gridToCRS != null) {
-            gridToDisplay.concatenate(gridToCRS);
+    public void setObjectiveCRS(final CoordinateReferenceSystem crs) {
+        resampledImages.clear();
+        data.clearCRS();
+        try {
+            super.setObjectiveCRS(crs);
+        } catch (Exception e) {
+            errorOccurred(e);
+            final Locale locale = getLocale();
+            final Resources i18n = Resources.forLocale(locale);
+            ExceptionReporter.show(null, i18n.getString(Resources.Keys.CanNotUseRefSys_1,
+                    IdentifiedObjects.getDisplayName(crs, locale)), e);
         }
-        return new Renderer() {
-            @Override protected void paint(final Graphics2D gr) {
-                gr.drawRenderedImage(data, gridToDisplay);
-            }
-        };
+        requestRepaint();
     }
 
     /**
@@ -452,8 +402,7 @@ public class CoverageCanvas extends MapCanvasAWT {
      */
     @Override
     protected void clear() {
-        data = null;
-        stretchedColorRamps.clear();
+        setRawImage(null, null);
         super.clear();
     }
 }
diff --git a/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/CoverageControls.java b/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/CoverageControls.java
index 52f92b3..a131c5d 100644
--- a/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/CoverageControls.java
+++ b/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/CoverageControls.java
@@ -29,6 +29,7 @@ import javafx.beans.property.ObjectProperty;
 import javafx.scene.control.ChoiceBox;
 import javafx.scene.paint.Color;
 import org.opengis.referencing.ReferenceSystem;
+import org.opengis.referencing.crs.CoordinateReferenceSystem;
 import org.apache.sis.coverage.grid.GridCoverage;
 import org.apache.sis.gui.referencing.RecentReferenceSystems;
 import org.apache.sis.gui.map.StatusBar;
@@ -88,7 +89,11 @@ final class CoverageControls extends Controls {
          */
         final VBox displayPane;
         {   // Block for making variables locale to this scope.
-            final ChoiceBox<ReferenceSystem> systems = referenceSystems.createChoiceBox((p,o,n) -> onReferenceSystemSelected(n));
+            final ChoiceBox<ReferenceSystem> systems = referenceSystems.createChoiceBox((p,o,n) -> {
+                if (n instanceof CoordinateReferenceSystem) {
+                    view.setObjectiveCRS((CoordinateReferenceSystem) n);
+                }
+            });
             systems.setMaxWidth(Double.POSITIVE_INFINITY);
             referenceSystem = systems.valueProperty();
             final Label systemLabel = new Label(vocabulary.getLabel(Vocabulary.Keys.ReferenceSystem));
@@ -140,12 +145,6 @@ final class CoverageControls extends Controls {
     }
 
     /**
-     * Invoked when a new coordinate reference system is selected.
-     */
-    private void onReferenceSystemSelected(final ReferenceSystem newValue) {
-    }
-
-    /**
      * Returns the main component, which is showing coverage tabular data.
      */
     @Override
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
new file mode 100644
index 0000000..76dfc0d
--- /dev/null
+++ b/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/RenderingData.java
@@ -0,0 +1,291 @@
+/*
+ * 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.gui.coverage;
+
+import java.util.Map;
+import java.util.HashMap;
+import java.awt.Graphics2D;
+import java.awt.Rectangle;
+import java.awt.image.BufferedImage;
+import java.awt.image.RenderedImage;
+import java.awt.geom.AffineTransform;
+import org.opengis.util.FactoryException;
+import org.opengis.referencing.datum.PixelInCell;
+import org.opengis.referencing.operation.CoordinateOperation;
+import org.opengis.referencing.operation.MathTransform;
+import org.opengis.referencing.operation.TransformException;
+import org.opengis.referencing.crs.CoordinateReferenceSystem;
+import org.apache.sis.coverage.grid.GridCoverage;
+import org.apache.sis.coverage.grid.GridGeometry;
+import org.apache.sis.coverage.grid.GridExtent;
+import org.apache.sis.geometry.Shapes2D;
+import org.apache.sis.image.Interpolation;
+import org.apache.sis.image.ImageProcessor;
+import org.apache.sis.internal.coverage.j2d.ImageUtilities;
+import org.apache.sis.internal.coverage.j2d.PreferredSize;
+import org.apache.sis.internal.system.Modules;
+import org.apache.sis.math.Statistics;
+import org.apache.sis.metadata.iso.extent.DefaultGeographicBoundingBox;
+import org.apache.sis.referencing.operation.transform.LinearTransform;
+import org.apache.sis.referencing.operation.transform.MathTransforms;
+import org.apache.sis.referencing.operation.matrix.AffineTransforms2D;
+import org.apache.sis.referencing.CRS;
+import org.apache.sis.util.logging.Logging;
+
+
+/**
+ * The {@code RenderedImage} to draw in a {@link CoverageCanvas} together with transform
+ * from pixel coordinates to display coordinates.
+ *
+ * <h2>Note on Java2D optimizations</h2>
+ * {@link Graphics2D#drawRenderedImage(RenderedImage, AffineTransform)} implementation
+ * has the following optimizations:
+ *
+ * <ul class="verbose">
+ *   <li>If the image is an instance of {@link BufferedImage},
+ *       then the {@link AffineTransform} can be anything. Java2D applies interpolations efficiently.</li>
+ *   <li>Otherwise if the {@link AffineTransform} scale factors are 1 and the translations are integers,
+ *       then Java2D invokes {@link RenderedImage#getTile(int, int)}. It make possible for us to create
+ *       a very large image covering the whole data but with tiles computed only when first requested.</li>
+ *   <li>Otherwise Java2D invokes {@link RenderedImage#getData(Rectangle)}, which is more costly.
+ *       We try to avoid that situation.</li>
+ * </ul>
+ *
+ * Consequently our strategy is to prepare a resampled image for the whole data when the zoom level changed
+ * and rely on tiling for reducing actual computations to required tiles. Since pan gestures are expressed
+ * in pixel coordinates, the translation terms in {@code resampledToDisplay} transform should stay integers.
+ *
+ * @todo This class does not perform a special case for {@link BufferedImage}. We wait to see if this class
+ *       works well in the general case before doing special cases.
+ *
+ * @author  Martin Desruisseaux (Geomatys)
+ * @version 1.1
+ * @since   1.1
+ * @module
+ */
+final class RenderingData implements Cloneable {
+    /**
+     * The data fetched from {@link GridCoverage#render(GridExtent)} for current {@code sliceExtent}.
+     * This rendered image may be tiled and fetching those tiles may require computations to be performed
+     * in background threads. Pixels in this {@code data} image are mapped to pixels in the display
+     * {@link CoverageCanvas#image} by the following chain of operations:
+     *
+     * <ol>
+     *   <li><code>{@linkplain #dataGeometry}.getGridGeometry(CELL_CENTER)</code></li>
+     *   <li><code>{@linkplain #changeOfCRS}.getMathTransform()</code></li>
+     *   <li>{@link CoverageCanvas#getObjectiveToDisplay()}</li>
+     * </ol>
+     */
+    private RenderedImage data;
+
+    /**
+     * Conversion from {@link #data} pixel coordinates to the coverage CRS, together with geospatial area.
+     * It contains the {@link GridGeometry#getGridToCRS(PixelInCell)} value of {@link GridCoverage} reduced
+     * to two dimensions and with a translation added for taking in account the requested {@code sliceExtent}.
+     * The coverage CRS is initially the same as the {@linkplain CoverageCanvas#getObjectiveCRS() objective CRS},
+     * but may become different later if user selects a different objective CRS.
+     */
+    private GridGeometry dataGeometry;
+
+    /**
+     * Conversion or transformation from {@linkplain #data} CRS to {@linkplain CoverageCanvas#getObjectiveCRS()
+     * objective CRS}, or {@code null} if not yet computed. This is an identity operation if the user did not
+     * selected a different CRS after the coverage has been shown.
+     */
+    private CoordinateOperation changeOfCRS;
+
+    /**
+     * The conversion from {@link #data} pixel coordinates to {@linkplain CoverageCanvas#getObjectiveCRS()
+     * objective CRS}. This is {@link GridGeometry#getGridToCRS(PixelInCell)} on {@link #dataGeometry}
+     * concatenated with {@link #changeOfCRS}. May be {@code null} if not yet computed.
+     */
+    private MathTransform cornerToObjective, centerToObjective;
+
+    /**
+     * The inverse of the {@linkplain CoverageCanvas#objectiveToDisplay objective to display} transform which was
+     * active at the time resampled images have been computed. The concatenation of this transform with the actual
+     * "objective to display" transform at the time the rendered image is drawn should be a translation.
+     */
+    private AffineTransform displayToObjective;
+
+    /**
+     * Key of the currently selected alternative in {@link CoverageCanvas#resampledImages} map.
+     */
+    Stretching selectedStretching;
+
+    /**
+     * Statistics on pixel values of current {@link #data}, or {@code null} if none or not yet computed.
+     * There is one {@link Statistics} instance per band.
+     */
+    private Statistics[] statistics;
+
+    /**
+     * The processor that we use for resampling image and stretching their color ramps.
+     */
+    private ImageProcessor processor;
+
+    /**
+     * Creates a new instance initialized to no image.
+     *
+     * @todo Listen to logging messages. We need to create a logging panel first.
+     */
+    RenderingData() {
+        selectedStretching = Stretching.NONE;
+        processor = new ImageProcessor();
+        processor.setErrorAction(ImageProcessor.ErrorAction.LOG);
+    }
+
+    /**
+     * Returns {@code true} if this object has no data.
+     */
+    final boolean isEmpty() {
+        return data == null;
+    }
+
+    /**
+     * Clears the cache of transforms that depend on the CRS.
+     */
+    final void clearCRS() {
+        changeOfCRS       = null;
+        cornerToObjective = null;
+        centerToObjective = null;
+    }
+
+    /**
+     * Sets the data to given image, which can be {@code null}.
+     */
+    final void setImage(final RenderedImage data, final GridGeometry dataGeometry) {
+        clearCRS();
+        displayToObjective = null;
+        statistics         = null;
+        this.data          = data;
+        this.dataGeometry  = dataGeometry;
+    }
+
+    /**
+     * Sets the interpolation method to use during resample operations.
+     */
+    final void setInterpolation(final Interpolation newValue) {
+        processor = processor.clone();          // Previous processor may be in use by background thread.
+        processor.setInterpolation(newValue);
+    }
+
+    /**
+     * 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.
+     */
+    final RenderedImage resample(final CoordinateReferenceSystem objectiveCRS,
+            final LinearTransform objectiveToDisplay) throws TransformException
+    {
+        if (changeOfCRS == null && objectiveCRS != null && dataGeometry.isDefined(GridGeometry.CRS)) {
+            DefaultGeographicBoundingBox areaOfInterest = null;
+            if (dataGeometry.isDefined(GridGeometry.ENVELOPE)) try {
+                areaOfInterest = new DefaultGeographicBoundingBox();
+                areaOfInterest.setBounds(dataGeometry.getEnvelope());
+            } catch (TransformException e) {
+                recoverableException(e);
+                // Leave `areaOfInterest` to null.
+            }
+            try {
+                changeOfCRS = CRS.findOperation(dataGeometry.getCoordinateReferenceSystem(), objectiveCRS, areaOfInterest);
+            } catch (FactoryException e) {
+                recoverableException(e);
+                // Leave `changeOfCRS` to null.
+            }
+        }
+        if (cornerToObjective == null || centerToObjective == null) {
+            cornerToObjective = dataGeometry.getGridToCRS(PixelInCell.CELL_CORNER);
+            centerToObjective = dataGeometry.getGridToCRS(PixelInCell.CELL_CENTER);
+            if (changeOfCRS != null) {
+                final MathTransform tr = changeOfCRS.getMathTransform();
+                cornerToObjective = MathTransforms.concatenate(cornerToObjective, tr);
+                centerToObjective = MathTransforms.concatenate(centerToObjective, tr);
+            }
+        }
+        /*
+         * Create a resampled image for current zoom level. If the image is zoomed, the resampled image bounds
+         * will be very large, potentially larger than 32 bit integer capacity (calculation done below clamps
+         * the result to 32 bit integer range). This is okay since only visible tiles will be created.
+         *
+         * TODO: if user pans the image close to integer range limit, we should create a new resampled image
+         *       shifted to new location (i.e. clear `CoverageCanvas.resampledImages` for forcing this method
+         *       to be invoked again).
+         */
+        final LinearTransform inverse = objectiveToDisplay.inverse();
+        displayToObjective = AffineTransforms2D.castOrCopy(inverse);
+        final MathTransform cornerToDisplay = MathTransforms.concatenate(cornerToObjective, objectiveToDisplay);
+        final MathTransform displayToCenter = MathTransforms.concatenate(inverse, centerToObjective.inverse());
+        final PreferredSize bounds = (PreferredSize) Shapes2D.transform(
+                MathTransforms.bidimensional(cornerToDisplay),
+                ImageUtilities.getBounds(data), new PreferredSize());
+        return processor.resample(bounds, displayToCenter, data);
+    }
+
+    /**
+     * Creates the stretched image from the given resampled image.
+     */
+    final RenderedImage stretch(final RenderedImage resampledImage) {
+        if (selectedStretching != Stretching.NONE) {
+            if (statistics == null) {
+                statistics = processor.getStatistics(data);
+            }
+            final Map<String,Object> modifiers = new HashMap<>(4);
+            modifiers.put("statistics", statistics);
+            if (selectedStretching == Stretching.AUTOMATIC) {
+                modifiers.put("MultStdDev", 3);
+            }
+            return processor.stretchColorRamp(resampledImage, modifiers);
+        }
+        return resampledImage;
+    }
+
+    /**
+     * Gets the transform to use for painting the stretched image. If the image to draw is an instance of
+     * {@link BufferedImage}, then it is okay to have any transform. However for other kinds of image,
+     * it is important that the transform has scale factors of 1 and integer translations because Java2D
+     * has an optimization which avoid to copy the whole data only for that case.
+     */
+    final AffineTransform getTransform(final LinearTransform objectiveToDisplay) {
+        AffineTransform resampledToDisplay = AffineTransforms2D.castOrCopy(objectiveToDisplay);
+        if (resampledToDisplay == objectiveToDisplay) {
+            resampledToDisplay = new AffineTransform(resampledToDisplay);
+        }
+        resampledToDisplay.concatenate(displayToObjective);
+        ImageUtilities.roundIfAlmostInteger(resampledToDisplay);
+        return resampledToDisplay;
+    }
+
+    /**
+     * Invoked when an exception occurred while computing a transform but the painting process can continue.
+     * This method pretends that the warning come from {@link CoverageCanvas} class since it is the public API.
+     */
+    private static void recoverableException(final Exception e) {
+        Logging.recoverableException(Logging.getLogger(Modules.APPLICATION), CoverageCanvas.class, "render", e);
+    }
+
+    /**
+     * Creates new rendering data initialized to a copy of this instance.
+     */
+    @Override
+    public RenderingData clone() {
+        try {
+            return (RenderingData) super.clone();
+        } catch (CloneNotSupportedException e) {
+            throw new AssertionError(e);
+        }
+    }
+}
diff --git a/application/sis-javafx/src/main/java/org/apache/sis/gui/map/MapCanvas.java b/application/sis-javafx/src/main/java/org/apache/sis/gui/map/MapCanvas.java
index e3ed2b0..11c7799 100644
--- a/application/sis-javafx/src/main/java/org/apache/sis/gui/map/MapCanvas.java
+++ b/application/sis-javafx/src/main/java/org/apache/sis/gui/map/MapCanvas.java
@@ -92,8 +92,8 @@ import org.apache.sis.portrayal.RenderException;
  *     of every information needed for performing the rendering in background.</li>
  *   <li>{@link Renderer#render()} is invoked in a background thread. That method creates or updates
  *     the nodes to show in this {@code MapCanvas} but without interacting with the canvas yet.</li>
- *   <li>{@link Renderer#commit()} is invoked in the JavaFX thread. The nodes prepared by {@code render()}
- *     can be transferred to {@link #floatingPane} in that method.</li>
+ *   <li>{@link Renderer#commit(MapCanvas)} is invoked in the JavaFX thread. The nodes prepared by
+ *     {@code render()} can be transferred to {@link #floatingPane} in that method.</li>
  * </ol>
  *
  * @author  Martin Desruisseaux (Geomatys)
@@ -490,10 +490,14 @@ public abstract class MapCanvas extends PlanarCanvas {
 
     /**
      * Sets the data bounds to use for computing the initial value of {@link #objectiveToDisplay}.
-     * This method should be invoked only when new data have been loaded, or when the caller wants
-     * to discard any zoom or translation and reset the view to the given bounds.
+     * Invoking this method also sets the {@link #getObjectiveCRS() objective CRS} of this canvas
+     * to the CRS of given envelope.
      *
-     * @param  visibleArea  bounding box in objective CRS of the initial area to show,
+     * <p>This method should be invoked only when new data have been loaded, or when the caller wants
+     * to discard any zoom or translation and reset the view to the given bounds. This method does not
+     * cause new repaint event; {@link #requestRepaint()} must be invoked by the caller if desired.</p>
+     *
+     * @param  visibleArea  bounding box in (new) objective CRS of the initial area to show,
      *         or {@code null} if unknown (in which case an identity transform will be set).
      *
      * @see #setObjectiveCRS(CoordinateReferenceSystem)
@@ -525,10 +529,13 @@ public abstract class MapCanvas extends PlanarCanvas {
      *   <li>{@link MapCanvas} invokes {@link #render()} in a background thread. That method creates or
      *     updates the nodes to show in the canvas but without reading or writing any canvas property;
      *     that method should use only the snapshot taken in step 1.</li>
-     *   <li>{@link MapCanvas} invokes {@link #commit()} in the JavaFX thread. The nodes prepared at
-     *     step 2 can be transferred to {@link MapCanvas#floatingPane} in that method.</li>
+     *   <li>{@link MapCanvas} invokes {@link #commit(MapCanvas)} in the JavaFX thread. The nodes prepared
+     *     at step 2 can be transferred to {@link MapCanvas#floatingPane} in that method.</li>
      * </ol>
      *
+     * This class should not access any {@link MapCanvas} property from a method invoked in background thread
+     * ({@link #render()}). It may access {@link MapCanvas} properties from the {@link #commit(MapCanvas)} method.
+     *
      * @author  Martin Desruisseaux (Geomatys)
      * @version 1.1
      * @since   1.1
@@ -579,18 +586,22 @@ public abstract class MapCanvas extends PlanarCanvas {
          * Invoked in a background thread for rendering the map. This method should not access any
          * {@link MapCanvas} property; if some canvas properties are needed, they should have been
          * copied at construction time.
+         *
+         * @throws TransformException if the rendering required coordinate transformation and that
+         *         operation failed.
          */
-        protected abstract void render();
+        protected abstract void render() throws TransformException;
 
         /**
          * Invoked in JavaFX thread after {@link #render()} completion. This method can update the
          * {@link #floatingPane} children with the nodes (images, shaped, <i>etc.</i>) created by
          * {@link #render()}.
          *
+         * @param  canvas  the canvas where drawing has been done.
          * @return {@code true} on success, or {@code false} if the rendering should be redone
          *         (for example because a change has been detected in the data).
          */
-        protected abstract boolean commit();
+        protected abstract boolean commit(MapCanvas canvas);
     }
 
     /**
@@ -730,14 +741,14 @@ public abstract class MapCanvas extends PlanarCanvas {
     Task<?> createWorker(final Renderer renderer) {
         return new Task<Void>() {
             /** Invoked in background thread. */
-            @Override protected Void call() {
+            @Override protected Void call() throws TransformException {
                 renderer.render();
                 return null;
             }
 
             /** Invoked in JavaFX thread on success. */
             @Override protected void succeeded() {
-                final boolean done = renderer.commit();
+                final boolean done = renderer.commit(MapCanvas.this);
                 renderingCompleted(this);
                 if (!done || contentsChanged()) {
                     repaint();
@@ -879,5 +890,6 @@ public abstract class MapCanvas extends PlanarCanvas {
         objectiveBounds = null;
         error.set(null);
         isRendering.set(false);
+        requestRepaint();
     }
 }
diff --git a/application/sis-javafx/src/main/java/org/apache/sis/gui/map/MapCanvasAWT.java b/application/sis-javafx/src/main/java/org/apache/sis/gui/map/MapCanvasAWT.java
index 36a1059..57cd3c9 100644
--- a/application/sis-javafx/src/main/java/org/apache/sis/gui/map/MapCanvasAWT.java
+++ b/application/sis-javafx/src/main/java/org/apache/sis/gui/map/MapCanvasAWT.java
@@ -32,6 +32,7 @@ import javafx.scene.image.PixelFormat;
 import javafx.scene.image.WritableImage;
 import javafx.concurrent.Task;
 import javafx.util.Callback;
+import org.opengis.referencing.operation.TransformException;
 import org.apache.sis.internal.coverage.j2d.ColorModelFactory;
 
 
@@ -134,9 +135,13 @@ public abstract class MapCanvasAWT extends MapCanvas {
      *   <tr><td>{@link #createRenderer()}</td>  <td>JavaFX thread</td>     <td></td></tr>
      *   <tr><td>{@link #render()}</td>          <td>Background thread</td> <td></td></tr>
      *   <tr><td>{@link #paint(Graphics2D)}</td> <td>Background thread</td> <td>May be invoked many times.</td></tr>
-     *   <tr><td>{@link #commit()}</td>          <td>JavaFX thread</td>     <td></td></tr>
+     *   <tr><td>{@link #commit(MapCanvas)}</td> <td>JavaFX thread</td>     <td></td></tr>
      * </table>
      *
+     * This class should not access any {@link MapCanvasAWT} property from a method invoked in background thread
+     * ({@link #render()} and {@link #paint(Graphics2D)}). It may access {@link MapCanvasAWT} properties from the
+     * {@link #commit(MapCanvas)} method.
+     *
      * @author  Martin Desruisseaux (Geomatys)
      * @version 1.1
      * @since   1.1
@@ -165,9 +170,12 @@ public abstract class MapCanvasAWT extends MapCanvas {
          * advance allow to hold the {@link Graphics2D} handler for a shorter time.
          *
          * <p>The default implementation does nothing.</p>
+         *
+         * @throws TransformException if the rendering required coordinate transformation and that
+         *         operation failed.
          */
         @Override
-        protected void render() {
+        protected void render() throws TransformException {
         }
 
         /**
@@ -181,17 +189,20 @@ public abstract class MapCanvasAWT extends MapCanvas {
         protected abstract void paint(Graphics2D gr);
 
         /**
-         * Invoked in JavaFX thread after {@link #render()} completion. This method can update the
-         * {@link #floatingPane} children with the nodes (images, shaped, <i>etc.</i>) created by
-         * {@link #render()}.
+         * Invoked in JavaFX thread after {@link #paint(Graphics2D)} completion. This method can update the
+         * {@link #floatingPane} children with the nodes (images, shaped, <i>etc.</i>) created by {@link #render()}.
+         * If this method detects that data has changed during the time {@code Renderer} was working in background,
+         * then this method can return {@code true} for requesting a new repaint. In such case that repaint will use
+         * a new {@link Renderer} instance; the current instance will not be reused.
          *
-         * <p>The default implementation does nothing.</p>
+         * <p>The default implementation does nothing and returns {@code true}.</p>
          *
+         * @param  canvas  the canvas where drawing has been done. It will be a {@link MapCanvasAWT} instance.
          * @return {@code true} on success, or {@code false} if the rendering should be redone
          *         (for example because a change has been detected in the data).
          */
         @Override
-        protected boolean commit() {
+        protected boolean commit(MapCanvas canvas) {
             return true;
         }
     }
@@ -275,7 +286,7 @@ public abstract class MapCanvasAWT extends MapCanvas {
          * background thread is executed; no direct reference to {@link MapCanvas} here.
          */
         @Override
-        protected WritableImage call() {
+        protected WritableImage call() throws TransformException {
             renderer.render();
             final int width  = renderer.getWidth();
             final int height = renderer.getHeight();
@@ -314,7 +325,7 @@ public abstract class MapCanvasAWT extends MapCanvas {
             buffer              = drawTo;
             bufferWrapper       = wrapper;
             bufferConfiguration = configuration;
-            final boolean done  = renderer.commit();
+            final boolean done  = renderer.commit(MapCanvasAWT.this);
             renderingCompleted(this);
             if (!done || contentsChanged()) {
                 repaint();
@@ -370,7 +381,7 @@ public abstract class MapCanvasAWT extends MapCanvas {
          * background thread is executed; no direct reference to {@link MapCanvas} here.
          */
         @Override
-        protected VolatileImage call() {
+        protected VolatileImage call() throws TransformException {
             renderer.render();
             final int width  = renderer.getWidth();
             final int height = renderer.getHeight();
@@ -433,7 +444,7 @@ public abstract class MapCanvasAWT extends MapCanvas {
             } finally {
                 drawTo.flush();                     // Release native resources.
             }
-            final boolean done = renderer.commit();
+            final boolean done = renderer.commit(MapCanvasAWT.this);
             renderingCompleted(this);
             if (!done || contentsLost || contentsChanged()) {
                 repaint();
diff --git a/application/sis-javafx/src/main/java/org/apache/sis/internal/gui/ImageRenderings.java b/application/sis-javafx/src/main/java/org/apache/sis/internal/gui/ImageRenderings.java
deleted file mode 100644
index 48de1c9..0000000
--- a/application/sis-javafx/src/main/java/org/apache/sis/internal/gui/ImageRenderings.java
+++ /dev/null
@@ -1,73 +0,0 @@
-/*
- * 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.gui;
-
-import java.util.Collections;
-import java.awt.image.RenderedImage;
-import org.apache.sis.image.ImageProcessor;
-
-
-/**
- * Operations on images for rendering purposes. The methods defined in this class delegate
- * to methods in the rest of SIS library with some arbitrary parameter value choices.
- * We use this class as a way to centralize where those choices are made for GUI purposes.
- *
- * @author  Martin Desruisseaux (Geomatys)
- * @version 1.1
- * @since   1.1
- * @module
- */
-public final class ImageRenderings {
-    /**
-     * The set of operations to use.
-     *
-     * @todo Listen to logging messages. We need to create a logging panel first.
-     */
-    private static final ImageProcessor PROCESSOR = new ImageProcessor();
-    static {
-        PROCESSOR.setErrorAction(ImageProcessor.ErrorAction.LOG);
-    }
-
-    /**
-     * Do not allow instantiation of this class.
-     */
-    private ImageRenderings() {
-    }
-
-    /**
-     * Stretches the color ramp of given image between a minimum and maximum values.
-     * If the given image is null or can not be stretched, then it is returned as-is.
-     *
-     * @param  image  the image to stretch, or {@code null}.
-     * @return the stretched image.
-     */
-    public static RenderedImage valueRangeStretching(final RenderedImage image) {
-        return PROCESSOR.stretchColorRamp(image, null);
-    }
-
-    /**
-     * Stretches the color ramp of given image between a minimum and maximum values
-     * adjusted  with standard deviations. If the given image is null or can not be
-     * stretched, then it is returned as-is.
-     *
-     * @param  image  the image to stretch, or {@code null}.
-     * @return the stretched image.
-     */
-    public static RenderedImage automaticStretching(final RenderedImage image) {
-        return PROCESSOR.stretchColorRamp(image, Collections.singletonMap("MultStdDev", 3));
-    }
-}
diff --git a/core/sis-feature/src/main/java/org/apache/sis/coverage/grid/GridCoverage2D.java b/core/sis-feature/src/main/java/org/apache/sis/coverage/grid/GridCoverage2D.java
index 791bb72..ef953b5 100644
--- a/core/sis-feature/src/main/java/org/apache/sis/coverage/grid/GridCoverage2D.java
+++ b/core/sis-feature/src/main/java/org/apache/sis/coverage/grid/GridCoverage2D.java
@@ -100,9 +100,10 @@ import org.opengis.coverage.PointOutsideCoverageException;
  */
 public class GridCoverage2D extends GridCoverage {
     /**
-     * Minimal number of dimension for this coverage.
+     * A constant for identifying code that relying on having 2 dimensions.
+     * This is the minimal number of dimension required for this coverage.
      */
-    static final int MIN_DIMENSION = 2;
+    static final int BIDIMENSIONAL = 2;
 
     /**
      * The sample values stored as a {@code RenderedImage}.
@@ -225,7 +226,7 @@ public class GridCoverage2D extends GridCoverage {
         final GridExtent extent = domain.getExtent();
         final int[] imageAxes;
         try {
-            imageAxes = extent.getSubspaceDimensions(MIN_DIMENSION);
+            imageAxes = extent.getSubspaceDimensions(BIDIMENSIONAL);
         } catch (CannotEvaluateException e) {
             throw new IllegalGridGeometryException(e.getMessage(), e);
         }
@@ -237,7 +238,7 @@ public class GridCoverage2D extends GridCoverage {
          * Verify that the domain is consistent with image size.
          * We do not verify image location; it can be anywhere.
          */
-        for (int i=0; i<MIN_DIMENSION; i++) {
+        for (int i=0; i<BIDIMENSIONAL; i++) {
             final int imageSize = (i == 0) ? data.getWidth() : data.getHeight();
             final long gridSize = extent.getSize(imageAxes[i]);
             if (imageSize != gridSize) {
@@ -296,7 +297,7 @@ public class GridCoverage2D extends GridCoverage {
             domain = new GridGeometry(extent, PixelInCell.CELL_CENTER, null, null);
         } else if (!domain.isDefined(GridGeometry.EXTENT)) {
             final int dimension = domain.getDimension();
-            if (dimension >= MIN_DIMENSION) {
+            if (dimension >= BIDIMENSIONAL) {
                 CoordinateReferenceSystem crs = null;
                 if (domain.isDefined(GridGeometry.CRS)) {
                     crs = domain.getCoordinateReferenceSystem();
@@ -354,12 +355,11 @@ public class GridCoverage2D extends GridCoverage {
     private static GridGeometry createGridGeometry(final RenderedImage data, final Envelope envelope) {
         ArgumentChecks.ensureNonNull("data", data);
         CoordinateReferenceSystem crs = null;
-        int dimension = MIN_DIMENSION;
+        int dimension = BIDIMENSIONAL;
         if (envelope != null) {
             dimension = envelope.getDimension();
-            if (dimension < MIN_DIMENSION) {
-                throw new IllegalGridGeometryException(Resources.format(
-                        Resources.Keys.GridEnvelopeMustBeNDimensional_1, MIN_DIMENSION));
+            if (dimension < BIDIMENSIONAL) {
+                throw new IllegalGridGeometryException(Resources.format(Resources.Keys.GridEnvelopeMustBeNDimensional_1, BIDIMENSIONAL));
             }
             crs = envelope.getCoordinateReferenceSystem();
         }
diff --git a/core/sis-feature/src/main/java/org/apache/sis/coverage/grid/GridExtent.java b/core/sis-feature/src/main/java/org/apache/sis/coverage/grid/GridExtent.java
index a13b529..61eacf5 100644
--- a/core/sis-feature/src/main/java/org/apache/sis/coverage/grid/GridExtent.java
+++ b/core/sis-feature/src/main/java/org/apache/sis/coverage/grid/GridExtent.java
@@ -58,6 +58,8 @@ import org.apache.sis.util.ArraysExt;
 import org.apache.sis.util.ComparisonMode;
 import org.apache.sis.util.LenientComparable;
 import org.apache.sis.util.iso.Types;
+import org.apache.sis.util.logging.Logging;
+import org.apache.sis.internal.system.Modules;
 
 // Branch-dependent imports
 import org.opengis.coverage.grid.GridEnvelope;
@@ -959,7 +961,8 @@ public class GridExtent implements GridEnvelope, LenientComparable, Serializable
                 }
             }
         } catch (FactoryException e) {
-            GridGeometry.recoverableException(e);
+            // "toEnvelope" is the closest public method that may invoke this method.
+            Logging.recoverableException(Logging.getLogger(Modules.RASTER), GridExtent.class, "toEnvelope", e);
         }
         return envelope;
     }
diff --git a/core/sis-feature/src/main/java/org/apache/sis/coverage/grid/GridGeometry.java b/core/sis-feature/src/main/java/org/apache/sis/coverage/grid/GridGeometry.java
index e33ff78..2d7f4d7 100644
--- a/core/sis-feature/src/main/java/org/apache/sis/coverage/grid/GridGeometry.java
+++ b/core/sis-feature/src/main/java/org/apache/sis/coverage/grid/GridGeometry.java
@@ -49,7 +49,6 @@ import org.apache.sis.referencing.IdentifiedObjects;
 import org.apache.sis.referencing.operation.matrix.Matrices;
 import org.apache.sis.referencing.operation.matrix.MatrixSIS;
 import org.apache.sis.referencing.operation.transform.MathTransforms;
-import org.apache.sis.referencing.operation.transform.TransformSeparator;
 import org.apache.sis.referencing.operation.transform.PassThroughTransform;
 import org.apache.sis.internal.referencing.DirectPositionView;
 import org.apache.sis.internal.referencing.TemporalAccessor;
@@ -61,6 +60,7 @@ import org.apache.sis.internal.util.Numerics;
 import org.apache.sis.util.collection.TreeTable;
 import org.apache.sis.util.collection.TableColumn;
 import org.apache.sis.util.collection.DefaultTreeTable;
+import org.apache.sis.util.collection.BackingStoreException;
 import org.apache.sis.util.resources.Vocabulary;
 import org.apache.sis.util.resources.Errors;
 import org.apache.sis.util.logging.Logging;
@@ -68,7 +68,6 @@ import org.apache.sis.util.LenientComparable;
 import org.apache.sis.util.ComparisonMode;
 import org.apache.sis.util.ArgumentChecks;
 import org.apache.sis.util.CharSequences;
-import org.apache.sis.util.ArraysExt;
 import org.apache.sis.util.Utilities;
 import org.apache.sis.util.Classes;
 import org.apache.sis.util.Debug;
@@ -215,7 +214,7 @@ public class GridGeometry implements LenientComparable, Serializable {
      * @serial This field is serialized because it may be a value specified explicitly at construction time,
      *         in which case it can be more accurate than a computed value.
      */
-    private final MathTransform cornerToCRS;
+    final MathTransform cornerToCRS;
 
     /**
      * An <em>estimation</em> of the grid resolution, in units of the CRS axes.
@@ -237,7 +236,7 @@ public class GridGeometry implements LenientComparable, Serializable {
      *
      * @see #isConversionLinear(int...)
      */
-    private final long nonLinears;
+    final long nonLinears;
 
     /**
      * The geographic bounding box as an unmodifiable metadata instance, or {@code null} if not yet computed.
@@ -512,7 +511,7 @@ public class GridGeometry implements LenientComparable, Serializable {
                 scales = gridToCRS.derivative(new DirectPositionView.Double(extent.getPointOfInterest()));
                 numToIgnore = 0;
             } catch (TransformException e) {
-                recoverableException(e);
+                recoverableException("<init>", e);
             }
         } else {
             this.extent   = null;
@@ -543,10 +542,11 @@ public class GridGeometry implements LenientComparable, Serializable {
      * Invoked when a recoverable exception occurred. Those exceptions must be minor enough
      * that they can be silently ignored in most cases.
      *
+     * @param  caller     the method where exception occurred.
      * @param  exception  the exception that occurred.
      */
-    static void recoverableException(final Exception exception) {
-        Logging.recoverableException(Logging.getLogger(Modules.RASTER), GridGeometry.class, "<init>", exception);
+    static void recoverableException(final String caller, final TransformException exception) {
+        Logging.recoverableException(Logging.getLogger(Modules.RASTER), GridGeometry.class, caller, exception);
     }
 
     /**
@@ -610,175 +610,23 @@ public class GridGeometry implements LenientComparable, Serializable {
     }
 
     /**
-     * Creates a new grid geometry over the specified dimensions of the given grid geometry.
-     * The number of grid dimensions will be the length of the given {@code dimensions} array,
-     * and the number of CRS dimensions will be reduced by the same amount.
-     *
-     * @param  other       the grid geometry to copy.
-     * @param  dimensions  the grid (not CRS) dimensions to select, in strictly increasing order.
-     * @param  filterCRS   {@code true} for reducing the CRS by the same amount of dimensions than the grid.
-     *                     If {@code false}, this constructor retains as many CRS dimensions as possible.
-     * @throws FactoryException if an error occurred while separating the "grid to CRS" transform.
-     *
-     * @see #reduce(int...)
+     * Creates a new grid geometry from the given components.
+     * This constructor performs no verification (unless assertions are enabled).
      */
-    private GridGeometry(final GridGeometry other, int[] dimensions, final boolean filterCRS) throws FactoryException {
-        extent = (other.extent != null) ? other.extent.reduce(dimensions) : null;
-        /*
-         * If a `gridToCRS` transform is available, retain the source dimensions specified by `dimensions`.
-         * We work on source dimensions because they are the grid dimensions. But after this reduction, we
-         * will work in CRS dimensions for the rest of this method. The CRS dimensions to retain are often
-         * the same than the grid dimensions, but not necessarily.
-         */
-        if (other.gridToCRS != null) {
-            final int[] sources = dimensions;
-            TransformSeparator sep = new TransformSeparator(other.gridToCRS);
-            sep.addSourceDimensions(sources);
-            if (filterCRS) {
-                final int[] target = other.findTargetDimensions(dimensions);
-                if (target != null) {
-                    sep.addTargetDimensions(dimensions);
-                }
-            }
-            gridToCRS  = sep.separate();
-            dimensions = sep.getTargetDimensions();
-            /*
-             * We redo a separation for `cornerToCRS` instead than applying a translation of the `gridToCRS`
-             * computed above because we don't know which of `gridToCRS` and `cornerToCRS` has less NaN values.
-             * We require however the exact same sequence of target dimensions.
-             */
-            sep = new TransformSeparator(other.cornerToCRS);
-            sep.addSourceDimensions(sources);
-            sep.addTargetDimensions(dimensions);
-            cornerToCRS = sep.separate();
-        } else {
-            gridToCRS   = null;
-            cornerToCRS = null;
-        }
-        /*
-         * At this point, `dimensions` gives CRS dimensions. It is usually the same sequence than grid dimensions
-         * but not always. In particular it may have more elements if `TransformSeparator` detected that dropping
-         * a grid dimension does not force us to drop the corresponding CRS dimension, for example because it has
-         * a constant value.
-         */
-        final int n = dimensions.length;
-        final ImmutableEnvelope env = other.envelope;
-        if (env != null) {
-            CoordinateReferenceSystem crs = env.getCoordinateReferenceSystem();
-            crs = org.apache.sis.referencing.CRS.reduce(crs, dimensions);
-            final double[] min = new double[n];
-            final double[] max = new double[n];
-            for (int i=0; i<n; i++) {
-                final int j = dimensions[i];
-                min[i] = env.getLower(j);
-                max[i] = env.getUpper(j);
-            }
-            envelope = new ImmutableEnvelope(min, max, crs);
-        } else {
-            envelope = null;
-        }
-        long     nonLinears = 0;
-        double[] resolution = other.resolution;
-        if (resolution != null) {
-            resolution = new double[n];
-        }
-        for (int i=0; i<n; i++) {
-            final int j = dimensions[i];
-            if (resolution != null) {
-                resolution[i] = other.resolution[j];
-            }
-            nonLinears |= ((other.nonLinears >>> j) & 1L) << i;
-        }
-        this.resolution = resolution;
-        this.nonLinears = nonLinears;
-    }
-
-    /**
-     * Finds CRS (target) dimensions that are related to the given grid (source) dimensions.
-     * This method returns an array where the number of CRS dimensions have been reduced by
-     * the same amount than the number of grid dimensions.
-     *
-     * <p>If this method is not invoked, then {@link TransformSeparator} will retain as many target dimensions
-     * as possible, which may be more than expected if a dimension that would normally be dropped is actually
-     * a constant (all scale coefficients set to zero). This method tries to avoid this effect by forcing the
-     * removal of CRS dimensions too. The CRS dimensions to remove are the ones that seem the less related to
-     * the grid dimensions that we keep. This method is not provided in {@link TransformSeparator} because of
-     * assumptions on the gridded nature of source coordinates.</p>
-     *
-     * <p>The algorithm used by this method (which is to compare the magnitude of scale coefficients anywhere
-     * in the matrix) assumes that grid cells are "square", e.g. that a translation of 1 pixel to the left is
-     * comparable in "real world" to a translation of 1 pixel to the bottom. This is often true but not always.
-     * To compensate, we divide scale coefficients by the {@linkplain #resolution} for that CRS dimension.</p>
-     *
-     * @param  dimensions  the grid dimensions to keep.
-     * @return the CRS (target) dimensions to keep.
-     */
-    private int[] findTargetDimensions(int[] dimensions) {
-        /*
-         * In most cases the transform is affine and we do not need a derivative computation
-         * (which save us from requiring a point of interest).
-         */
-        int numRow = -1;
-        Matrix derivative = MathTransforms.getMatrix(gridToCRS);
-        if (derivative == null) {
-            if (extent != null) try {
-                derivative = gridToCRS.derivative(new DirectPositionView.Double(extent.getPointOfInterest()));
-            } catch (TransformException e) {
-                recoverableException(e);
-                return null;
-            } else {
-                return null;
-            }
-            numRow = 0;
-        }
-        numRow += derivative.getNumRow();               // Excluding the [0 0 0 … 1] row in affine transform.
-        final int n = dimensions.length + (gridToCRS.getTargetDimensions() - gridToCRS.getSourceDimensions());
-        /*
-         * Search for the greatest scale coefficient. For the greatest value, take the row as the target
-         * dimension and remember that we should not check anymore any value in the row and column where
-         * the value has been found.
-         */
-        long selected = 0;
-        while (Long.bitCount(selected) < n) {
-            double max = -1;
-            int   kmax = -1;
-            int   jmax = -1;
-            for (int j=0; j<numRow; j++) {
-                if ((selected & Numerics.bitmask(j)) == 0) {
-                    double r = 1;                               // For compensation of non-square cells.
-                    if (resolution != null) {
-                        final double t = resolution[j];
-                        if (t > 0) r = t;                       // Exclude NaN values.
-                    }
-                    for (int k=0; k < dimensions.length; k++) {
-                        final double e = Math.abs(derivative.getElement(j, dimensions[k])) / r;
-                        if (e > max) {
-                            max  = e;
-                            kmax = k;
-                            jmax = j;
-                        }
-                    }
-                }
-            }
-            if ((kmax | jmax) < 0) {
-                return null;                            // Can not provide the requested number of dimensions.
-            }
-            if (jmax >= Long.SIZE) {
-                throw excessiveDimension(gridToCRS);
-            }
-            selected |= (1L << jmax);
-            dimensions = ArraysExt.remove(dimensions, kmax, 1);
-        }
-        /*
-         * Expand the values encoded in the `selected` bitmask.
-         */
-        final int[] target = new int[n];
-        for (int i=0; i<n; i++) {
-            final int j = Long.numberOfTrailingZeros(selected);
-            target[i] = j;
-            selected &= ~(1L << j);
+    GridGeometry(final GridExtent extent, final MathTransform gridToCRS, final MathTransform cornerToCRS,
+                 final ImmutableEnvelope envelope, final double[] resolution, final long nonLinears)
+    {
+        this.extent      = extent;
+        this.gridToCRS   = gridToCRS;
+        this.cornerToCRS = cornerToCRS;
+        this.envelope    = envelope;
+        this.resolution  = resolution;
+        this.nonLinears  = nonLinears;
+        if (gridToCRS != null) {
+            assert (extent     == null) || gridToCRS.getSourceDimensions() == extent.getDimension();
+            assert (envelope   == null) || gridToCRS.getTargetDimensions() == envelope.getDimension();
+            assert (resolution == null) || gridToCRS.getTargetDimensions() == resolution.length;
         }
-        return target;
     }
 
     /**
@@ -1071,7 +919,7 @@ public class GridGeometry implements LenientComparable, Serializable {
         } else if (domain != null && gridToCRS != null) try {
             return resolution(gridToCRS.derivative(new DirectPositionView.Double(domain.getPointOfInterest())), 0);
         } catch (TransformException e) {
-            recoverableException(e);
+            recoverableException("resolution", e);
         }
         return null;
     }
@@ -1220,7 +1068,7 @@ public class GridGeometry implements LenientComparable, Serializable {
     /**
      * Invoked when the number of non-linear dimensions exceeds the {@code GridGeometry} capacity.
      */
-    private static ArithmeticException excessiveDimension(final MathTransform gridToCRS) {
+    static ArithmeticException excessiveDimension(final MathTransform gridToCRS) {
         return new ArithmeticException(Errors.format(Errors.Keys.ExcessiveNumberOfDimensions_1, gridToCRS.getTargetDimensions()));
     }
 
@@ -1343,9 +1191,9 @@ public class GridGeometry implements LenientComparable, Serializable {
     public GridGeometry reduce(int... dimensions) {
         dimensions = GridExtent.verifyDimensions(dimensions, getDimension());
         if (dimensions != null) try {
-            return new GridGeometry(this, dimensions, true);
+            return new SliceGeometry(this, null, dimensions, null).reduce(null, -1);
         } catch (FactoryException e) {
-            throw new IllegalGridGeometryException(e, "dimensions");
+            throw new BackingStoreException(e);
         }
         return this;
     }
diff --git a/core/sis-feature/src/main/java/org/apache/sis/coverage/grid/ImageRenderer.java b/core/sis-feature/src/main/java/org/apache/sis/coverage/grid/ImageRenderer.java
index 52baa47..52de407 100644
--- a/core/sis-feature/src/main/java/org/apache/sis/coverage/grid/ImageRenderer.java
+++ b/core/sis-feature/src/main/java/org/apache/sis/coverage/grid/ImageRenderer.java
@@ -17,6 +17,7 @@
 package org.apache.sis.coverage.grid;
 
 import java.util.Arrays;
+import java.util.Hashtable;
 import java.nio.Buffer;
 import java.awt.Point;
 import java.awt.Rectangle;
@@ -26,7 +27,9 @@ import java.awt.image.SampleModel;
 import java.awt.image.BufferedImage;
 import java.awt.image.RenderedImage;
 import java.awt.image.WritableRaster;
+import java.awt.image.ImagingOpException;
 import java.awt.image.RasterFormatException;
+import org.opengis.util.FactoryException;
 import org.opengis.geometry.MismatchedDimensionException;
 import org.apache.sis.coverage.SubspaceNotSpecifiedException;
 import org.apache.sis.coverage.MismatchedCoverageRangeException;
@@ -38,8 +41,12 @@ import org.apache.sis.internal.util.CollectionsExt;
 import org.apache.sis.util.NullArgumentException;
 import org.apache.sis.util.resources.Errors;
 import org.apache.sis.util.ArgumentChecks;
+import org.apache.sis.util.ComparisonMode;
+import org.apache.sis.util.ArraysExt;
 import org.apache.sis.math.Vector;
 
+import static org.apache.sis.image.PlanarImage.GRID_GEOMETRY_KEY;
+
 
 /**
  * A builder for the rendered image to be returned by {@link GridCoverage#render(GridExtent)}.
@@ -88,6 +95,31 @@ import org.apache.sis.math.Vector;
  */
 public class ImageRenderer {
     /**
+     * The grid geometry of the {@link GridCoverage} specified at construction time.
+     */
+    private final GridGeometry geometry;
+
+    /**
+     * The requested slice, or {@code null} if unspecified.
+     * If unspecified, then the extent to use is the full coverage grid extent.
+     */
+    private final GridExtent sliceExtent;
+
+    /**
+     * The dimensions to select in the grid coverage for producing an image. This is an array of length
+     * {@value GridCoverage2D#BIDIMENSIONAL} obtained by {@link GridExtent#getSubspaceDimensions(int)}.
+     */
+    private final int[] gridDimensions;
+
+    /**
+     * The result of {@link #getImageGeometry(int)} if the specified number of dimension 2.
+     * This is cached for avoiding to recompute this geometry if asked many times.
+     *
+     * @see #getImageGeometry(int)
+     */
+    private GridGeometry imageGeometry;
+
+    /**
      * Location of the first image pixel relative to the grid coverage extent. The (0,0) offset means that the first pixel
      * in the {@code sliceExtent} (specified at construction time) is the first pixel in the whole {@link GridCoverage}.
      *
@@ -206,6 +238,13 @@ public class ImageRenderer {
     private DataBuffer buffer;
 
     /**
+     * The properties to give to the image, or {@code null} if none.
+     *
+     * @see #addProperty(String, Object)
+     */
+    private Hashtable<String,Object> properties;
+
+    /**
      * Creates a new image renderer for the given slice extent.
      *
      * @param  coverage     the source coverage for which to build an image.
@@ -217,7 +256,9 @@ public class ImageRenderer {
     public ImageRenderer(final GridCoverage coverage, GridExtent sliceExtent) {
         ArgumentChecks.ensureNonNull("coverage", coverage);
         bands = CollectionsExt.toArray(coverage.getSampleDimensions(), SampleDimension.class);
-        final GridExtent source = coverage.getGridGeometry().getExtent();
+        geometry = coverage.getGridGeometry();
+        final GridExtent source = geometry.getExtent();
+        this.sliceExtent = sliceExtent;
         if (sliceExtent != null) {
             final int dimension = sliceExtent.getDimension();
             if (source.getDimension() != dimension) {
@@ -227,9 +268,9 @@ public class ImageRenderer {
         } else {
             sliceExtent = source;
         }
-        final int[] dimensions = sliceExtent.getSubspaceDimensions(2);
-        final int  xd   = dimensions[0];
-        final int  yd   = dimensions[1];
+        gridDimensions  = sliceExtent.getSubspaceDimensions(GridCoverage2D.BIDIMENSIONAL);
+        final int  xd   = gridDimensions[0];
+        final int  yd   = gridDimensions[1];
         final long xcov = source.getLow(xd);
         final long ycov = source.getLow(yd);
         final long xreq = sliceExtent.getLow(xd);
@@ -289,7 +330,14 @@ public class ImageRenderer {
     }
 
     /**
-     * Returns the location of the image upper-left corner together with the image size.
+     * Returns the location of the image upper-left corner together with the image size. The image coordinate system
+     * is relative to the {@code sliceExtent} specified at construction time: the (0,0) pixel coordinates correspond
+     * to the {@code sliceExtent} {@linkplain GridExtent#getLow(int) low coordinates}. Consequently the rectangle
+     * {@linkplain Rectangle#x <var>x</var>} and {@linkplain Rectangle#y <var>y</var>} coordinates are (0,0) if
+     * the image is located exactly in the area requested by {@code sliceExtent}, or is shifted as below otherwise:
+     *
+     * <blockquote>( <var>x</var>, <var>y</var> ) =
+     * (grid coordinates of actually provided region) − (grid coordinates of requested region)</blockquote>
      *
      * @return the rendered image location and size (never null).
      */
@@ -298,6 +346,107 @@ public class ImageRenderer {
     }
 
     /**
+     * Computes the conversion from pixel coordinates to CRS, together with the geospatial envelope of the image.
+     * The {@link GridGeometry} returned by this method is derived from the {@linkplain GridCoverage#getGridGeometry()
+     * coverage grid geometry} with the following changes:
+     *
+     * <ul>
+     *   <li>The {@linkplain GridGeometry#getDimension() number of grid dimensions} is always 2.</li>
+     *   <li>The number of {@linkplain GridGeometry#getCoordinateReferenceSystem() CRS} dimensions
+     *       is specified by {@code dimCRS} (usually 2).</li>
+     *   <li>The {@linkplain GridGeometry#getEnvelope() envelope} may be a sub-region of the coverage envelope.</li>
+     *   <li>The {@linkplain GridGeometry#getExtent() grid extent} is the {@linkplain #getBounds() image bounds}.</li>
+     *   <li>The {@linkplain GridGeometry#getGridToCRS grid to CRS} transform is derived from the coverage transform
+     *       with a translation for mapping the {@code sliceExtent} {@linkplain GridExtent#getLow(int) low coordinates}
+     *       to (0,0) pixel coordinates.</li>
+     * </ul>
+     *
+     * @param  dimCRS  desired number of dimensions in the CRS. This is usually 2.
+     * @return conversion from pixel coordinates to CRS of the given number of dimensions,
+     *         together with image bounds and geospatial envelope if possible.
+     *
+     * @see org.apache.sis.image.PlanarImage#GRID_GEOMETRY_KEY
+     *
+     * @since 1.1
+     */
+    public GridGeometry getImageGeometry(final int dimCRS) {
+        GridGeometry ig = imageGeometry;
+        if (ig == null || dimCRS != GridCoverage2D.BIDIMENSIONAL) {
+            if (isSameGeometry(dimCRS)) {
+                ig = geometry;
+            } else try {
+                ig = new SliceGeometry(geometry, sliceExtent, gridDimensions, null)
+                        .reduce(new GridExtent(imageX, imageY, width, height), dimCRS);
+            } catch (FactoryException e) {
+                throw canNotCompute(e);
+            }
+            if (dimCRS == GridCoverage2D.BIDIMENSIONAL) {
+                imageGeometry = ig;
+            }
+        }
+        return ig;
+    }
+
+    /**
+     * Returns the value associated to the given property. By default the only property is
+     * {@value org.apache.sis.image.PlanarImage#GRID_GEOMETRY_KEY}, but more properties can
+     * be added by calls to {@link #addProperty(String, Object)}.
+     *
+     * @param  key  the property for which to get a value.
+     * @return value associated to the given property, or {@code null} if none.
+     *
+     * @since 1.1
+     */
+    public Object getProperty(final String key) {
+        if (GRID_GEOMETRY_KEY.equals(key)) {
+            return getImageGeometry(GridCoverage2D.BIDIMENSIONAL);
+        }
+        return (properties != null) ? properties.get(key) : null;
+    }
+
+    /**
+     * Adds a value associated to a property. This method can be invoked only once for each {@code key}.
+     * Those properties will be given to the image created by the {@link #image()} method.
+     *
+     * @param  key    key of the property to set.
+     * @param  value  value to associate to the given key.
+     * @throws IllegalArgumentException if a value is already associated to the given key.
+     *
+     * @since 1.1
+     */
+    public void addProperty(final String key, final Object value) {
+        ArgumentChecks.ensureNonNull("key", key);
+        if (!GRID_GEOMETRY_KEY.equals(key)) {
+            if (properties == null) {
+                properties = new Hashtable<>();
+            }
+            if (properties.putIfAbsent(key, value) == null) {
+                return;
+            }
+        }
+        throw new IllegalArgumentException(Errors.format(Errors.Keys.ElementAlreadyPresent_1, key));
+    }
+
+    /**
+     * Returns {@code true} if a {@link #getImageGeometry(int)} request for the given number of CRS dimensions
+     * can return {@link #geometry} directly. This common case avoids the need for more costly computation with
+     * {@link SliceGeometry}.
+     */
+    private boolean isSameGeometry(final int dimCRS) {
+        final int tgtDim = geometry.getTargetDimension();
+        ArgumentChecks.ensureBetween("dimCRS", GridCoverage2D.BIDIMENSIONAL, tgtDim, dimCRS);
+        if (tgtDim == dimCRS && geometry.getDimension() == gridDimensions.length) {
+            final GridExtent extent = geometry.extent;
+            if (sliceExtent == null) {
+                return extent == null || extent.startsAtZero();
+            } else if (sliceExtent.equals(extent, ComparisonMode.IGNORE_METADATA)) {
+                return sliceExtent.startsAtZero();
+            }
+        }
+        return false;
+    }
+
+    /**
      * Sets the data as a Java2D buffer. The {@linkplain DataBuffer#getNumBanks() number of banks}
      * in the given buffer must be equal to the {@linkplain #getNumBands() expected number of bands}.
      * In each bank, the value located at the {@linkplain DataBuffer#getOffsets() bank offset} is the value
@@ -456,6 +605,8 @@ public class ImageRenderer {
     /**
      * Creates an image with the data specified by the last call to a {@code setData(…)} method.
      * The image upper-left corner is located at the position given by {@link #getBounds()}.
+     * The two-dimensional {@linkplain #getImageGeometry(int) image geometry} is stored as
+     * a property associated to the {@value org.apache.sis.image.PlanarImage#GRID_GEOMETRY_KEY} key.
      *
      * @return the image.
      * @throws IllegalStateException if no {@code setData(…)} method has been invoked before this method call.
@@ -465,6 +616,84 @@ public class ImageRenderer {
     public RenderedImage image() {
         WritableRaster raster = raster();
         ColorModel colors = ColorModelFactory.createColorModel(bands, visibleBand, buffer.getDataType(), ColorModelFactory.GRAYSCALE);
-        return new BufferedImage(colors, raster, false, null);
+        final Untiled image = new Untiled(colors, raster, properties);
+        if (imageGeometry != null) {
+            image.geometry = imageGeometry;
+        } else if (isSameGeometry(GridCoverage2D.BIDIMENSIONAL)) {
+            image.geometry = imageGeometry = geometry;
+        } else {
+            image.supplier = new SliceGeometry(geometry, sliceExtent, gridDimensions, null);
+        }
+        return image;
+    }
+
+    /**
+     * A {@link BufferedImage} which will compute the {@value org.apache.sis.image.PlanarImage#GRID_GEOMETRY_KEY}
+     * property when first needed. We use this class even when the property value is known in advance because it
+     * has the desired side-effect of not letting {@link #getSubimage(int, int, int, int)} inherit that property.
+     * The use of a {@link BufferedImage} subclass is desired because Java2D rendering pipeline has optimizations
+     * in the form {@code if (image instanceof BufferedImage)}.
+     */
+    private static final class Untiled extends BufferedImage {
+        /**
+         * The value associated to the {@value org.apache.sis.image.PlanarImage#GRID_GEOMETRY_KEY} key,
+         * or {@code null} if not yet computed.
+         */
+        private GridGeometry geometry;
+
+        /**
+         * The object to use for computing {@link #geometry}, or {@code null} if not needed.
+         * This field is cleared after {@link #geometry} has been computed.
+         */
+        private SliceGeometry supplier;
+
+        /**
+         * Creates a new buffered image wrapping the given raster.
+         */
+        Untiled(final ColorModel colors, final WritableRaster raster, final Hashtable<?,?> properties) {
+            super(colors, raster, false, properties);
+        }
+
+        /**
+         * Returns the names of properties that this image can provide.
+         */
+        @Override
+        public String[] getPropertyNames() {
+            return ArraysExt.concatenate(super.getPropertyNames(), new String[] {GRID_GEOMETRY_KEY});
+        }
+
+        /**
+         * Returns the property associated to the given key.
+         * If the key is {@value org.apache.sis.image.PlanarImage#GRID_GEOMETRY_KEY},
+         * then the {@link GridGeometry} will be computed when first needed.
+         *
+         * @throws ImagingOpException if the property value can not be computed.
+         */
+        @Override
+        public Object getProperty(final String key) {
+            if (!GRID_GEOMETRY_KEY.equals(key)) {
+                return super.getProperty(key);
+            }
+            synchronized (this) {
+                if (geometry == null) try {
+                    final GridExtent extent = new GridExtent(getMinX(), getMinY(), getWidth(), getHeight());
+                    geometry = supplier.reduce(extent, GridCoverage2D.BIDIMENSIONAL);
+                } catch (FactoryException e) {
+                    throw canNotCompute(e);
+                }
+                supplier = null;                // Let GC do its work.
+            }
+            return geometry;
+        }
+    }
+
+    /**
+     * Invoked if an error occurred while computing the {@link #getImageGeometry(int)} value.
+     * This exception should never occur actually, unless a custom factory implementation is
+     * used (instead of the Apache SIS default) and there is a problem with that factory.
+     */
+    private static ImagingOpException canNotCompute(final FactoryException e) {
+        throw (ImagingOpException) new ImagingOpException(
+                Errors.format(Errors.Keys.CanNotCompute_1, "ImageGeometry")).initCause(e);
     }
 }
diff --git a/core/sis-feature/src/main/java/org/apache/sis/coverage/grid/ResampledGridCoverage.java b/core/sis-feature/src/main/java/org/apache/sis/coverage/grid/ResampledGridCoverage.java
index 21e3260..8ca6ebb 100644
--- a/core/sis-feature/src/main/java/org/apache/sis/coverage/grid/ResampledGridCoverage.java
+++ b/core/sis-feature/src/main/java/org/apache/sis/coverage/grid/ResampledGridCoverage.java
@@ -152,7 +152,9 @@ final class ResampledGridCoverage extends GridCoverage {
      */
     private GridCoverage specialize(final boolean isGeometryExplicit) throws TransformException {
         GridExtent extent = gridGeometry.getExtent();
-        if (extent.getDimension() < GridCoverage2D.MIN_DIMENSION || extent.getSubDimension() > BIDIMENSIONAL) {
+        if (extent.getDimension()    < GridCoverage2D.BIDIMENSIONAL ||
+            extent.getSubDimension() > GridCoverage2D.BIDIMENSIONAL)
+        {
             return this;
         }
         /*
diff --git a/core/sis-feature/src/main/java/org/apache/sis/coverage/grid/SliceGeometry.java b/core/sis-feature/src/main/java/org/apache/sis/coverage/grid/SliceGeometry.java
new file mode 100644
index 0000000..f850d27
--- /dev/null
+++ b/core/sis-feature/src/main/java/org/apache/sis/coverage/grid/SliceGeometry.java
@@ -0,0 +1,344 @@
+/*
+ * 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.coverage.grid;
+
+import java.awt.image.RenderedImage;
+import org.opengis.util.FactoryException;
+import org.opengis.referencing.operation.Matrix;
+import org.opengis.referencing.operation.MathTransform;
+import org.opengis.referencing.operation.MathTransformFactory;
+import org.opengis.referencing.operation.TransformException;
+import org.opengis.referencing.crs.CoordinateReferenceSystem;
+import org.apache.sis.referencing.operation.transform.MathTransforms;
+import org.apache.sis.referencing.operation.transform.LinearTransform;
+import org.apache.sis.referencing.operation.transform.TransformSeparator;
+import org.apache.sis.referencing.CRS;
+import org.apache.sis.geometry.GeneralEnvelope;
+import org.apache.sis.geometry.ImmutableEnvelope;
+import org.apache.sis.internal.referencing.DirectPositionView;
+import org.apache.sis.internal.util.Numerics;
+import org.apache.sis.util.ComparisonMode;
+import org.apache.sis.util.ArraysExt;
+
+
+/**
+ * Builds a grid geometry for a slice in a {@link GridCoverage}. This is the implementation of
+ * {@link GridGeometry#reduce(int...)} and {@link ImageRenderer#getImageGeometry(int)} methods.
+ *
+ * @author  Martin Desruisseaux (Geomatys)
+ * @version 1.1
+ * @since   1.1
+ * @module
+ */
+final class SliceGeometry {
+    /**
+     * The coverage grid geometry from which to take a slice.
+     */
+    private final GridGeometry geometry;
+
+    /**
+     * Extents of the slice to take in the {@linkplain #geometry}.
+     */
+    private final GridExtent sliceExtent;
+
+    /**
+     * Dimensions of the slice to retain. All dimensions not in this sequence will be discarded.
+     * This is usually the array computed by {@link GridExtent#getSubspaceDimensions(int)}.
+     */
+    private final int[] gridDimensions;
+
+    /**
+     * Factory to use for creating new transforms, or {@code null} for default.
+     */
+    private final MathTransformFactory factory;
+
+    /**
+     * Creates a new builder of slice geometry.
+     *
+     * @param  geometry        the grid geometry for which the transform is desired.
+     * @param  sliceExtent     the requested extent, or {@code null} for the whole coverage.
+     * @param  gridDimensions  the grid (not CRS) dimensions to select, in strictly increasing order.
+     */
+    SliceGeometry(final GridGeometry geometry, final GridExtent sliceExtent,
+                  final int[] gridDimensions, final MathTransformFactory factory)
+    {
+        this.geometry       = geometry;
+        this.sliceExtent    = sliceExtent;
+        this.gridDimensions = gridDimensions;
+        this.factory        = factory;
+    }
+
+    /**
+     * Creates a new grid geometry over the specified dimensions of the geometry specified at construction time.
+     * The number of grid dimensions will be the length of the {@link #gridDimensions} array, and the number of
+     * CRS dimensions will be reduced by the same amount.
+     *
+     * <p>If a non-null {@link #sliceExtent} has been specified, that extent shall be a sub-extent of the extent
+     * of the original grid geometry. In particular it must have the same number of dimensions in same order and
+     * the original "grid to CRS" transform shall be valid with that {@link #sliceExtent}. That sub-extent will
+     * be used in replacement of the original extent for computing the geospatial area and the resolution.</p>
+     *
+     * <p>If a non-null {@code relativeExtent} is specified, a translation will be inserted before "grid to CRS"
+     * conversion in order that lowest coordinate values of {@link #sliceExtent} (or original extent if there is
+     * no slice extent) will map to (0,0,…,0) coordinate values in relative extent. This is used for taking in
+     * account the translation between {@link #sliceExtent} coordinates and coordinates of the image returned by
+     * {@link GridCoverage#render(GridExtent)}, in which case the relative extent is the location and size of the
+     * {@link RenderedImage}. The number of dimensions of relative extent must be equal to {@code gridDimensions}
+     * array length (i.e. the dimensionality reduction must be already done).</p>
+     *
+     * @param  relativeExtent  if non-null, an extent <em>relative</em> to {@link #sliceExtent} to assign to
+     *                         the grid geometry to return. Dimensionality reduction shall be already applied.
+     * @param  dimCRS          desired number of CRS dimensions, or -1 for automatic.
+     * @throws FactoryException if an error occurred while separating the "grid to CRS" transform.
+     *
+     * @see GridGeometry#reduce(int...)
+     */
+    final GridGeometry reduce(final GridExtent relativeExtent, final int dimCRS) throws FactoryException {
+        GridExtent    extent      = geometry.extent;
+        MathTransform gridToCRS   = geometry.gridToCRS;
+        MathTransform cornerToCRS = geometry.cornerToCRS;
+        double[]      resolution  = geometry.resolution;
+        /*
+         * If a `gridToCRS` transform is available, retain the source dimensions specified by `gridDimensions`.
+         * We work on source dimensions because they are the grid dimensions. The CRS dimensions to retain are
+         * often the same than the grid dimensions, but not necessarily. In particular the CRS may have more
+         * elements if `TransformSeparator` detected that dropping a grid dimension does not force us to drop
+         * the corresponding CRS dimension, for example because it has a constant value.
+         */
+        int[] crsDimensions = gridDimensions;
+        if (gridToCRS != null) {
+            TransformSeparator sep = new TransformSeparator(gridToCRS, factory);
+            sep.addSourceDimensions(gridDimensions);
+            /*
+             * Try to reduce the CRS by the same amount of dimensions than the grid.
+             */
+            crsDimensions = findTargetDimensions(gridToCRS, extent, resolution, gridDimensions, dimCRS);
+            if (crsDimensions != null) {
+                sep.addTargetDimensions(crsDimensions);
+            }
+            gridToCRS     = sep.separate();
+            crsDimensions = sep.getTargetDimensions();
+            /*
+             * We redo a separation for `cornerToCRS` instead than applying a translation of the `gridToCRS`
+             * computed above because we don't know which of `gridToCRS` and `cornerToCRS` has less NaN values.
+             * We require however the exact same sequence of target dimensions.
+             */
+            sep = new TransformSeparator(cornerToCRS, factory);
+            sep.addSourceDimensions(gridDimensions);
+            sep.addTargetDimensions(crsDimensions);
+            cornerToCRS = sep.separate();
+        }
+        /*
+         * Get an extent over only the specified grid dimensions. This code may opportunistically substitute
+         * the full grid geometry extent by a sub-region. The use of a sub-region happens if this `reduce(…)`
+         * method is invoked (indirectly) from a method like `GridGeometry.render(…)`.
+         */
+        final boolean useSubExtent = (sliceExtent != null) && !sliceExtent.equals(extent, ComparisonMode.IGNORE_METADATA);
+        if (useSubExtent) {
+            extent = sliceExtent;
+        }
+        if (extent != null) {
+            extent = extent.reduce(gridDimensions);
+        }
+        GeneralEnvelope subArea = null;
+        if (useSubExtent && cornerToCRS != null) try {
+            subArea = extent.toCRS(cornerToCRS, gridToCRS, null);
+        } catch (TransformException e) {
+            // GridGeometry.reduce(…) is the public method invoking indirectly this method.
+            GridGeometry.recoverableException("reduce", e);
+        }
+        /*
+         * Create an envelope with only the requested dimensions, clipped to the sub-area if one has been
+         * computed from `sliceExtent`.  The result after this code may still be a null envelope if there
+         * is not enough information.
+         */
+        final int n = crsDimensions.length;
+        ImmutableEnvelope envelope = geometry.envelope;
+        if (envelope != null) {
+            if (subArea != null || envelope.getDimension() != n) {
+                final CoordinateReferenceSystem crs = CRS.reduce(envelope.getCoordinateReferenceSystem(), crsDimensions);
+                final double[] min = new double[n];
+                final double[] max = new double[n];
+                for (int i=0; i<n; i++) {
+                    final int j = crsDimensions[i];
+                    min[i] = envelope.getLower(j);
+                    max[i] = envelope.getUpper(j);
+                }
+                if (subArea != null) {
+                    for (int i=0; i<n; i++) {
+                        double v;
+                        if ((v = subArea.getLower(i)) > min[i]) min[i] = v;
+                        if ((v = subArea.getUpper(i)) < max[i]) max[i] = v;
+                    }
+                }
+                envelope = new ImmutableEnvelope(min, max, crs);
+            }
+        } else if (subArea != null) {
+            envelope = new ImmutableEnvelope(subArea);
+        }
+        /*
+         * If a `sliceExtent` has been specified, the resolution may differ because the "point of interest"
+         * which is by default in extent center, may now be at a different location. In such case recompute
+         * the resolution. Otherwise (same extent than original grid geometry), just copy resolution values
+         * from the original grid geometry.
+         */
+        if (useSubExtent || resolution == null) {
+            resolution = GridGeometry.resolution(gridToCRS, extent);
+        } else if (resolution.length != n) {
+            resolution = new double[n];
+            for (int i=0; i<n; i++) {
+                resolution[i] = geometry.resolution[crsDimensions[i]];
+            }
+        }
+        /*
+         * Coordinate (0,0) in `RenderedImage` corresponds to the lowest coordinates in `sliceExtent` request.
+         * For taking that offset in account, we need to apply a translation. It happens when this method is
+         * invoked (indirectly) from `GridCoverage.render(…)` but not when invoked from `GridGeometry.reduce(…)`
+         */
+        if (relativeExtent != null) {
+            if (extent != null && !extent.startsAtZero()) {
+                final double[] offset = new double[gridDimensions.length];
+                for (int i=0; i<gridDimensions.length; i++) {
+                    offset[i] = extent.getLow(gridDimensions[i]);
+                }
+                final LinearTransform translation = MathTransforms.translation(offset);
+                gridToCRS   = concatenate(translation, gridToCRS);
+                cornerToCRS = concatenate(translation, cornerToCRS);
+            }
+            extent = relativeExtent;
+        }
+        /*
+         * Slicing should not alter whether conversion in a dimension is a linear operation or not.
+         * So we just copy the flags from the original grid geometry, selecting only the flags for
+         * the specified dimensions.
+         */
+        long nonLinears = 0;
+        for (int i=0; i<n; i++) {
+            nonLinears |= ((geometry.nonLinears >>> crsDimensions[i]) & 1L) << i;
+        }
+        return new GridGeometry(extent, gridToCRS, cornerToCRS, envelope, resolution, nonLinears);
+    }
+
+    /**
+     * Finds CRS (target) dimensions that are related to the given grid (source) dimensions.
+     * This method returns an array where the number of CRS dimensions has been reduced by
+     * the same amount than the reduction in number of grid dimensions.
+     *
+     * <p>If this method is not invoked, then {@link TransformSeparator} will retain as many target dimensions
+     * as possible, which may be more than expected if a dimension that would normally be dropped is actually
+     * a constant (all scale coefficients set to zero). This method tries to avoid this effect by forcing the
+     * removal of CRS dimensions too. The CRS dimensions to remove are the ones that seem the less related to
+     * the grid dimensions that we keep. This method is not provided in {@link TransformSeparator} because of
+     * assumptions on the gridded nature of source coordinates.</p>
+     *
+     * <p>The algorithm used by this method (which is to compare the magnitude of scale coefficients anywhere
+     * in the matrix) assumes that grid cells are "square", e.g. that a translation of 1 pixel to the left is
+     * comparable in "real world" to a translation of 1 pixel to the bottom. This is often true but not always.
+     * To compensate, we divide scale coefficients by the {@linkplain GridGeometry#resolution} for that CRS
+     * dimension.</p>
+     *
+     * @param  gridToCRS       value of {@link GridGeometry#gridToCRS}  (may be {@code null}).
+     * @param  extent          value of {@link GridGeometry#extent}     (may be {@code null}).
+     * @param  resolution      value of {@link GridGeometry#resolution} (may be {@code null}).
+     * @param  gridDimensions  the grid (source) dimensions to keep.
+     * @param  dimCRS          desired number of CRS dimensions, or -1 for automatic.
+     * @return the CRS (target) dimensions to keep, or {@code null} if this method can not compute them.
+     */
+    private static int[] findTargetDimensions(final MathTransform gridToCRS, final GridExtent extent,
+                                              final double[] resolution, int[] gridDimensions, int dimCRS)
+    {
+        /*
+         * In most cases the transform is affine and we do not need a derivative computation
+         * (which save us from requiring a point of interest).
+         */
+        int numRow = -1;        // The -1 is for later exclusion of the [0 0 0 … 1] row in affine transform.
+        Matrix derivative = MathTransforms.getMatrix(gridToCRS);
+        if (derivative == null) {
+            if (extent != null) try {
+                derivative = gridToCRS.derivative(new DirectPositionView.Double(extent.getPointOfInterest()));
+            } catch (TransformException e) {
+                // GridGeometry.reduce(…) is the public method invoking indirectly this method.
+                GridGeometry.recoverableException("reduce", e);
+                return null;
+            } else {
+                return null;
+            }
+            numRow = 0;         // Do not exclude any row in the matrix (cancel the -1 value set earlier).
+        }
+        numRow += derivative.getNumRow();
+        if (dimCRS < 0) {
+            dimCRS = gridDimensions.length + (gridToCRS.getTargetDimensions() - gridToCRS.getSourceDimensions());
+        }
+        /*
+         * Search for the greatest scale coefficient. For the greatest value, take the row as the target
+         * dimension and remember that we should not check anymore any value in the row and column where
+         * the value has been found.
+         */
+        long selected = 0;
+        while (Long.bitCount(selected) < dimCRS) {
+            double max = -1;
+            int   kmax = -1;
+            int   jmax = -1;
+            for (int j=0; j<numRow; j++) {
+                if ((selected & Numerics.bitmask(j)) == 0) {
+                    double r = 1;                               // For compensation of non-square cells.
+                    if (resolution != null) {
+                        final double t = resolution[j];
+                        if (t > 0) r = t;                       // Exclude NaN values.
+                    }
+                    for (int k=0; k < gridDimensions.length; k++) {
+                        final double e = Math.abs(derivative.getElement(j, gridDimensions[k])) / r;
+                        if (e > max) {
+                            max  = e;
+                            kmax = k;
+                            jmax = j;
+                        }
+                    }
+                }
+            }
+            if ((kmax | jmax) < 0) {
+                return null;                            // Can not provide the requested number of dimensions.
+            }
+            if (jmax >= Long.SIZE) {
+                throw GridGeometry.excessiveDimension(gridToCRS);
+            }
+            selected |= (1L << jmax);
+            gridDimensions = ArraysExt.remove(gridDimensions, kmax, 1);
+        }
+        /*
+         * Expand the values encoded in the `selected` bitmask.
+         */
+        final int[] crsDimensions = new int[dimCRS];
+        for (int i=0; i<dimCRS; i++) {
+            final int j = Long.numberOfTrailingZeros(selected);
+            crsDimensions[i] = j;
+            selected &= ~(1L << j);
+        }
+        return crsDimensions;
+    }
+
+    /**
+     * Returns the concatenation of given transforms.
+     */
+    private MathTransform concatenate(final MathTransform tr1, final MathTransform tr2) throws FactoryException {
+        if (factory != null) {
+            return factory.createConcatenatedTransform(tr1, tr2);
+        } else {
+            return MathTransforms.concatenate(tr1, tr2);
+        }
+    }
+}
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 ed6933d..ba1e200 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
@@ -37,7 +37,6 @@ import org.apache.sis.util.ArgumentChecks;
 import org.apache.sis.util.resources.Errors;
 import org.apache.sis.util.collection.WeakHashSet;
 import org.apache.sis.internal.system.Modules;
-import org.apache.sis.internal.coverage.j2d.ImageUtilities;
 import org.apache.sis.internal.coverage.j2d.TileOpExecutor;
 import org.apache.sis.internal.coverage.j2d.TiledImage;
 import org.apache.sis.referencing.operation.transform.MathTransforms;
@@ -104,7 +103,7 @@ public class ImageProcessor implements Cloneable {
      * of known implementation, especially the ones which are costly to compute. The implementation
      * shall override {@link Object#equals(Object)} and {@link Object#hashCode()} methods.
      */
-    private static RenderedImage unique(final RenderedImage image) {
+    static RenderedImage unique(final RenderedImage image) {
         return CACHE.unique(image);
     }
 
@@ -382,15 +381,15 @@ public class ImageProcessor implements Cloneable {
      * then that image is returned as-is. Otherwise this method returns a new image having that property.
      * The property value will be computed when first requested (it is not computed by this method).
      *
-     * @param  source  the image for which to provide statistics.
+     * @param  source  the image for which to provide statistics (may be {@code null}).
      * @return an image with an {@value StatisticsCalculator#STATISTICS_KEY} property.
+     *         May be {@code image} if the given argument is null or already has a statistics property.
      *
      * @see #getStatistics(RenderedImage)
      * @see StatisticsCalculator#STATISTICS_KEY
      */
     public RenderedImage statistics(final RenderedImage source) {
-        ArgumentChecks.ensureNonNull("source", source);
-        return ArraysExt.contains(source.getPropertyNames(), StatisticsCalculator.STATISTICS_KEY)
+        return (source == null) || ArraysExt.contains(source.getPropertyNames(), StatisticsCalculator.STATISTICS_KEY)
                 ? source : unique(new StatisticsCalculator(source, parallel(source), failOnException()));
     }
 
@@ -414,14 +413,7 @@ public class ImageProcessor implements Cloneable {
     public RenderedImage stretchColorRamp(final RenderedImage source, final double minimum, final double maximum) {
         ArgumentChecks.ensureFinite("minimum", minimum);
         ArgumentChecks.ensureFinite("maximum", maximum);
-        if (!(minimum < maximum)) {
-            throw new IllegalArgumentException(Errors.format(Errors.Keys.IllegalRange_2, minimum, maximum));
-        }
-        final int visibleBand = ImageUtilities.getVisibleBand(source);
-        if (visibleBand >= 0) {
-            return unique(RecoloredImage.rescale(source, visibleBand, minimum, maximum));
-        }
-        return source;
+        return RecoloredImage.create(source, minimum, maximum);
     }
 
     /**
@@ -442,18 +434,22 @@ public class ImageProcessor implements Cloneable {
      * <p>The range of values for the color ramp can be narrowed with following modifiers
      * (a {@link Map} is used for allowing addition of more modifiers in future Apache SIS versions).
      * All unrecognized modifiers are silently ignored. If no modifier is specified, then the color ramp
-     * will be stretched from minimum to maximum values.</p>
+     * will be stretched from minimum to maximum values found in specified image.</p>
      *
      * <table>
      *   <caption>Value range modifiers</caption>
      *   <tr>
      *     <th>Key</th>
      *     <th>Purpose</th>
-     *     <th>Examples</th>
+     *     <th>Values</th>
      *   </tr><tr>
-     *     <td>{@code MultStdDev}</td>
+     *     <td>{@code "MultStdDev"}</td>
      *     <td>Multiple of the standard deviation.</td>
-     *     <td>1.5, 2 or 3.</td>
+     *     <td>{@link Number} (typical values: 1.5, 2 or 3)</td>
+     *   </tr><tr>
+     *     <td>{@code "statistics"}</td>
+     *     <td>Statistics or image from which to get statistics.</td>
+     *     <td>{@link Statistics} or {@link RenderedImage}</td>
      *   </tr>
      * </table>
      *
@@ -462,32 +458,8 @@ public class ImageProcessor implements Cloneable {
      * @return the image with color ramp stretched between the automatic bounds,
      *         or {@code image} unchanged if the operation can not be applied on the given image.
      */
-    public RenderedImage stretchColorRamp(final RenderedImage source, final Map<String,Number> modifiers) {
-        double deviations = Double.POSITIVE_INFINITY;
-        if (modifiers != null) {
-            Number value = modifiers.get("MultStdDev");
-            if (value != null) {
-                deviations = value.doubleValue();
-                ArgumentChecks.ensureStrictlyPositive("MultStdDev", deviations);
-            }
-        }
-        final int visibleBand = ImageUtilities.getVisibleBand(source);
-        if (visibleBand >= 0) {
-            final Statistics[] statistics = getStatistics(source);
-            if (statistics != null && visibleBand < statistics.length) {
-                final Statistics s = statistics[visibleBand];
-                if (s != null) {
-                    deviations *= s.standardDeviation(true);
-                    final double mean    = s.mean();
-                    final double minimum = Math.max(s.minimum(), mean - deviations);
-                    final double maximum = Math.min(s.maximum(), mean + deviations);
-                    if (minimum < maximum) {
-                        return unique(RecoloredImage.rescale(source, visibleBand, minimum, maximum));
-                    }
-                }
-            }
-        }
-        return source;
+    public RenderedImage stretchColorRamp(final RenderedImage source, final Map<String,?> modifiers) {
+        return RecoloredImage.create(this, source, modifiers);
     }
 
     /**
diff --git a/core/sis-feature/src/main/java/org/apache/sis/image/PlanarImage.java b/core/sis-feature/src/main/java/org/apache/sis/image/PlanarImage.java
index 258918a..bc29971 100644
--- a/core/sis-feature/src/main/java/org/apache/sis/image/PlanarImage.java
+++ b/core/sis-feature/src/main/java/org/apache/sis/image/PlanarImage.java
@@ -34,6 +34,7 @@ import org.apache.sis.internal.coverage.j2d.ImageUtilities;
 import org.apache.sis.internal.coverage.j2d.TileOpExecutor;
 import org.apache.sis.internal.coverage.j2d.ColorModelFactory;
 import org.apache.sis.internal.jdk9.JDK9;
+import org.apache.sis.coverage.grid.GridGeometry;       // For javadoc
 
 
 /**
@@ -106,6 +107,23 @@ import org.apache.sis.internal.jdk9.JDK9;
  */
 public abstract class PlanarImage implements RenderedImage {
     /**
+     * Key for a property defining a conversion from pixel coordinates to "real world" coordinates.
+     * Other information include an envelope in "real world" coordinates and an estimation of pixel resolution.
+     * The value is a {@link GridGeometry} instance with following properties:
+     *
+     * <ul>
+     *   <li>The {@linkplain GridGeometry#getDimension() number of grid dimensions} is always 2.</li>
+     *   <li>The number of {@linkplain GridGeometry#getCoordinateReferenceSystem() CRS} dimensions is always 2.</li>
+     *   <li>The {@linkplain GridGeometry#getExtent() grid extent} is the {@linkplain #getBounds() image bounds}.</li>
+     *   <li>The {@linkplain GridGeometry#getGridToCRS grid to CRS} map pixel coordinates "real world" coordinates
+     *       (always two-dimensional).</li>
+     * </ul>
+     *
+     * @see org.apache.sis.coverage.grid.ImageRenderer#getImageGeometry(int)
+     */
+    public static final String GRID_GEOMETRY_KEY = "org.apache.sis.GridGeometry";
+
+    /**
      * Key of a property defining the resolutions of sample values in each band. This property is recommended
      * for images having sample values as floating point numbers. For example if sample values were computed by
      * <var>value</var> = <var>integer</var> × <var>scale factor</var>, then the resolution is the scale factor.
@@ -167,6 +185,9 @@ public abstract class PlanarImage implements RenderedImage {
      *     <th>Keys</th>
      *     <th>Values</th>
      *   </tr><tr>
+     *     <td>{@value #GRID_GEOMETRY_KEY}</td>
+     *     <td>Conversion from pixel coordinates to "real world" coordinates.</td>
+     *   </tr><tr>
      *     <td>{@value #SAMPLE_RESOLUTIONS_KEY}</td>
      *     <td>Resolutions of sample values in each band.</td>
      *   </tr><tr>
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 b1709bf..c70970e 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
@@ -16,10 +16,15 @@
  */
 package org.apache.sis.image;
 
+import java.util.Map;
 import java.awt.image.ColorModel;
 import java.awt.image.SampleModel;
 import java.awt.image.RenderedImage;
 import org.apache.sis.internal.coverage.j2d.ColorModelFactory;
+import org.apache.sis.internal.coverage.j2d.ImageUtilities;
+import org.apache.sis.util.resources.Errors;
+import org.apache.sis.util.ArgumentChecks;
+import org.apache.sis.math.Statistics;
 
 
 /**
@@ -57,7 +62,7 @@ final class RecoloredImage extends ImageAdapter {
      * @param  maximum      the sample value to display with the last color of the color ramp (white in a grayscale image).
      * @return the image with color ramp rescaled between the given bounds. May be the given image returned as-is.
      */
-    static RenderedImage rescale(RenderedImage source, final int visibleBand, final double minimum, final double maximum) {
+    private static RenderedImage create(RenderedImage source, final int visibleBand, final double minimum, final double maximum) {
         final SampleModel sm = source.getSampleModel();
         final int dataType = sm.getDataType();
         final ColorModel colors = ColorModelFactory.createGrayScale(dataType, sm.getNumBands(), visibleBand, minimum, maximum);
@@ -70,7 +75,82 @@ final class RecoloredImage extends ImageAdapter {
                 break;
             }
         }
-        return new RecoloredImage(source, colors);
+        return ImageProcessor.unique(new RecoloredImage(source, colors));
+    }
+
+    /**
+     * Implementation of {@link ImageProcessor#stretchColorRamp(RenderedImage, double, double)}.
+     * Defined in this class for reducing {@link ImageProcessor} size.
+     *
+     * @param  source    the image to recolor (may be {@code null}).
+     * @param  minimum   the sample value to display with the first color of the color ramp (black in a grayscale image).
+     * @param  maximum   the sample value to display with the last color of the color ramp (white in a grayscale image).
+     * @return the image with color ramp stretched between the given bounds, or {@code image} unchanged if the operation
+     *         can not be applied on the given image.
+     */
+    static RenderedImage create(final RenderedImage source, final double minimum, final double maximum) {
+        if (!(minimum < maximum)) {
+            throw new IllegalArgumentException(Errors.format(Errors.Keys.IllegalRange_2, minimum, maximum));
+        }
+        final int visibleBand = ImageUtilities.getVisibleBand(source);
+        if (visibleBand >= 0) {
+            return create(source, visibleBand, minimum, maximum);
+        }
+        return source;
+    }
+
+    /**
+     * Implementation of {@link ImageProcessor#stretchColorRamp(RenderedImage, Map)}.
+     * Defined in this class for reducing {@link ImageProcessor} size.
+     * See above-cited public method for the list of modifier keys recognized by this method.
+     *
+     * @param  processor  the processor to use for computing statistics if needed.
+     * @param  source     the image to recolor (may be {@code null}).
+     * @param  modifiers  modifiers for narrowing the range of values, or {@code null} if none.
+     * @return the image with color ramp stretched between the automatic bounds,
+     *         or {@code image} unchanged if the operation can not be applied on the given image.
+     */
+    static RenderedImage create(final ImageProcessor processor, final RenderedImage source, final Map<String,?> modifiers) {
+        RenderedImage statsSource   = source;
+        Statistics[]  statsAllBands = null;
+        Statistics    statistics    = null;
+        double        deviations    = Double.POSITIVE_INFINITY;
+        if (modifiers != null) {
+            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;
+            }
+        }
+        final int visibleBand = ImageUtilities.getVisibleBand(source);
+        if (visibleBand >= 0) {
+            if (statistics == null) {
+                if (statsAllBands == null) {
+                    statsAllBands = processor.getStatistics(statsSource);
+                }
+                if (statsAllBands != null && visibleBand < statsAllBands.length) {
+                    statistics = statsAllBands[visibleBand];
+                }
+            }
+            if (statistics != null) {
+                deviations *= statistics.standardDeviation(true);
+                final double mean    = statistics.mean();
+                final double minimum = Math.max(statistics.minimum(), mean - deviations);
+                final double maximum = Math.min(statistics.maximum(), mean + deviations);
+                if (minimum < maximum) {
+                    return create(source, visibleBand, minimum, maximum);
+                }
+            }
+        }
+        return source;
     }
 
     /**
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 b605755..7fe2994 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
@@ -51,10 +51,9 @@ public class ImageLayout {
 
     /**
      * The default instance which will target {@value ImageUtilities#DEFAULT_TILE_SIZE} pixels as tile
-     * width and height. This default instance conservatively disallows tile sizes that are not divisors
-     * of image size.
+     * width and height.
      */
-    public static final ImageLayout DEFAULT = new ImageLayout(null, false);
+    public static final ImageLayout DEFAULT = new ImageLayout(null);
 
     /**
      * Preferred size for tiles.
@@ -64,20 +63,11 @@ public class ImageLayout {
     private final int preferredTileWidth, preferredTileHeight;
 
     /**
-     * Whether this instance allows tiles that are only partially filled. A value of {@code true} implies that
-     * tiles in the last row or in the last column may contain empty pixels. A value of {@code false} implies
-     * that this class will be unable to subdivide large images in smaller tiles if the image size is a prime
-     * number.
-     */
-    private final boolean allowPartialTiles;
-
-    /**
      * Creates a new image layout.
      *
      * @param  preferredTileSize  the preferred tile size, or {@code null} for the default size.
-     * @param  allowPartialTiles  whether this instance allows tiles that are only partially filled.
      */
-    public ImageLayout(final Dimension preferredTileSize, final boolean allowPartialTiles) {
+    public ImageLayout(final Dimension preferredTileSize) {
         if (preferredTileSize != null) {
             preferredTileWidth  = preferredTileSize.width;
             preferredTileHeight = preferredTileSize.height;
@@ -85,7 +75,6 @@ public class ImageLayout {
             preferredTileWidth  = ImageUtilities.DEFAULT_TILE_SIZE;
             preferredTileHeight = ImageUtilities.DEFAULT_TILE_SIZE;
         }
-        this.allowPartialTiles = allowPartialTiles;
     }
 
     /**
@@ -100,24 +89,32 @@ public class ImageLayout {
      * @return the suggested tile size, or {@code imageSize} if none.
      */
     private static int toTileSize(final int imageSize, final int preferredTileSize, final boolean allowPartialTiles) {
-        if (imageSize <= 2*preferredTileSize) {     // Factor 2 is arbitrary.
+        final int maxTileSize = 2*preferredTileSize;    // Factor 2 is arbitrary (may be revisited in future versions).
+        if (imageSize <= maxTileSize) {
             return imageSize;
         }
         int rmax = imageSize % preferredTileSize;
         if (rmax == 0) return preferredTileSize;
         /*
          * Find tile sizes which are divisors of image size and select the one closest to desired size.
-         * Note: the (i >= 0) check is a paranoiac check redundant with (imageSize % tileSize == 0) check.
+         * Note: the (i >= 0) case should never happen because it an exact match existed, it should have
+         * been found by the (imageSize % tileSize == 0) check.
          */
         final int[] divisors = MathFunctions.divisors(imageSize);
         int i = Arrays.binarySearch(divisors, preferredTileSize);
-        if (i >= 0) return divisors[i];
         if ((i = ~i) < divisors.length) {
-            final int smaller = divisors[i];
-            final boolean tooSmall = (smaller < MIN_TILE_SIZE);
-            if (++i < divisors.length) {
-                final int larger = divisors[i];
-                if (larger < imageSize && (tooSmall || (larger - preferredTileSize) <= preferredTileSize - smaller)) {
+            final int smaller;
+            final boolean tooSmall;
+            if (i == 0) {
+                smaller  = 0;
+                tooSmall = true;
+            } else {
+                smaller  = divisors[i - 1];
+                tooSmall = (smaller < MIN_TILE_SIZE);
+            }
+            final int larger = divisors[i];
+            if (larger <= (allowPartialTiles ? maxTileSize : imageSize)) {
+                if (tooSmall || (larger - preferredTileSize) <= (preferredTileSize - smaller)) {
                     return larger;
                 }
             }
@@ -129,29 +126,31 @@ public class ImageLayout {
          * Found no exact divisor. If we are allowed to return an approximated size,
          * search the divisor which will minimize the amount of empty pixels.
          */
+        if (!allowPartialTiles) {
+            return imageSize;
+        }
         int best = preferredTileSize;
-        if (allowPartialTiles) {
-            for (i = imageSize/2; --i >= MIN_TILE_SIZE;) {
-                final int r = imageSize % i;
-                if (r == 0) return i;       // Should never happen since we checked divisors before, but be paranoiac.
-                if (r > rmax || (r == rmax && Math.abs(i - preferredTileSize) < Math.abs(best - preferredTileSize))) {
-                    rmax = r;
-                    best = i;
-                }
+        for (i = maxTileSize; --i >= MIN_TILE_SIZE;) {
+            final int r = imageSize % i;                    // Should never be 0 since we checked divisors before.
+            if (r > rmax || (r == rmax && Math.abs(i - preferredTileSize) < Math.abs(best - preferredTileSize))) {
+                rmax = r;
+                best = i;
             }
         }
         /*
          * At this point `best` is an "optimal" tile size (the one that left as few empty pixels as possible),
-         * and `rmax` is the amount of non-empty pixels using this tile size. We will use that "optimal" size
-         * only if it fills at least 75% of the tile size. Otherwise, we arbitrarily consider that it doesn't
-         * worth to tile.
+         * and `rmax` is the amount of non-empty pixels using this tile size.
          */
-        return (rmax >= preferredTileSize - preferredTileSize/4) ? best : imageSize;
+        return best;
     }
 
     /**
      * Suggests a tile size for the specified image size. This method suggests a tile size which is a divisor
      * of the given image size if possible, or a size that left as few empty pixels as possible otherwise.
+     * The {@code allowPartialTile} argument specifies whether to allow tiles that are only partially filled.
+     * A value of {@code true} implies that tiles in the last row or in the last column may contain empty pixels.
+     * A value of {@code false} implies that this class will be unable to subdivide large images in smaller tiles
+     * if the image size is a prime number.
      *
      * <p>The {@code allowPartialTile} argument should be {@code false} if the tiled image is opaque,
      * or if the sample value for transparent pixels is different than zero. This restriction is for
@@ -162,8 +161,7 @@ public class ImageLayout {
      * @param  allowPartialTiles  whether to allow tiles that are only partially filled.
      * @return suggested tile size for the given image size.
      */
-    public Dimension suggestTileSize(final int imageWidth, final int imageHeight, boolean allowPartialTiles) {
-        allowPartialTiles &= this.allowPartialTiles;
+    public Dimension suggestTileSize(final int imageWidth, final int imageHeight, final boolean allowPartialTiles) {
         return new Dimension(toTileSize(imageWidth,  preferredTileWidth,  allowPartialTiles),
                              toTileSize(imageHeight, preferredTileHeight, allowPartialTiles));
     }
@@ -183,14 +181,15 @@ public class ImageLayout {
      * @return suggested tile size for the given image.
      */
     public Dimension suggestTileSize(final RenderedImage image, final Rectangle bounds) {
-        boolean pt = allowPartialTiles;
-        if (pt && image != null) {
+        boolean allowPartialTiles = (bounds instanceof PreferredSize);
+        if (allowPartialTiles && image != null) {
             final ColorModel cm = image.getColorModel();
-            if (pt = (cm != null)) {
+            allowPartialTiles = (cm != null);
+            if (allowPartialTiles) {
                 if (cm instanceof IndexColorModel) {
-                    pt = ((IndexColorModel) cm).getTransparentPixel() == 0;
+                    allowPartialTiles = ((IndexColorModel) cm).getTransparentPixel() == 0;
                 } else {
-                    pt = cm.hasAlpha();
+                    allowPartialTiles = (cm.getTransparency() != ColorModel.OPAQUE);
                 }
             }
         }
@@ -215,8 +214,13 @@ public class ImageLayout {
         } else {
             return new Dimension(preferredTileWidth, preferredTileHeight);
         }
-        return new Dimension(toTileSize(width,  preferredTileWidth,  pt & singleXTile),
-                             toTileSize(height, preferredTileHeight, pt & singleYTile));
+        final Dimension tileSize = new Dimension(
+                toTileSize(width,  preferredTileWidth,  allowPartialTiles & singleXTile),
+                toTileSize(height, preferredTileHeight, allowPartialTiles & singleYTile));
+        if (allowPartialTiles) {
+            ((PreferredSize) bounds).makeDivisible(tileSize);
+        }
+        return tileSize;
     }
 
     /**
@@ -226,7 +230,7 @@ public class ImageLayout {
      * constructor.
      *
      * @param  image   the image form which to get a sample model.
-     * @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 image sample model with preferred tile size.
      *
      * @see ComputedImage#ComputedImage(SampleModel, RenderedImage...)
@@ -249,7 +253,6 @@ public class ImageLayout {
     @Override
     public String toString() {
         return Strings.toString(getClass(),
-                "preferredTileSize", new StringBuilder().append(preferredTileWidth).append('×').append(preferredTileHeight),
-                "allowPartialTiles", allowPartialTiles);
+                "preferredTileSize", new StringBuilder().append(preferredTileWidth).append('×').append(preferredTileHeight));
     }
 }
diff --git a/core/sis-feature/src/main/java/org/apache/sis/internal/coverage/j2d/ImageUtilities.java b/core/sis-feature/src/main/java/org/apache/sis/internal/coverage/j2d/ImageUtilities.java
index 584895b..8f19bee 100644
--- a/core/sis-feature/src/main/java/org/apache/sis/internal/coverage/j2d/ImageUtilities.java
+++ b/core/sis-feature/src/main/java/org/apache/sis/internal/coverage/j2d/ImageUtilities.java
@@ -19,6 +19,7 @@ package org.apache.sis.internal.coverage.j2d;
 import java.util.Arrays;
 import java.awt.Rectangle;
 import java.awt.color.ColorSpace;
+import java.awt.geom.AffineTransform;
 import java.awt.image.ColorModel;
 import java.awt.image.DataBuffer;
 import java.awt.image.IndexColorModel;
@@ -35,6 +36,10 @@ import org.apache.sis.util.Static;
 import org.apache.sis.util.logging.Logging;
 import org.apache.sis.util.resources.Vocabulary;
 
+import static java.lang.Math.abs;
+import static java.lang.Math.rint;
+import static org.apache.sis.internal.util.Numerics.COMPARISON_THRESHOLD;
+
 
 /**
  * Utility methods related to images and their color model or sample model.
@@ -407,4 +412,39 @@ public final class ImageUtilities extends Static {
         bounds.height = Math.max(1, Math.min(BUFFER_SIZE / (size * bounds.width), bounds.height));
         return afterLastRow;
     }
+
+    /**
+     * If scale and shear coefficients are close to integers, replaces their current values by their rounded values.
+     * The scale and shear coefficients are handled in a "all or nothing" way; either all of them or none are rounded.
+     * The translation terms are handled separately, provided that the scale and shear coefficients have been rounded.
+     *
+     * <p>This rounding is useful in order to accelerate some rendering operations. In particular Java2D has an
+     * optimization when drawing {@link RenderedImage}: if the transform has only a translation (scale factors
+     * are equal to 1) and if that translation is integer, then Java2D will fetch only tiles that are required
+     * for the area to draw. Otherwise Java2D fetches a copy of the whole image.</p>
+     *
+     * @param  tr  the transform to round. Rounding will be applied in place.
+     * @return whether the transform has integer coefficients (possibly after rounding applied by this method).
+     */
+    public static boolean roundIfAlmostInteger(final AffineTransform tr) {
+        double r;
+        final double m00, m01, m10, m11;
+        if (abs((m00 = rint(r=tr.getScaleX())) - r) <= COMPARISON_THRESHOLD &&
+            abs((m01 = rint(r=tr.getShearX())) - r) <= COMPARISON_THRESHOLD &&
+            abs((m11 = rint(r=tr.getScaleY())) - r) <= COMPARISON_THRESHOLD &&
+            abs((m10 = rint(r=tr.getShearY())) - r) <= COMPARISON_THRESHOLD)
+        {
+            /*
+             * At this point the scale and shear coefficients can been rounded to integers.
+             * Continue only if this rounding does not make the transform non-invertible.
+             */
+            if ((m00!=0 || m01!=0) && (m10!=0 || m11!=0)) {
+                final double m02 = rint(tr.getTranslateX());
+                final double m12 = rint(tr.getTranslateY());
+                tr.setTransform(m00, m10, m01, m11, m02, m12);
+                return true;
+            }
+        }
+        return false;
+    }
 }
diff --git a/core/sis-feature/src/main/java/org/apache/sis/internal/coverage/j2d/PreferredSize.java b/core/sis-feature/src/main/java/org/apache/sis/internal/coverage/j2d/PreferredSize.java
new file mode 100644
index 0000000..06f564d
--- /dev/null
+++ b/core/sis-feature/src/main/java/org/apache/sis/internal/coverage/j2d/PreferredSize.java
@@ -0,0 +1,67 @@
+/*
+ * 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.j2d;
+
+import java.awt.Dimension;
+import java.awt.Rectangle;
+
+
+/**
+ * Specifies an image size which can be modified by {@link ImageLayout} if needed. Changes are applied only if
+ * an image can not be tiled because {@link ImageLayout} can not find a tile size close to the desired size.
+ * For example if the image width is a prime number, there is no way to divide the image horizontally with
+ * an integer number of tiles. The only way to get an integer number of tiles is to change the image size.
+ * The use of this class is understood by {@link ImageLayout} as a permission to do so.
+ *
+ * @author  Martin Desruisseaux (Geomatys)
+ * @version 1.1
+ * @since   1.1
+ * @module
+ */
+@SuppressWarnings("serial")     // Not intended to be serialized.
+public final class PreferredSize extends Rectangle {
+    /**
+     * Creates a new rectangle.
+     */
+    public PreferredSize() {
+    }
+
+    /**
+     * Adjusts the bounds for making it divisible by the given tile size.
+     */
+    final void makeDivisible(final Dimension tileSize) {
+        if (!isEmpty()) {
+            final int sx = sizeToAdd(width,   tileSize.width);
+            final int sy = sizeToAdd(height,  tileSize.height);
+            if ((width  += sx) < 0) width  -= tileSize.width;       // if (overflow) reduce to valid range.
+            if ((height += sy) < 0) height -= tileSize.height;
+            if (x < (x -= sx/2)) x = Integer.MIN_VALUE;             // if (overflow) set to minimal value.
+            if (y < (y -= sy/2)) y = Integer.MIN_VALUE;
+        }
+    }
+
+    /**
+     * Computes the size to add to the width or height for making it divisible by the given tile size.
+     */
+    private static int sizeToAdd(int size, final int tileSize) {
+        size %= tileSize;
+        if (size != 0) {
+            size = tileSize - size;
+        }
+        return size;
+    }
+}
diff --git a/core/sis-feature/src/main/java/org/apache/sis/internal/coverage/j2d/ScaledColorModel.java b/core/sis-feature/src/main/java/org/apache/sis/internal/coverage/j2d/ScaledColorModel.java
index ab6fc8a..3136ab3 100644
--- a/core/sis-feature/src/main/java/org/apache/sis/internal/coverage/j2d/ScaledColorModel.java
+++ b/core/sis-feature/src/main/java/org/apache/sis/internal/coverage/j2d/ScaledColorModel.java
@@ -78,6 +78,14 @@ final class ScaledColorModel extends ComponentColorModel {
     @Override public int getAlpha(int    value) {return                                   MASK;}
 
     /**
+     * Returns whether this color model is capable to handle transparent pixels.
+     */
+    @Override
+    public int getTransparency() {
+        return ImageUtilities.isIntegerType(transferType) ? Transparency.OPAQUE : Transparency.BITMASK;
+    }
+
+    /**
      * Returns the alpha value for the given sample values.
      * This is based only on whether or not the value is NaN.
      */
diff --git a/core/sis-feature/src/main/java/org/apache/sis/internal/coverage/package-info.java b/core/sis-feature/src/main/java/org/apache/sis/internal/coverage/package-info.java
new file mode 100644
index 0000000..46043f4
--- /dev/null
+++ b/core/sis-feature/src/main/java/org/apache/sis/internal/coverage/package-info.java
@@ -0,0 +1,31 @@
+/*
+ * 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.
+ */
+
+/**
+ * A set of helper classes for grid coverages.
+ *
+ * <p><strong>Do not use!</strong></p>
+ *
+ * This package is for internal use by SIS only. Classes in this package
+ * may change in incompatible ways in any future version without notice.
+ *
+ * @author  Martin Desruisseaux (Geomatys)
+ * @version 1.1
+ * @since   1.1
+ * @module
+ */
+package org.apache.sis.internal.coverage;
diff --git a/core/sis-feature/src/test/java/org/apache/sis/coverage/grid/GridGeometryTest.java b/core/sis-feature/src/test/java/org/apache/sis/coverage/grid/GridGeometryTest.java
index 336d60b..f650086 100644
--- a/core/sis-feature/src/test/java/org/apache/sis/coverage/grid/GridGeometryTest.java
+++ b/core/sis-feature/src/test/java/org/apache/sis/coverage/grid/GridGeometryTest.java
@@ -322,7 +322,8 @@ public final strictfp class GridGeometryTest extends TestCase {
 
     /**
      * Tests {@link GridGeometry#reduce(int...)} with a {@code gridToCRS} transform having a constant value
-     * in one dimension. This method tests indirectly {@link GridGeometry#findTargetDimensions(int[])}.
+     * in one dimension. This method tests indirectly {@link SliceGeometry#findTargetDimensions(MathTransform,
+     * GridExtent, double[], int[], int)}.
      */
     @Test
     public void testReduceScalelessDimension() {
diff --git a/core/sis-feature/src/test/java/org/apache/sis/internal/coverage/j2d/ImageLayoutTest.java b/core/sis-feature/src/test/java/org/apache/sis/internal/coverage/j2d/ImageLayoutTest.java
new file mode 100644
index 0000000..c5467c0
--- /dev/null
+++ b/core/sis-feature/src/test/java/org/apache/sis/internal/coverage/j2d/ImageLayoutTest.java
@@ -0,0 +1,44 @@
+/*
+ * 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.j2d;
+
+import java.awt.Dimension;
+import org.apache.sis.test.TestCase;
+import org.junit.Test;
+
+import static org.junit.Assert.*;
+
+
+/**
+ * Tests {@link ImageLayoutTest}.
+ *
+ * @author  Martin Desruisseaux (Geomatys)
+ * @version 1.1
+ * @since   1.1
+ * @module
+ */
+public final strictfp class ImageLayoutTest extends TestCase {
+    /**
+     * Tests {@link ImageLayout#suggestTileSize(int, int, boolean)}.
+     */
+    @Test
+    public void testSuggestTileSize() {
+        final Dimension size = ImageLayout.DEFAULT.suggestTileSize(367877, 5776326, true);
+        assertEquals("width",  511, size.width);
+        assertEquals("height", 246, size.height);
+    }
+}
diff --git a/core/sis-feature/src/test/java/org/apache/sis/internal/coverage/j2d/ImageUtilitiesTest.java b/core/sis-feature/src/test/java/org/apache/sis/internal/coverage/j2d/ImageUtilitiesTest.java
index cc90055..a7e8d28 100644
--- a/core/sis-feature/src/test/java/org/apache/sis/internal/coverage/j2d/ImageUtilitiesTest.java
+++ b/core/sis-feature/src/test/java/org/apache/sis/internal/coverage/j2d/ImageUtilitiesTest.java
@@ -17,6 +17,7 @@
 package org.apache.sis.internal.coverage.j2d;
 
 import java.awt.Rectangle;
+import java.awt.geom.AffineTransform;
 import java.awt.image.ColorModel;
 import java.awt.image.DataBuffer;
 import java.awt.image.RenderedImage;
@@ -28,6 +29,7 @@ import org.apache.sis.test.TestCase;
 import org.junit.Test;
 
 import static org.junit.Assert.*;
+import static org.apache.sis.internal.util.Numerics.COMPARISON_THRESHOLD;
 
 
 /**
@@ -175,4 +177,26 @@ public final strictfp class ImageUtilitiesTest extends TestCase {
                 Vocabulary.Keys.Blue,
                 Vocabulary.Keys.Transparency);
     }
+
+    /**
+     * Tests the {@link ImageUtilities#roundIfAlmostInteger(AffineTransform)} method.
+     */
+    @Test
+    public void testRoundIfAlmostInteger() {
+        final double tolerance = COMPARISON_THRESHOLD;
+        final AffineTransform test = new AffineTransform(4, 0, 0, 4, -400, -1186);
+        final AffineTransform copy = new AffineTransform(test);
+        assertTrue(ImageUtilities.roundIfAlmostInteger(test));
+        assertEquals("Coefficients were already integers, so the " +
+                "transform should not have been modified.", copy, test);
+
+        test.scale(1 + tolerance/8, 1 - tolerance/8);
+        assertTrue(ImageUtilities.roundIfAlmostInteger(test));
+        assertEquals("Coefficients should have been rounded.", copy, test);
+
+        test.scale(1 + tolerance*2, 1 - tolerance*2);
+        assertFalse(ImageUtilities.roundIfAlmostInteger(test));
+        assertFalse("Change was larger than threshold, so the " +
+                "transform should not have been modified.", copy.equals(test));
+    }
 }
diff --git a/core/sis-feature/src/test/java/org/apache/sis/test/suite/FeatureTestSuite.java b/core/sis-feature/src/test/java/org/apache/sis/test/suite/FeatureTestSuite.java
index 87c47b9..527405e 100644
--- a/core/sis-feature/src/test/java/org/apache/sis/test/suite/FeatureTestSuite.java
+++ b/core/sis-feature/src/test/java/org/apache/sis/test/suite/FeatureTestSuite.java
@@ -75,6 +75,7 @@ import org.junit.runners.Suite;
 
     // Rasters
     org.apache.sis.internal.coverage.j2d.ImageUtilitiesTest.class,
+    org.apache.sis.internal.coverage.j2d.ImageLayoutTest.class,
     org.apache.sis.internal.coverage.j2d.ScaledColorSpaceTest.class,
     org.apache.sis.image.PlanarImageTest.class,
     org.apache.sis.image.ComputedImageTest.class,
diff --git a/core/sis-metadata/src/main/java/org/apache/sis/metadata/SpecialCases.java b/core/sis-metadata/src/main/java/org/apache/sis/metadata/SpecialCases.java
index 64b0618..81704e6 100644
--- a/core/sis-metadata/src/main/java/org/apache/sis/metadata/SpecialCases.java
+++ b/core/sis-metadata/src/main/java/org/apache/sis/metadata/SpecialCases.java
@@ -29,7 +29,7 @@ import org.apache.sis.util.collection.BackingStoreException;
  * which are returned as {@link Longitude} or {@link Latitude} instances instead of {@link Double}.
  *
  * @author  Martin Desruisseaux (Geomatys)
- * @version 1.0
+ * @version 1.1
  * @since   0.4
  * @module
  */
@@ -115,9 +115,11 @@ final class SpecialCases extends PropertyAccessor {
         Object value = super.get(index, metadata);
         if (value != null) {
             if (index == westBoundLongitude || index == eastBoundLongitude) {
-                value = new Longitude((Double) value);
+                final double angle = (Double) value;
+                value = Double.isNaN(angle) ? null : new Longitude(angle);
             } else if (index == southBoundLatitude || index == northBoundLatitude) {
-                value = new Latitude((Double) value);
+                final double angle = (Double) value;
+                value = Double.isNaN(angle) ? null : new Latitude(angle);
             }
         }
         return value;
diff --git a/core/sis-portrayal/src/main/java/org/apache/sis/portrayal/Canvas.java b/core/sis-portrayal/src/main/java/org/apache/sis/portrayal/Canvas.java
index 0707e0f..ff52a94 100644
--- a/core/sis-portrayal/src/main/java/org/apache/sis/portrayal/Canvas.java
+++ b/core/sis-portrayal/src/main/java/org/apache/sis/portrayal/Canvas.java
@@ -804,9 +804,10 @@ public class Canvas extends Observable implements Localized {
     /**
      * Returns the coordinate values of the Point Of Interest (POI) in objective CRS.
      * The array length should be equal to {@link #getDisplayDimensions()}.
+     * May be {@code null} if the point of interest is unknown.
      */
     final double[] getObjectivePOI() {
-        return objectivePOI.getCoordinate();
+        return (objectivePOI != null) ? objectivePOI.getCoordinate() : null;
     }
 
     /**
diff --git a/core/sis-portrayal/src/main/java/org/apache/sis/portrayal/CanvasContext.java b/core/sis-portrayal/src/main/java/org/apache/sis/portrayal/CanvasContext.java
index 4a26338..42fbd22 100644
--- a/core/sis-portrayal/src/main/java/org/apache/sis/portrayal/CanvasContext.java
+++ b/core/sis-portrayal/src/main/java/org/apache/sis/portrayal/CanvasContext.java
@@ -185,26 +185,28 @@ final class CanvasContext extends CoordinateOperationContext {
          */
         if (!(resolution > 0)) {
             final double[] poi = canvas.getObjectivePOI();
-            objectiveToDisplay.transform(poi, 0, poi, 0, 1);
-            final Matrix derivative = MathTransforms.derivativeAndTransform(displayToGeographic, poi, 0, poi, 1);
-            final double[] vector   = new double[derivative.getNumCol()];
-            final double[] combined = new double[derivative.getNumRow()];
-            for (int j=combined.length; --j>=0;) {                      // Process latitude (1) before longitude (0).
-                for (int i=0; i<vector.length; i++) {
-                    vector[i] = derivative.getElement(j,i);
+            if (poi != null) {
+                objectiveToDisplay.transform(poi, 0, poi, 0, 1);
+                final Matrix derivative = MathTransforms.derivativeAndTransform(displayToGeographic, poi, 0, poi, 0);
+                final double[] vector   = new double[derivative.getNumCol()];
+                final double[] combined = new double[derivative.getNumRow()];
+                for (int j=combined.length; --j>=0;) {                      // Process latitude (1) before longitude (0).
+                    for (int i=0; i<vector.length; i++) {
+                        vector[i] = derivative.getElement(j,i);
+                    }
+                    double m = MathFunctions.magnitude(vector);
+                    switch (j) {
+                        case 0: m *= Math.cos(combined[1]);                 // Adjust longitude, then fall through.
+                        case 1: m  = Math.toRadians(m); break;              // Latitude (this case) or longitude (case 0).
+                        // Other cases: assume value already in metres.
+                    }
+                    combined[j] = m;
                 }
-                double m = MathFunctions.magnitude(vector);
-                switch (j) {
-                    case 0: m *= Math.cos(combined[1]);                 // Adjust longitude, then fall through.
-                    case 1: m  = Math.toRadians(m); break;              // Latitude (this case) or longitude (case 0).
-                    // Other cases: assume value already in metres.
-                }
-                combined[j] = m;
+                final Ellipsoid ellipsoid = ((GeographicCRS) objectiveToGeographic.getTargetCRS()).getDatum().getEllipsoid();
+                double radius = Formulas.radiusOfConformalSphere(ellipsoid, combined[1]);
+                radius = ellipsoid.getAxisUnit().getConverterTo(Units.METRE).convert(radius);
+                resolution = MathFunctions.magnitude(combined) * radius;
             }
-            final Ellipsoid ellipsoid = ((GeographicCRS) objectiveToGeographic.getTargetCRS()).getDatum().getEllipsoid();
-            double radius = Formulas.radiusOfConformalSphere(ellipsoid, combined[1]);
-            radius = ellipsoid.getAxisUnit().getConverterTo(Units.METRE).convert(radius);
-            resolution = MathFunctions.magnitude(combined) * radius;
         }
     }
 
diff --git a/core/sis-portrayal/src/main/java/org/apache/sis/portrayal/PlanarCanvas.java b/core/sis-portrayal/src/main/java/org/apache/sis/portrayal/PlanarCanvas.java
index ffac6e0..394ef2b 100644
--- a/core/sis-portrayal/src/main/java/org/apache/sis/portrayal/PlanarCanvas.java
+++ b/core/sis-portrayal/src/main/java/org/apache/sis/portrayal/PlanarCanvas.java
@@ -108,7 +108,9 @@ public abstract class PlanarCanvas extends Canvas {
      * {@link Units#PIXEL} and coordinate values are usually (but not necessarily) integers.
      *
      * <p>This value may be {@code null} on newly created {@code Canvas}, before data are added and canvas
-     * is configured. It should not be {@code null} anymore once a {@code Canvas} is ready for displaying.</p>
+     * is configured. It should not be {@code null} anymore once a {@code Canvas} is ready for displaying.
+     * The returned envelope is a copy; display changes happening after this method invocation will not be
+     * reflected in the returned envelope.</p>
      *
      * @return size and location of the display device.
      *
diff --git a/core/sis-referencing-by-identifiers/src/main/java/org/apache/sis/referencing/gazetteer/MilitaryGridReferenceSystem.java b/core/sis-referencing-by-identifiers/src/main/java/org/apache/sis/referencing/gazetteer/MilitaryGridReferenceSystem.java
index 1f9acd3..d07f5c1 100644
--- a/core/sis-referencing-by-identifiers/src/main/java/org/apache/sis/referencing/gazetteer/MilitaryGridReferenceSystem.java
+++ b/core/sis-referencing-by-identifiers/src/main/java/org/apache/sis/referencing/gazetteer/MilitaryGridReferenceSystem.java
@@ -49,6 +49,7 @@ import org.apache.sis.referencing.NamedIdentifier;
 import org.apache.sis.referencing.IdentifiedObjects;
 import org.apache.sis.referencing.cs.AxesConvention;
 import org.apache.sis.referencing.crs.DefaultProjectedCRS;
+import org.apache.sis.referencing.operation.transform.MathTransforms;
 import org.apache.sis.internal.referencing.j2d.IntervalRectangle;
 import org.apache.sis.metadata.sql.MetadataSource;
 import org.apache.sis.metadata.sql.MetadataStoreException;
@@ -1094,7 +1095,7 @@ public class MilitaryGridReferenceSystem extends ReferencingByIdentifiers {
                 yEnd  = y    - step;
             }
             yStart    = gridY;
-            gridToAOI = (MathTransform2D) op.getMathTransform().inverse();
+            gridToAOI = MathTransforms.bidimensional(op.getMathTransform().inverse());
             /*
              * To be strict, we should also test that the region of interest does not intersect both the upper half
              * and lower half of Universal Polar Stereographic (UPS) projection domain. We do not check that because
diff --git a/core/sis-referencing/src/main/java/org/apache/sis/geometry/Shapes2D.java b/core/sis-referencing/src/main/java/org/apache/sis/geometry/Shapes2D.java
index 81d52ec..c037999 100644
--- a/core/sis-referencing/src/main/java/org/apache/sis/geometry/Shapes2D.java
+++ b/core/sis-referencing/src/main/java/org/apache/sis/geometry/Shapes2D.java
@@ -22,7 +22,6 @@ import java.awt.geom.Line2D;
 import java.awt.geom.Ellipse2D;
 import java.awt.geom.Rectangle2D;
 import java.awt.geom.AffineTransform;
-import org.opengis.geometry.MismatchedDimensionException;
 import org.opengis.referencing.cs.CoordinateSystem;
 import org.opengis.referencing.cs.CoordinateSystemAxis;
 import org.opengis.referencing.crs.CoordinateReferenceSystem;
@@ -37,7 +36,7 @@ import org.apache.sis.internal.referencing.j2d.IntervalRectangle;
 import org.apache.sis.internal.referencing.CoordinateOperations;
 import org.apache.sis.referencing.operation.AbstractCoordinateOperation;
 import org.apache.sis.referencing.operation.matrix.AffineTransforms2D;
-import org.apache.sis.util.resources.Errors;
+import org.apache.sis.referencing.operation.transform.MathTransforms;
 import org.apache.sis.util.ArgumentChecks;
 import org.apache.sis.util.Static;
 
@@ -400,12 +399,7 @@ public final class Shapes2D extends Static {
         if (envelope == null) {
             return null;
         }
-        final MathTransform transform = operation.getMathTransform();
-        if (!(transform instanceof MathTransform2D)) {
-            throw new MismatchedDimensionException(Errors.format(Errors.Keys.IllegalPropertyValueClass_3,
-                    "transform", MathTransform2D.class, MathTransform.class));
-        }
-        MathTransform2D mt = (MathTransform2D) transform;
+        MathTransform2D mt = MathTransforms.bidimensional(operation.getMathTransform());
         final double[] center = new double[2];
         destination = transform(mt, envelope, destination, center);
         /*
diff --git a/core/sis-referencing/src/main/java/org/apache/sis/referencing/operation/matrix/AffineTransforms2D.java b/core/sis-referencing/src/main/java/org/apache/sis/referencing/operation/matrix/AffineTransforms2D.java
index 04da43b..b2e8363 100644
--- a/core/sis-referencing/src/main/java/org/apache/sis/referencing/operation/matrix/AffineTransforms2D.java
+++ b/core/sis-referencing/src/main/java/org/apache/sis/referencing/operation/matrix/AffineTransforms2D.java
@@ -39,6 +39,7 @@ import static java.awt.geom.AffineTransform.*;
 /**
  * Bridge between {@link Matrix} and Java2D {@link AffineTransform} instances.
  * Those {@code AffineTransform} instances can be viewed as 3×3 matrices.
+ * Contains also utility methods operating on {@link AffineTransform} instances.
  *
  * @author  Martin Desruisseaux (IRD, Geomatys)
  * @version 1.1
diff --git a/core/sis-referencing/src/main/java/org/apache/sis/referencing/operation/transform/MathTransforms.java b/core/sis-referencing/src/main/java/org/apache/sis/referencing/operation/transform/MathTransforms.java
index 23dfc19..75e90b1 100644
--- a/core/sis-referencing/src/main/java/org/apache/sis/referencing/operation/transform/MathTransforms.java
+++ b/core/sis-referencing/src/main/java/org/apache/sis/referencing/operation/transform/MathTransforms.java
@@ -469,7 +469,7 @@ public final class MathTransforms extends Static {
     public static MathTransform2D concatenate(MathTransform2D tr1, MathTransform2D tr2)
             throws MismatchedDimensionException
     {
-        return (MathTransform2D) concatenate((MathTransform) tr1, (MathTransform) tr2);
+        return bidimensional(concatenate((MathTransform) tr1, (MathTransform) tr2));
     }
 
     /**
@@ -525,7 +525,26 @@ public final class MathTransforms extends Static {
     public static MathTransform2D concatenate(MathTransform2D tr1, MathTransform2D tr2, MathTransform2D tr3)
             throws MismatchedDimensionException
     {
-        return (MathTransform2D) concatenate((MathTransform) tr1, (MathTransform) tr2, (MathTransform) tr3);
+        return bidimensional(concatenate((MathTransform) tr1, (MathTransform) tr2, (MathTransform) tr3));
+    }
+
+    /**
+     * Returns the given transform as a {@link MathTransform2D} instance.
+     * If the given transform is {@code null} or already implements the {@link MathTransform2D} interface,
+     * then it is returned as-is. Otherwise the given transform is wrapped in an adapter.
+     *
+     * @param  transform  the transform to have as {@link MathTransform2D} instance, or {@code null}.
+     * @return the given transform as a {@link MathTransform2D}, or {@code null} if the argument was null.
+     * @throws MismatchedDimensionException if the number of source and target dimensions is not 2.
+     *
+     * @since 1.1
+     */
+    public static MathTransform2D bidimensional(final MathTransform transform) {
+        if (transform == null || transform instanceof MathTransform2D) {
+            return (MathTransform2D) transform;
+        } else {
+            return new TransformAdapter2D(transform);
+        }
     }
 
     /**
diff --git a/core/sis-referencing/src/main/java/org/apache/sis/referencing/operation/transform/TransformAdapter2D.java b/core/sis-referencing/src/main/java/org/apache/sis/referencing/operation/transform/TransformAdapter2D.java
new file mode 100644
index 0000000..67a82fe
--- /dev/null
+++ b/core/sis-referencing/src/main/java/org/apache/sis/referencing/operation/transform/TransformAdapter2D.java
@@ -0,0 +1,107 @@
+/*
+ * 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.referencing.operation.transform;
+
+import java.io.Serializable;
+import org.opengis.geometry.DirectPosition;
+import org.opengis.geometry.MismatchedDimensionException;
+import org.opengis.referencing.operation.Matrix;
+import org.opengis.referencing.operation.MathTransform;
+import org.opengis.referencing.operation.MathTransform2D;
+import org.opengis.referencing.operation.TransformException;
+import org.opengis.referencing.operation.NoninvertibleTransformException;
+import org.apache.sis.geometry.DirectPosition2D;
+import org.apache.sis.util.resources.Errors;
+
+
+/**
+ * Wraps a {@link MathTransform} as a {@link MathTransform2D}. This adapter should not be needed with
+ * Apache SIS implementations. It is provided in case we got a foreigner implementation that do not
+ * implement the expected interface.
+ *
+ * @author  Martin Desruisseaux (Geomatys)
+ * @version 1.1
+ *
+ * @see MathTransforms#bidimensional(MathTransform)
+ *
+ * @since 1.1
+ * @module
+ */
+final class TransformAdapter2D extends AbstractMathTransform2D implements Serializable {
+    /**
+     * For cross-version compatibility.
+     */
+    private static final long serialVersionUID = 7587206692912120654L;
+
+    /**
+     * The math transform which was supposed to implement the {@link MathTransform2D} interface..
+     */
+    private final MathTransform impl;
+
+    /**
+     * Creates a wrapper for the given transform.
+     */
+    TransformAdapter2D(final MathTransform impl) {
+        this.impl = impl;
+        int dim;
+        if ((dim = impl.getSourceDimensions()) != 2 || (dim = impl.getTargetDimensions()) != 2) {
+            throw new MismatchedDimensionException(Errors.format(Errors.Keys.MismatchedDimension_3, "transform", 2, dim));
+        }
+    }
+
+    /** Transforms a single point and opportunistically compute its derivative. */
+    @Override public Matrix transform(double[] srcPts, int srcOff, double[] dstPts, int dstOff, boolean derivate) throws TransformException {
+        Matrix d = derivate ? impl.derivative(new DirectPosition2D(srcPts[srcOff], srcPts[srcOff + 1])) : null;
+        if (dstPts != null) impl.transform(srcPts, srcOff, dstPts, dstOff, 1);
+        return d;
+    }
+
+    /** Delegates to wrapped transform. */
+    @Override public void transform(double[] srcPts, int srcOff, double[] dstPts, int dstOff, int numPts) throws TransformException {
+        impl.transform(srcPts, srcOff, dstPts, dstOff, numPts);
+    }
+
+    /** Delegates to wrapped transform. */
+    @Override public void transform(double[] srcPts, int srcOff, float[]  dstPts, int dstOff, int numPts) throws TransformException {
+        impl.transform(srcPts, srcOff, dstPts, dstOff, numPts);
+    }
+
+    /** Delegates to wrapped transform. */
+    @Override public void transform(float[]  srcPts, int srcOff, double[] dstPts, int dstOff, int numPts) throws TransformException {
+        impl.transform(srcPts, srcOff, dstPts, dstOff, numPts);
+    }
+
+    /** Delegates to wrapped transform. */
+    @Override public void transform(float[] srcPts, int srcOff, float[] dstPts, int dstOff, int numPts) throws TransformException {
+        impl.transform(srcPts, srcOff, dstPts, dstOff, numPts);
+    }
+
+    /** Delegates to wrapped transform. */
+    @Override public DirectPosition transform(DirectPosition ptSrc, DirectPosition ptDst) throws TransformException {
+        return impl.transform(ptSrc, ptDst);
+    }
+
+    /** Delegates to wrapped transform. */
+    @Override public MathTransform2D inverse() throws NoninvertibleTransformException {
+        return MathTransforms.bidimensional(impl.inverse());
+    }
+
+    /** Delegates to wrapped transform. */
+    @Override public String toWKT() {
+        return impl.toWKT();
+    }
+}
diff --git a/core/sis-referencing/src/test/java/org/apache/sis/referencing/operation/matrix/AffineTransforms2DTest.java b/core/sis-referencing/src/test/java/org/apache/sis/referencing/operation/matrix/AffineTransforms2DTest.java
index b103c38..4eba3fa 100644
--- a/core/sis-referencing/src/test/java/org/apache/sis/referencing/operation/matrix/AffineTransforms2DTest.java
+++ b/core/sis-referencing/src/test/java/org/apache/sis/referencing/operation/matrix/AffineTransforms2DTest.java
@@ -28,7 +28,7 @@ import static java.lang.StrictMath.*;
  * Tests the {@link AffineTransforms2D} static methods.
  *
  * @author  Martin Desruisseaux (IRD, Geomatys)
- * @version 0.4
+ * @version 1.1
  * @since   0.4
  * @module
  */


Mime
View raw message