You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@sis.apache.org by de...@apache.org on 2023/04/23 17:03:35 UTC

[sis] branch geoapi-4.0 updated (8f686ad270 -> e4fc9a54a7)

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

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


    from 8f686ad270 Update for a change in localization data in Java 20: `Locale.CANADA` become more like US. We fix the tests by using `Locale.CANADA_FRENCH`, which keep the "year/month/day" format.
     new 59b0f4a2a1 Fix a compilation error which was unnoticed before Java 20.
     new e4fc9a54a7 Make `CoverageCombiner` more suitable to public API: - infer `xdim` and `ydim` automatically. - check units of measurement.

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


Summary of changes:
 .../org/apache/sis/coverage/CoverageCombiner.java  | 134 +++++++++++++------
 .../sis/coverage/grid/GridCoverageBuilder.java     |   5 +
 .../org/apache/sis/coverage/grid/GridExtent.java   |  82 +++++++++++-
 .../java/org/apache/sis/image/ComputedImage.java   |  20 +--
 .../java/org/apache/sis/image/ImageCombiner.java   |  72 ++++------
 .../java/org/apache/sis/image/ImageProcessor.java  |   8 +-
 .../java/org/apache/sis/image/Visualization.java   |   2 +-
 .../sis/internal/coverage/SampleDimensions.java    |  36 +++++
 .../sis/internal/coverage/j2d/ImageLayout.java     |  62 +++++++--
 .../apache/sis/coverage/CoverageCombinerTest.java  |  70 ++++++++++
 .../apache/sis/coverage/grid/GridExtentTest.java   |  22 +++-
 .../apache/sis/test/suite/FeatureTestSuite.java    |   1 +
 .../operation/transform/MathTransforms.java        |  16 +++
 .../operation/transform/UnitConversion.java        | 145 +++++++++++++++++++++
 ...DefinitionTest.java => UnitConversionTest.java} |  42 +++---
 .../sis/test/suite/ReferencingTestSuite.java       |   1 +
 .../apache/sis/internal/sql/feature/Column.java    |   2 +-
 .../internal/storage/WritableResourceSupport.java  |   9 +-
 18 files changed, 591 insertions(+), 138 deletions(-)
 create mode 100644 core/sis-feature/src/test/java/org/apache/sis/coverage/CoverageCombinerTest.java
 create mode 100644 core/sis-referencing/src/main/java/org/apache/sis/referencing/operation/transform/UnitConversion.java
 copy core/sis-referencing/src/test/java/org/apache/sis/referencing/operation/transform/{DomainDefinitionTest.java => UnitConversionTest.java} (50%)


[sis] 01/02: Fix a compilation error which was unnoticed before Java 20.

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

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

commit 59b0f4a2a1dd5e5de7454f43b6c752840c73c26a
Author: Martin Desruisseaux <ma...@geomatys.com>
AuthorDate: Sun Apr 23 19:01:47 2023 +0200

    Fix a compilation error which was unnoticed before Java 20.
---
 .../src/main/java/org/apache/sis/internal/sql/feature/Column.java       | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/storage/sis-sqlstore/src/main/java/org/apache/sis/internal/sql/feature/Column.java b/storage/sis-sqlstore/src/main/java/org/apache/sis/internal/sql/feature/Column.java
index 40b4624ea1..3550760200 100644
--- a/storage/sis-sqlstore/src/main/java/org/apache/sis/internal/sql/feature/Column.java
+++ b/storage/sis-sqlstore/src/main/java/org/apache/sis/internal/sql/feature/Column.java
@@ -189,7 +189,7 @@ public final class Column {
      * For example, we sometimes get {@code "public"."geometry"} (including the quotes)
      * instead of a plain {@code geometry}. If this is the case, keep only the local part.
      *
-     * @param  type   value found in the {@value Reflection.TYPE_NAME} column.
+     * @param  type   value found in the {@value Reflection#TYPE_NAME} column.
      * @param  quote  value of {@code DatabaseMetaData.getIdentifierQuoteString()}.
      * @return local part of the type name.
      */


[sis] 02/02: Make `CoverageCombiner` more suitable to public API: - infer `xdim` and `ydim` automatically. - check units of measurement.

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

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

commit e4fc9a54a7cf2ebfb3110b5163dd38a607b1568c
Author: Martin Desruisseaux <ma...@geomatys.com>
AuthorDate: Sun Apr 23 19:02:08 2023 +0200

    Make `CoverageCombiner` more suitable to public API:
    - infer `xdim` and `ydim` automatically.
    - check units of measurement.
---
 .../org/apache/sis/coverage/CoverageCombiner.java  | 134 +++++++++++++------
 .../sis/coverage/grid/GridCoverageBuilder.java     |   5 +
 .../org/apache/sis/coverage/grid/GridExtent.java   |  82 +++++++++++-
 .../java/org/apache/sis/image/ComputedImage.java   |  20 +--
 .../java/org/apache/sis/image/ImageCombiner.java   |  72 ++++------
 .../java/org/apache/sis/image/ImageProcessor.java  |   8 +-
 .../java/org/apache/sis/image/Visualization.java   |   2 +-
 .../sis/internal/coverage/SampleDimensions.java    |  36 +++++
 .../sis/internal/coverage/j2d/ImageLayout.java     |  62 +++++++--
 .../apache/sis/coverage/CoverageCombinerTest.java  |  70 ++++++++++
 .../apache/sis/coverage/grid/GridExtentTest.java   |  22 +++-
 .../apache/sis/test/suite/FeatureTestSuite.java    |   1 +
 .../operation/transform/MathTransforms.java        |  16 +++
 .../operation/transform/UnitConversion.java        | 145 +++++++++++++++++++++
 .../operation/transform/UnitConversionTest.java    |  59 +++++++++
 .../sis/test/suite/ReferencingTestSuite.java       |   1 +
 .../internal/storage/WritableResourceSupport.java  |   9 +-
 17 files changed, 624 insertions(+), 120 deletions(-)

diff --git a/core/sis-feature/src/main/java/org/apache/sis/coverage/CoverageCombiner.java b/core/sis-feature/src/main/java/org/apache/sis/coverage/CoverageCombiner.java
index 231f6946c1..aa2cbd7d8f 100644
--- a/core/sis-feature/src/main/java/org/apache/sis/coverage/CoverageCombiner.java
+++ b/core/sis-feature/src/main/java/org/apache/sis/coverage/CoverageCombiner.java
@@ -20,10 +20,14 @@ import java.util.Arrays;
 import java.awt.Dimension;
 import java.awt.image.RenderedImage;
 import java.awt.image.WritableRenderedImage;
+import javax.measure.IncommensurableException;
+import javax.measure.Unit;
 import org.opengis.geometry.Envelope;
 import org.opengis.referencing.datum.PixelInCell;
 import org.opengis.referencing.operation.MathTransform;
+import org.opengis.referencing.operation.MathTransform1D;
 import org.opengis.referencing.operation.TransformException;
+import org.apache.sis.referencing.operation.transform.MathTransforms;
 import org.apache.sis.coverage.grid.GridExtent;
 import org.apache.sis.coverage.grid.GridGeometry;
 import org.apache.sis.coverage.grid.GridCoverage;
@@ -32,14 +36,17 @@ import org.apache.sis.image.ImageProcessor;
 import org.apache.sis.image.ImageCombiner;
 import org.apache.sis.image.Interpolation;
 import org.apache.sis.image.PlanarImage;
-import org.apache.sis.util.ArraysExt;
 import org.apache.sis.util.ArgumentChecks;
-import org.apache.sis.util.resources.Errors;
+import org.apache.sis.measure.NumberRange;
+import org.apache.sis.internal.coverage.SampleDimensions;
 
 import static java.lang.Math.round;
 import static org.apache.sis.internal.util.Numerics.saturatingAdd;
 import static org.apache.sis.internal.util.Numerics.saturatingSubtract;
 
+// Branch-dependent imports
+import org.opengis.coverage.CannotEvaluateException;
+
 
 /**
  * Combines an arbitrary number of coverages into a single one.
@@ -49,11 +56,14 @@ import static org.apache.sis.internal.util.Numerics.saturatingSubtract;
  * <ol>
  *   <li>Creates a {@code CoverageCombiner} with the destination coverage where to write.</li>
  *   <li>Configure with methods such as {@link #setInterpolation setInterpolation(…)}.</li>
- *   <li>Invoke {@link #apply apply(…)} methods for each list of coverages to combine.</li>
+ *   <li>Invoke {@link #acceptAll acceptAll(…)} methods for each list of coverages to combine.</li>
  *   <li>Get the combined coverage with {@link #result()}.</li>
  * </ol>
  *
+ * Coverage domains can have any number of dimensions.
  * Coverages are combined in the order they are specified.
+ * For each coverage, sample dimensions are combined in the order they appear, regardless their names.
+ * For each sample dimension, values are converted to the unit of measurement of the destination coverage.
  *
  * <h2>Limitations</h2>
  * The current implementation has the following limitations.
@@ -61,11 +71,10 @@ import static org.apache.sis.internal.util.Numerics.saturatingSubtract;
  *
  * <ul>
  *   <li>Supports only {@link GridCoverage} instances, not yet more generic coverages.</li>
- *   <li>No interpolation except in the two dimensions specified at construction time.
+ *   <li>No interpolation except in the two dimensions having the largest size (usually the 2 first).
  *       For all other dimensions, data are taken from the nearest neighbor two-dimensional slice.</li>
  *   <li>No expansion of the destination coverage for accommodating data of source coverages
  *       that are outside the destination coverage bounds.</li>
- *   <li>No verification of whether sample dimensions are in the same order.</li>
  * </ul>
  *
  * @author  Martin Desruisseaux (Geomatys)
@@ -95,29 +104,30 @@ public class CoverageCombiner {
     /**
      * The dimension to extract as {@link RenderedImage}s.
      * This is usually 0 for <var>x</var> and 1 for <var>y</var>.
+     * The other dimensions can have any size (not restricted to 1 cell).
      */
     private final int xdim, ydim;
 
+    /**
+     * Whether the {@linkplain #destination} uses converted values.
+     */
+    private final boolean isConverted;
+
     /**
      * Creates a coverage combiner which will write in the given coverage.
-     * The coverage is not cleared; cells that are not overwritten by calls
+     * The coverage is not cleared: cells that are not overwritten by calls
      * to the {@code accept(…)} method will be left unchanged.
      *
      * @param  destination  the destination coverage where to combine source coverages.
-     * @param  xdim         the dimension to extract as {@link RenderedImage} <var>x</var> axis. This is usually 0.
-     * @param  ydim         the dimension to extract as {@link RenderedImage} <var>y</var> axis. This is usually 1.
+     * @throws CannotEvaluateException if the coverage does not have at least 2 dimensions.
      */
-    public CoverageCombiner(final GridCoverage destination, final int xdim, final int ydim) {
+    public CoverageCombiner(final GridCoverage destination) {
         ArgumentChecks.ensureNonNull("destination", destination);
-        this.destination = destination;
-        final int dimension = destination.getGridGeometry().getDimension();
-        ArgumentChecks.ensureBetween("xdim", 0, dimension-1, xdim);
-        ArgumentChecks.ensureBetween("ydim", 0, dimension-1, ydim);
-        if (xdim == ydim) {
-            throw new IllegalArgumentException(Errors.format(Errors.Keys.DuplicatedNumber_1, xdim));
-        }
-        this.xdim = xdim;
-        this.ydim = ydim;
+        this.destination = destination.forConvertedValues(true);
+        isConverted = (this.destination == destination);
+        final int[] dim = destination.getGridGeometry().getExtent().getLargestDimensions(BIDIMENSIONAL);
+        xdim = dim[0];
+        ydim = dim[1];
         processor = new ImageProcessor();
     }
 
@@ -166,35 +176,77 @@ public class CoverageCombiner {
         return new ImageRenderer(coverage, slice).getImageGeometry(BIDIMENSIONAL);
     }
 
+    /**
+     * Returns the conversions from source units to target units.
+     * Conversion is fetched for each pair of units at the same index.
+     *
+     * @param  sources  the source units. May contain null elements.
+     * @param  targets  the target units. May contain null elements.
+     * @return converters, or {@code null} if none. May contain null elements.
+     * @throws IncommensurableException if a pair of units are not convertible.
+     */
+    private static MathTransform1D[] createUnitConverters(final Unit<?>[] sources, final Unit<?>[] targets)
+            throws IncommensurableException
+    {
+        MathTransform1D[] converters = null;
+        final int n = Math.min(sources.length, targets.length);
+        for (int i=0; i<n; i++) {
+            final Unit<?> source = sources[i];
+            final Unit<?> target = targets[i];
+            if (source != null && target != null) {
+                final MathTransform1D c = MathTransforms.convert(source.getConverterToAny(target));
+                if (!c.isIdentity()) {
+                    if (converters == null) {
+                        converters = new MathTransform1D[n];
+                        Arrays.fill(converters, MathTransforms.identity(1));
+                    }
+                    converters[i] = c;
+                }
+            }
+        }
+        return converters;
+    }
+
     /**
      * Writes the given coverages on top of the destination coverage.
      * The given coverages are resampled to the grid geometry of the destination coverage.
      * Coverages that do not intercept with the destination coverage are silently ignored.
      *
+     * <h4>Performance note</h4>
+     * If there is many coverages to write, they should be specified in a single
+     * call to {@code acceptAll(…)} instead of invoking this method multiple times.
+     * Bulk operations can reduce the number of calls to {@link GridCoverage#render(GridExtent)}.
+     *
      * @param  sources  the coverages to write on top of destination coverage.
      * @return {@code true} on success, or {@code false} if at least one slice
      *         in the destination coverage is not writable.
      * @throws TransformException if the coordinates of a given coverage cannot be transformed
      *         to the coordinates of destination coverage.
+     * @throws IncommensurableException if the unit of measurement of at least one source sample dimension
+     *         is not convertible to the unit of measurement of the corresponding target sample dimension.
      */
-    public boolean apply(GridCoverage... sources) throws TransformException {
+    public boolean acceptAll(GridCoverage... sources) throws TransformException, IncommensurableException {
         ArgumentChecks.ensureNonNull("sources", sources);
         sources = sources.clone();
-        final GridGeometry    targetGG            = destination.getGridGeometry();
-        final GridExtent      targetEx            = targetGG.getExtent();
-        final int             dimension           = targetEx.getDimension();
-        final long[]          minIndices          = new long[dimension]; Arrays.fill(minIndices, Long.MAX_VALUE);
-        final long[]          maxIndices          = new long[dimension]; Arrays.fill(maxIndices, Long.MIN_VALUE);
-        final MathTransform[] toSourceSliceCorner = new MathTransform[sources.length];
-        final MathTransform[] toSourceSliceCenter = new MathTransform[sources.length];
+        final GridGeometry        targetGG            = destination.getGridGeometry();
+        final GridExtent          targetEx            = targetGG.getExtent();
+        final int                 dimension           = targetEx.getDimension();
+        final long[]              minIndices          = new long[dimension]; Arrays.fill(minIndices, Long.MAX_VALUE);
+        final long[]              maxIndices          = new long[dimension]; Arrays.fill(maxIndices, Long.MIN_VALUE);
+        final MathTransform[]     toSourceSliceCorner = new MathTransform  [sources.length];
+        final MathTransform[]     toSourceSliceCenter = new MathTransform  [sources.length];
+        final MathTransform1D[][] unitConverters      = new MathTransform1D[sources.length][];
+        final NumberRange<?>[][]  sourceRanges        = new NumberRange<?> [sources.length][];
+        final Unit<?>[]           destinationUnits    = SampleDimensions.units(destination);
         /*
          * Compute the intersection between `source` and `destination`, in units of destination cell indices.
-         * If a coverage does not intersect the destination, the corresponding element in the `sources` array
-         * will be set to null.
+         * If a coverage does not intersect the destination, it will be discarded.
          */
+        int numSources = 0;
 next:   for (int j=0; j<sources.length; j++) {
-            final GridCoverage source = sources[j];
+            GridCoverage source = sources[j];
             ArgumentChecks.ensureNonNullElement("sources", j, source);
+            source = source.forConvertedValues(true);
             final GridGeometry  sourceGG = source.getGridGeometry();
             final GridExtent    sourceEx = sourceGG.getExtent();
             final MathTransform toSource = targetGG.createTransformTo(sourceGG, PixelInCell.CELL_CORNER);
@@ -211,7 +263,6 @@ next:   for (int j=0; j<sources.length; j++) {
                 min[i] = Math.max(targetEx.getLow (i), round(env.getMinimum(i)));
                 max[i] = Math.min(targetEx.getHigh(i), round(env.getMaximum(i) - 1));
                 if (min[i] > max[i]) {
-                    sources[j] = null;
                     continue next;
                 }
             }
@@ -223,10 +274,15 @@ next:   for (int j=0; j<sources.length; j++) {
                 minIndices[i] = Math.min(minIndices[i], min[i]);
                 maxIndices[i] = Math.max(maxIndices[i], max[i]);
             }
-            toSourceSliceCenter[j] = targetGG.createTransformTo(sourceGG, PixelInCell.CELL_CENTER);
-            toSourceSliceCorner[j] = toSource;
+            toSourceSliceCenter[numSources] = targetGG.createTransformTo(sourceGG, PixelInCell.CELL_CENTER);
+            toSourceSliceCorner[numSources] = toSource;
+            sources            [numSources] = source;
+            unitConverters     [numSources] = createUnitConverters(SampleDimensions.units(source), destinationUnits);
+            sourceRanges       [numSources] = SampleDimensions.ranges(source);
+            numSources++;
         }
-        if (ArraysExt.allEquals(sources, null)) {
+        Arrays.fill(sources, numSources, sources.length, null);
+        if (numSources == 0) {
             return true;                                // No intersection. We "successfully" wrote nothing.
         }
         /*
@@ -251,11 +307,8 @@ next:   for (;;) {
             final RenderedImage targetSlice = destination.render(targetSliceExtent);
             if (targetSlice instanceof WritableRenderedImage) {
                 final ImageCombiner combiner = new ImageCombiner((WritableRenderedImage) targetSlice, processor);
-                for (int j=0; j<sources.length; j++) {
+                for (int j=0; j<numSources; j++) {
                     final GridCoverage source = sources[j];
-                    if (source == null) {
-                        continue;
-                    }
                     /*
                      * Compute the bounds of the source image to load (with a margin for rounding and interpolations).
                      * For all dimensions other than the slice dimensions, we take the center of the slice to read.
@@ -278,9 +331,14 @@ next:   for (;;) {
                     }
                     /*
                      * Get the source image and combine with the corresponding slice of destination coverage.
+                     * Data are converted to the destination units before the resampling is applied.
                      */
                     GridExtent sourceSliceExtent = new GridExtent(null, minSourceIndices, maxSourceIndices, true);
                     RenderedImage sourceSlice = source.render(sourceSliceExtent);
+                    MathTransform1D[] converters = unitConverters[j];
+                    if (converters != null) {
+                        sourceSlice = processor.convert(sourceSlice, sourceRanges[j], converters, combiner.getBandType());
+                    }
                     MathTransform toSource =
                             getGridGeometry(targetSlice, destination, targetSliceExtent).createTransformTo(
                             getGridGeometry(sourceSlice, source,      sourceSliceExtent), PixelInCell.CELL_CENTER);
@@ -321,6 +379,6 @@ next:   for (;;) {
      * @return the combination of destination coverage with all source coverages.
      */
     public GridCoverage result() {
-        return destination;
+        return destination.forConvertedValues(isConverted);
     }
 }
diff --git a/core/sis-feature/src/main/java/org/apache/sis/coverage/grid/GridCoverageBuilder.java b/core/sis-feature/src/main/java/org/apache/sis/coverage/grid/GridCoverageBuilder.java
index f0fd0b1218..80dccf4f38 100644
--- a/core/sis-feature/src/main/java/org/apache/sis/coverage/grid/GridCoverageBuilder.java
+++ b/core/sis-feature/src/main/java/org/apache/sis/coverage/grid/GridCoverageBuilder.java
@@ -372,6 +372,11 @@ public class GridCoverageBuilder {
             size = new Dimension(size);
             ArgumentChecks.ensureStrictlyPositive("width",  size.width);
             ArgumentChecks.ensureStrictlyPositive("height", size.height);
+            final int length = Math.multiplyExact(size.width, size.height);
+            final int capacity = data.getSize();
+            if (length > capacity) {
+                throw new IllegalArgumentException(Errors.format(Errors.Keys.UnexpectedArrayLength_2, length, capacity));
+            }
         }
         this.size = size;
         buffer = data;
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 e5098872c7..88d2370a8b 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
@@ -970,11 +970,7 @@ public class GridExtent implements GridEnvelope, LenientComparable, Serializable
      * @throws CannotEvaluateException if this grid extent does not have at least {@code numDim} dimensions.
      */
     public int[] getSubspaceDimensions(final int numDim) {
-        ArgumentChecks.ensurePositive("numDim", numDim);
-        final int m = getDimension();
-        if (numDim > m) {
-            throw new CannotEvaluateException(Resources.format(Resources.Keys.GridEnvelopeMustBeNDimensional_1, numDim));
-        }
+        final int m = ensureValidDimension(numDim);
         final int[] selected = new int[numDim];
         int count = 0;
         for (int i=0; i<m; i++) {
@@ -1004,6 +1000,82 @@ public class GridExtent implements GridEnvelope, LenientComparable, Serializable
         return selected;
     }
 
+    /**
+     * Ensures that 0 ≤ {@code numDim} ≤ <var>n</var>
+     * where <var>n</var> is the number of dimensions of this grid extent.
+     *
+     * @param  numDim  the user-supplied number of dimensions to validate.
+     * @return the number of dimensions in this grid extent.
+     * @throws CannotEvaluateException if this grid extent does not have at least {@code numDim} dimensions.
+     */
+    private int ensureValidDimension(final int numDim) {
+        ArgumentChecks.ensurePositive("numDim", numDim);
+        final int m = getDimension();
+        if (numDim > m) {
+            throw new CannotEvaluateException(Resources.format(Resources.Keys.GridEnvelopeMustBeNDimensional_1, numDim));
+        }
+        return m;
+    }
+
+    /**
+     * Returns the indices of the {@code numDim} dimensions having the largest sizes.
+     * This method can be used as an alternative to {@link #getSubspaceDimensions(int)}
+     * when it is acceptable that the omitted dimensions have sizes larger than 1 cell.
+     *
+     * @param  numDim  number of dimensions of the sub-space.
+     * @return indices of the {@code numDim} dimensions having the largest sizes, in increasing order.
+     * @throws CannotEvaluateException if this grid extent does not have at least {@code numDim} dimensions.
+     *
+     * @since 1.4
+     */
+    public int[] getLargestDimensions(final int numDim) {
+        return DimSize.sort(coordinates, ensureValidDimension(numDim), numDim);
+    }
+
+    /**
+     * A (dimension, size) tuple. Used for sorting dimensions by their size.
+     * This is used for {@link GridExtent#getLargestDimensions()} implementation.
+     */
+    private static final class DimSize extends org.apache.sis.internal.jdk17.Record implements Comparable<DimSize> {
+        /** Index of the dimension.      */ private final int  dim;
+        /** Size as an unsigned integer. */ private final long size;
+
+        /** Creates a new (dimension, size) tuple. */
+        private DimSize(final int dim, final long size) {
+            this.dim  = dim;
+            this.size = size;
+        }
+
+        /** Compares two tuples for order based on their size. */
+        @Override public int compareTo(final DimSize other) {
+            int c = Long.compareUnsigned(other.size, size);     // Reverse order.
+            if (c == 0) c = Integer.compare(dim, other.dim);
+            return c;
+        }
+
+        /** Implementation of {@link GridExtent#getLargestDimensions()}. */
+        static int[] sort(final long[] coordinates, final int m, final int numDim) {
+            if (numDim == m) {
+                return ArraysExt.range(0, numDim);      // Small optimization for a common case.
+            }
+            final var sizes = new DimSize[m];
+            for (int i=0; i<m; i++) {
+                /*
+                 * Do not use `getSize(int)` because the results may overflow.
+                 * It is okay because we will treat them as unsigned integers.
+                 */
+                sizes[i] = new DimSize(i, coordinates[m + i] - coordinates[i]);
+            }
+            Arrays.sort(sizes);
+            final int[] result = new int[numDim];
+            for (int i=0; i<numDim; i++) {
+                result[i] = sizes[i].dim;
+            }
+            Arrays.sort(result);
+            return result;
+        }
+    }
+
     /**
      * Returns the type (vertical, temporal, …) of grid axis at given dimension.
      * This information is provided because the grid axis type cannot always be inferred from the context.
diff --git a/core/sis-feature/src/main/java/org/apache/sis/image/ComputedImage.java b/core/sis-feature/src/main/java/org/apache/sis/image/ComputedImage.java
index 4a1756a737..8d076eb8d7 100644
--- a/core/sis-feature/src/main/java/org/apache/sis/image/ComputedImage.java
+++ b/core/sis-feature/src/main/java/org/apache/sis/image/ComputedImage.java
@@ -306,20 +306,24 @@ public abstract class ComputedImage extends PlanarImage implements Disposable {
      *
      * If this method is invoked, then is should be done soon after construction time
      * before any tile computation starts.
+     *
+     * @param  target  the destination image, or {@code null} if none.
      */
     final void setDestination(final WritableRenderedImage target) {
         if (destination != null) {
             throw new IllegalStateException(Errors.format(Errors.Keys.AlreadyInitialized_1, "destination"));
         }
-        if (!sampleModel.equals(target.getSampleModel())) {
-            throw new IllegalArgumentException(Resources.format(Resources.Keys.MismatchedSampleModel));
-        }
-        if (target.getTileGridXOffset() != getTileGridXOffset() ||
-            target.getTileGridYOffset() != getTileGridYOffset())
-        {
-            throw new IllegalArgumentException(Resources.format(Resources.Keys.MismatchedTileGrid));
+        if (target != null) {
+            if (!sampleModel.equals(target.getSampleModel())) {
+                throw new IllegalArgumentException(Resources.format(Resources.Keys.MismatchedSampleModel));
+            }
+            if (target.getTileGridXOffset() != getTileGridXOffset() ||
+                target.getTileGridYOffset() != getTileGridYOffset())
+            {
+                throw new IllegalArgumentException(Resources.format(Resources.Keys.MismatchedTileGrid));
+            }
+            destination = target;
         }
-        destination = target;
     }
 
     /**
diff --git a/core/sis-feature/src/main/java/org/apache/sis/image/ImageCombiner.java b/core/sis-feature/src/main/java/org/apache/sis/image/ImageCombiner.java
index c724a8ed30..47af72eca3 100644
--- a/core/sis-feature/src/main/java/org/apache/sis/image/ImageCombiner.java
+++ b/core/sis-feature/src/main/java/org/apache/sis/image/ImageCombiner.java
@@ -16,10 +16,8 @@
  */
 package org.apache.sis.image;
 
-import java.awt.Point;
 import java.awt.Rectangle;
 import java.awt.image.Raster;
-import java.awt.image.SampleModel;
 import java.awt.image.RenderedImage;
 import java.awt.image.WritableRenderedImage;
 import java.util.function.Consumer;
@@ -61,7 +59,7 @@ import org.apache.sis.measure.Units;
  * Only the intersection of both images is used.</p>
  *
  * @author  Martin Desruisseaux (Geomatys)
- * @version 1.2
+ * @version 1.4
  * @since   1.1
  */
 public class ImageCombiner implements Consumer<RenderedImage> {
@@ -75,13 +73,6 @@ public class ImageCombiner implements Consumer<RenderedImage> {
      */
     private final WritableRenderedImage destination;
 
-    /**
-     * The value to use in calls to {@link ImageProcessor#setImageLayout(ImageLayout)}.
-     * We set this property before use of {@link #processor} because the value may change
-     * for each slice processed by {@link org.apache.sis.coverage.CoverageCombiner}.
-     */
-    private final Layout layout;
-
     /**
      * Creates an image combiner which will write in the given image. That image is not cleared;
      * pixels that are not overwritten by calls to the {@code accept(…)} or {@code resample(…)}
@@ -108,37 +99,6 @@ public class ImageCombiner implements Consumer<RenderedImage> {
         ArgumentChecks.ensureNonNull("processor", processor);
         this.destination = destination;
         this.processor = processor;
-        layout = new Layout(destination.getSampleModel());
-    }
-
-    /**
-     * Provides sample model of images created by resample operations.
-     * It must be the sample model of destination image, with the same tile size.
-     */
-    private static final class Layout extends ImageLayout {
-        /** Sample model of destination image. */
-        private final SampleModel sampleModel;
-
-        /** Indices of the first tile ({@code minTileX}, {@code minTileY}). */
-        final Point minTile;
-
-        /** Creates a new layout which will request the specified sample model. */
-        Layout(final SampleModel sampleModel) {
-            super(null, false);
-            ArgumentChecks.ensureNonNull("sampleModel", sampleModel);
-            this.sampleModel = sampleModel;
-            minTile = new Point();
-        }
-
-        /** Returns the target sample model for {@link ResampledImage} or other operations. */
-        @Override public SampleModel createCompatibleSampleModel(RenderedImage image, Rectangle bounds) {
-            return sampleModel;
-        }
-
-        /** Returns indices of the first tile, which must have been set in the {@link #minTile} field in advance. */
-        @Override public Point getMinTile() {
-            return minTile;
-        }
     }
 
     /**
@@ -192,6 +152,17 @@ public class ImageCombiner implements Consumer<RenderedImage> {
         processor.setPositionalAccuracyHints(hints);
     }
 
+    /**
+     * Returns the type of number used for representing the values of each band.
+     *
+     * @return the type of number capable to hold sample values of each band.
+     *
+     * @since 1.4
+     */
+    public DataType getBandType() {
+        return DataType.forBands(destination);
+    }
+
     /**
      * Writes the given image on top of destination image. The given source image shall use the same pixel
      * coordinate system than the destination image (but not necessarily the same tile indices).
@@ -279,14 +250,19 @@ public class ImageCombiner implements Consumer<RenderedImage> {
          */
         final RenderedImage result;
         synchronized (processor) {
-            final Point minTile = layout.minTile;
-            minTile.x = minTileX;
-            minTile.y = minTileY;
-            processor.setImageLayout(layout);
-            result = processor.resample(source, bounds, toSource);
+            final ImageLayout layout = ImageLayout.forDestination(destination, minTileX, minTileY);
+            final ImageLayout previous = processor.getImageLayout();
+            try {
+                processor.setImageLayout(layout);
+                result = processor.resample(source, bounds, toSource);
+            } finally {
+                processor.setImageLayout(previous);
+            }
         }
-        if (result instanceof ComputedImage) {
-            ((ComputedImage) result).setDestination(destination);
+        /*
+         * Check if the result is writing directly in the destination image.
+         */
+        if (result instanceof ComputedImage && ((ComputedImage) result).getDestination() == destination) {
             processor.prefetch(result, ImageUtilities.getBounds(destination));
         } else {
             accept(result);
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 f3927fb049..bb33057a0f 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
@@ -1203,9 +1203,11 @@ public class ImageProcessor implements Cloneable {
                 fillValues              = this.fillValues;
                 positionalAccuracyHints = this.positionalAccuracyHints;
             }
-            resampled = unique(new ResampledImage(source,
-                    layout.createCompatibleSampleModel(source, bounds), layout.getMinTile(),
-                    bounds, toSource, interpolation, fillValues, positionalAccuracyHints));
+            final SampleModel rsm = layout.createCompatibleSampleModel(source, bounds);
+            final var image = new ResampledImage(source, rsm, layout.getMinTile(), bounds, toSource,
+                                                 interpolation, fillValues, positionalAccuracyHints);
+            image.setDestination(layout.getDestination());
+            resampled = unique(image);
             break;
         }
         /*
diff --git a/core/sis-feature/src/main/java/org/apache/sis/image/Visualization.java b/core/sis-feature/src/main/java/org/apache/sis/image/Visualization.java
index aedb0fcdfb..e6588276e3 100644
--- a/core/sis-feature/src/main/java/org/apache/sis/image/Visualization.java
+++ b/core/sis-feature/src/main/java/org/apache/sis/image/Visualization.java
@@ -275,7 +275,7 @@ final class Visualization extends ResampledImage {
              */
             final boolean shortcut = toSource.isIdentity() && (bounds == null || ImageUtilities.getBounds(source).contains(bounds));
             if (shortcut) {
-                layout = ImageLayout.fixedSize(source);
+                layout = ImageLayout.forTileSize(source);
             }
             /*
              * Sample values will be unconditionally converted to integers in the [0 … 255] range.
diff --git a/core/sis-feature/src/main/java/org/apache/sis/internal/coverage/SampleDimensions.java b/core/sis-feature/src/main/java/org/apache/sis/internal/coverage/SampleDimensions.java
index a8dcb60a16..9952fb69c8 100644
--- a/core/sis-feature/src/main/java/org/apache/sis/internal/coverage/SampleDimensions.java
+++ b/core/sis-feature/src/main/java/org/apache/sis/internal/coverage/SampleDimensions.java
@@ -21,6 +21,8 @@ import java.util.Optional;
 import java.util.function.DoubleUnaryOperator;
 import java.awt.Shape;
 import java.awt.image.RenderedImage;
+import javax.measure.Unit;
+import org.apache.sis.coverage.BandedCoverage;
 import org.apache.sis.coverage.SampleDimension;
 import org.apache.sis.coverage.Category;
 import org.apache.sis.image.ImageProcessor;
@@ -71,6 +73,40 @@ public final class SampleDimensions extends Static {
     private SampleDimensions() {
     }
 
+    /**
+     * Returns the units of measurement for all bands of the given coverage.
+     * The length of the returned array is the number of sample dimensions.
+     * The array may contain {@code null} elements.
+     *
+     * @param  source  the coverage for which to get units of measurement.
+     * @return the unit of measurement of all bands in the given coverage.
+     */
+    public static Unit<?>[] units(final BandedCoverage source) {
+        final List<SampleDimension> bands = source.getSampleDimensions();
+        final var units = new Unit<?>[bands.size()];
+        for (int i=0; i<units.length; i++) {
+            units[i] = bands.get(i).getUnits().orElse(null);
+        }
+        return units;
+    }
+
+    /**
+     * Returns the range of sample values for all bands of the given coverage.
+     * The length of the returned array is the number of sample dimensions.
+     * The array may contain {@code null} elements.
+     *
+     * @param  source  the coverage for which to get sample value ranges.
+     * @return the sample value ranges of all bands in the given coverage.
+     */
+    public static NumberRange<?>[] ranges(final BandedCoverage source) {
+        final List<SampleDimension> bands = source.getSampleDimensions();
+        final var ranges = new NumberRange<?>[bands.size()];
+        for (int i=0; i<ranges.length; i++) {
+            ranges[i] = bands.get(i).getSampleRange().orElse(null);
+        }
+        return ranges;
+    }
+
     /**
      * Returns the background values of all bands in the given list.
      * The length of the returned array is the number of sample dimensions.
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 3783305f0a..5d285a4e1b 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
@@ -25,6 +25,7 @@ import java.awt.image.IndexColorModel;
 import java.awt.image.RenderedImage;
 import java.awt.image.SampleModel;
 import java.awt.image.BandedSampleModel;
+import java.awt.image.WritableRenderedImage;
 import org.apache.sis.math.MathFunctions;
 import org.apache.sis.image.ComputedImage;
 import org.apache.sis.util.ArraysExt;
@@ -108,22 +109,34 @@ public class ImageLayout {
      * @param  source  image from which to take tile size and indices.
      * @return layout giving exactly the tile size and indices of given image.
      */
-    public static ImageLayout fixedSize(final RenderedImage source) {
-        return new FixedSize(source);
+    public static ImageLayout forTileSize(final RenderedImage source) {
+        return new FixedSize(source, source.getMinTileX(), source.getMinTileY());
+    }
+
+    /**
+     * Creates a new layout for writing in the given destination.
+     *
+     * @param  source    image from which to take tile size and indices.
+     * @param  minTileX  column index of the first tile.
+     * @param  minTileY  row index of the first tile.
+     * @return layout giving exactly the tile size and indices of given image.
+     */
+    public static ImageLayout forDestination(final WritableRenderedImage source, final int minTileX, final int minTileY) {
+        return new FixedDestination(source, minTileX, minTileY);
     }
 
     /**
      * Override preferred tile size with a fixed size.
      */
-    private static final class FixedSize extends ImageLayout {
+    private static class FixedSize extends ImageLayout {
         /** Indices of the first tile. */
-        private final int xmin, ymin;
+        private final int minTileX, minTileY;
 
         /** Creates a new layout with exactly the tile size of given image. */
-        FixedSize(final RenderedImage source) {
+        FixedSize(final RenderedImage source, final int minTileX, final int minTileY) {
             super(new Dimension(source.getTileWidth(), source.getTileHeight()), false);
-            xmin = source.getMinTileX();
-            ymin = source.getMinTileY();
+            this.minTileX = minTileX;
+            this.minTileY = minTileY;
         }
 
         /** Returns the fixed tile size. All parameters are ignored. */
@@ -138,7 +151,31 @@ public class ImageLayout {
 
         /** Returns indices of the first tile. */
         @Override public Point getMinTile() {
-            return new Point(xmin, ymin);
+            return new Point(minTileX, minTileY);
+        }
+    }
+
+    /**
+     * Override sample model with the one of the destination.
+     */
+    private static final class FixedDestination extends FixedSize {
+        /** The destination image. */
+        private final WritableRenderedImage destination;
+
+        /** Creates a new layout with exactly the tile size of given image. */
+        FixedDestination(final WritableRenderedImage destination, final int minTileX, final int minTileY) {
+            super(destination, minTileX, minTileY);
+            this.destination = destination;
+        }
+
+        /** Returns an existing image where to write the computation result. */
+        @Override public WritableRenderedImage getDestination() {
+            return destination;
+        }
+
+        /** Returns the target sample model, which is fixed to the same than the destination image. */
+        @Override public SampleModel createCompatibleSampleModel(RenderedImage image, Rectangle bounds) {
+            return destination.getSampleModel();
         }
     }
 
@@ -380,6 +417,15 @@ public class ImageLayout {
         return null;
     }
 
+    /**
+     * Returns an existing image where to write the computation result, or {@code null} if none.
+     *
+     * @return preexisting destination of computation result, or {@code null} if none.
+     */
+    public WritableRenderedImage getDestination() {
+        return null;
+    }
+
     /**
      * Returns a string representation for debugging purpose.
      *
diff --git a/core/sis-feature/src/test/java/org/apache/sis/coverage/CoverageCombinerTest.java b/core/sis-feature/src/test/java/org/apache/sis/coverage/CoverageCombinerTest.java
new file mode 100644
index 0000000000..1efaf699d2
--- /dev/null
+++ b/core/sis-feature/src/test/java/org/apache/sis/coverage/CoverageCombinerTest.java
@@ -0,0 +1,70 @@
+/*
+ * 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;
+
+import java.awt.Dimension;
+import java.awt.image.DataBufferFloat;
+import javax.measure.IncommensurableException;
+import org.opengis.referencing.operation.TransformException;
+import org.apache.sis.coverage.grid.GridCoverage;
+import org.apache.sis.coverage.grid.GridCoverageBuilder;
+import org.apache.sis.geometry.Envelope2D;
+import org.apache.sis.measure.Units;
+import org.apache.sis.test.TestCase;
+import org.junit.Test;
+
+import static org.junit.Assert.*;
+
+
+/**
+ * Tests {@link CoverageCombiner}.
+ *
+ * @author  Martin Desruisseaux (Geomatys)
+ * @version 1.4
+ * @since   1.4
+ */
+public final class CoverageCombinerTest extends TestCase {
+    /**
+     * Tests a coverage combination involving unit conversion.
+     *
+     * @throws TransformException if the coordinates of a given coverage cannot be transformed.
+     * @throws IncommensurableException if the unit of measurement is not convertible.
+     */
+    @Test
+    public void testUnitConversion() throws TransformException, IncommensurableException {
+        final var s = new Dimension(2,2);
+        GridCoverage c1 = new GridCoverageBuilder()
+                .setDomain(new Envelope2D(null, 2, 2, s.width, s.height))
+                .setRanges(new SampleDimension.Builder().addQuantitative("C1", 0, 10, Units.METRE).build())
+                .setValues(new DataBufferFloat(new float[] {4, 8, 2, 3}, s.width * s.height), s)
+                .build();
+
+        GridCoverage c2 = new GridCoverageBuilder()
+                .setDomain(new Envelope2D(null, 3, 2, s.width, s.height))
+                .setRanges(new SampleDimension.Builder().addQuantitative("C1", 0, 10, Units.CENTIMETRE).build())
+                .setValues(new DataBufferFloat(new float[] {500, 600, 900, 700}, s.width * s.height), s)
+                .build();
+
+        final var combiner = new CoverageCombiner(c1);
+        combiner.acceptAll(c2);
+        GridCoverage r = combiner.result();
+
+        float[] data = null;
+        data = r.render(null).getData().getSamples(0, 0, s.width, s.height, 0, data);
+        assertArrayEquals(new float[] {4, 5, 2, 9}, data, 0);
+    }
+}
diff --git a/core/sis-feature/src/test/java/org/apache/sis/coverage/grid/GridExtentTest.java b/core/sis-feature/src/test/java/org/apache/sis/coverage/grid/GridExtentTest.java
index b1ceb4dc7d..e0ed90d131 100644
--- a/core/sis-feature/src/test/java/org/apache/sis/coverage/grid/GridExtentTest.java
+++ b/core/sis-feature/src/test/java/org/apache/sis/coverage/grid/GridExtentTest.java
@@ -48,7 +48,7 @@ import static org.apache.sis.test.ReferencingAssert.*;
  * @author  Martin Desruisseaux (IRD, Geomatys)
  * @author  Alexis Manin (Geomatys)
  * @author  Johann Sorel (Geomatys)
- * @version 1.3
+ * @version 1.4
  * @since   1.0
  */
 public final class GridExtentTest extends TestCase {
@@ -350,16 +350,16 @@ public final class GridExtentTest extends TestCase {
     }
 
     /**
-     * Tests {@link GridExtent#getSubspaceDimensions(int)}.
+     * Tests {@link GridExtent#getSubspaceDimensions(int)} and {@link GridExtent#getLargestDimensions(int)}.
      * Opportunistically tests {@link GridExtent#getSliceCoordinates()} since the two methods closely related.
      */
     @Test
     public void testGetSubspaceDimensions() {
         final GridExtent extent = new GridExtent(null, new long[] {100, 5, 200, 40}, new long[] {500, 5, 800, 40}, true);
         assertMapEquals(Map.of(1, 5L, 3, 40L), extent.getSliceCoordinates());
-        assertArrayEquals(new int[] {0,  2  }, extent.getSubspaceDimensions(2));
-        assertArrayEquals(new int[] {0,1,2  }, extent.getSubspaceDimensions(3));
-        assertArrayEquals(new int[] {0,1,2,3}, extent.getSubspaceDimensions(4));
+        assertSubspaceEquals(extent, 0,  2  );
+        assertSubspaceEquals(extent, 0,1,2  );
+        assertSubspaceEquals(extent, 0,1,2,3);
         try {
             extent.getSubspaceDimensions(1);
             fail("Should not reduce to 1 dimension.");
@@ -368,6 +368,18 @@ public final class GridExtentTest extends TestCase {
         }
     }
 
+    /**
+     * Verifies the result of {@code getSubspaceDimensions(…)} and {@code getLargestDimensions(…)}.
+     * In this test, the two methods should produce the same results.
+     *
+     * @param extent    the grid extent to test.
+     * @param expected  the expected result.
+     */
+    private static void assertSubspaceEquals(final GridExtent extent, final int... expected) {
+        assertArrayEquals(expected, extent.getSubspaceDimensions(expected.length));
+        assertArrayEquals(expected, extent.getLargestDimensions (expected.length));
+    }
+
     /**
      * Tests {@link GridExtent#cornerToCRS(Envelope, long, int[])}.
      */
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 4f233d0744..d0984716c4 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
@@ -110,6 +110,7 @@ import org.junit.runners.Suite;
     org.apache.sis.coverage.CategoryListTest.class,
     org.apache.sis.coverage.SampleDimensionTest.class,
     org.apache.sis.coverage.SampleRangeFormatTest.class,
+    org.apache.sis.coverage.CoverageCombinerTest.class,
     org.apache.sis.coverage.grid.PixelTranslationTest.class,
     org.apache.sis.coverage.grid.GridOrientationTest.class,
     org.apache.sis.coverage.grid.GridExtentTest.class,
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 74c4b9c99c..9b7cbae31f 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
@@ -21,6 +21,7 @@ import java.util.List;
 import java.util.BitSet;
 import java.util.Optional;
 import java.awt.geom.AffineTransform;
+import javax.measure.UnitConverter;
 import org.opengis.util.FactoryException;
 import org.opengis.geometry.Envelope;
 import org.opengis.geometry.DirectPosition;
@@ -251,6 +252,21 @@ public final class MathTransforms extends Static {
         }
     }
 
+    /**
+     * Converts the given unit converter to a math transform.
+     * This is a bridge between Unit API and referencing API.
+     *
+     * @param  converter  the unit converter.
+     * @return a transform doing the same computation than the given unit converter.
+     *
+     * @since 1.4
+     */
+    @SuppressWarnings("fallthrough")
+    public static MathTransform1D convert(final UnitConverter converter) {
+        ArgumentChecks.ensureNonNull("converter", converter);
+        return UnitConversion.create(converter);
+    }
+
     /**
      * Creates a transform for the <i>y=f(x)</i> function where <var>y</var> are computed by a linear interpolation.
      * Both {@code preimage} (the <var>x</var>) and {@code values} (the <var>y</var>) arguments can be null:
diff --git a/core/sis-referencing/src/main/java/org/apache/sis/referencing/operation/transform/UnitConversion.java b/core/sis-referencing/src/main/java/org/apache/sis/referencing/operation/transform/UnitConversion.java
new file mode 100644
index 0000000000..9a39fb2508
--- /dev/null
+++ b/core/sis-referencing/src/main/java/org/apache/sis/referencing/operation/transform/UnitConversion.java
@@ -0,0 +1,145 @@
+/*
+ * 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 javax.measure.UnitConverter;
+import org.opengis.referencing.operation.MathTransform;
+import org.opengis.referencing.operation.MathTransform1D;
+import org.opengis.referencing.operation.MathTransformFactory;
+import org.opengis.referencing.operation.TransformException;
+import org.opengis.util.FactoryException;
+import org.apache.sis.internal.referencing.Resources;
+import org.apache.sis.measure.Units;
+
+
+/**
+ * Bridge between Unit API and referencing API.
+ * This is used only when the converter is non-linear or is not a recognized implementation.
+ *
+ * @author  Martin Desruisseaux (Geomatys)
+ * @version 1.4
+ * @since   1.4
+ */
+final class UnitConversion extends AbstractMathTransform1D implements Serializable {
+    /**
+     * For cross-version compatibility.
+     */
+    private static final long serialVersionUID = -7344042406568682405L;
+
+    /**
+     * The unit converter to wrap.
+     */
+    @SuppressWarnings("serial")                 // Apache SIS implementation is serializable.
+    private final UnitConverter converter;
+
+    /**
+     * The inverse conversion, computed when first needed.
+     */
+    private UnitConversion inverse;
+
+    /**
+     * Creates a new wrapper.
+     *
+     * @param converter the unit converter to wrap.
+     */
+    private UnitConversion(final UnitConverter converter) {
+        this.converter = converter;
+    }
+
+    /**
+     * Converts the given unit converter to a math transform.
+     */
+    @SuppressWarnings("fallthrough")
+    static MathTransform1D create(final UnitConverter converter) {
+        Number[] coefficients = Units.coefficients(converter);
+        if (coefficients != null) {
+            Number scale = 1, offset = 0;
+            switch (coefficients.length) {
+                case 2: scale  = coefficients[1];     // Fall through
+                case 1: offset = coefficients[0];     // Fall through
+                case 0: return LinearTransform1D.create(scale, offset);
+            }
+        }
+        return new UnitConversion(converter);
+    }
+
+    /**
+     * Tests whether this transform changes any value.
+     */
+    @Override
+    public boolean isIdentity() {
+        return converter.isIdentity();
+    }
+
+    /**
+     * Converts the given value.
+     *
+     * @param  value  the value to convert.
+     * @return the converted value.
+     */
+    @Override
+    public double transform(double value) {
+        return converter.convert(value);
+    }
+
+    /**
+     * Computes the derivative at the given value.
+     *
+     * @param  value  the value for which to compute derivative.
+     * @return the derivative for the given value.
+     * @throws TransformException if the derivative cannot be computed.
+     */
+    @Override
+    public double derivative(double value) throws TransformException {
+        final double derivative = Units.derivative(converter, value);
+        if (Double.isNaN(derivative) && !Double.isNaN(value)) {
+            throw new TransformException(Resources.format(Resources.Keys.CanNotComputeDerivative));
+        }
+        return derivative;
+    }
+
+    /**
+     * Returns the inverse transform of this object.
+     */
+    @Override
+    public synchronized MathTransform1D inverse() {
+        if (inverse == null) {
+            inverse = new UnitConversion(converter.inverse());
+            inverse.inverse = this;
+        }
+        return inverse;
+    }
+
+    /**
+     * Concatenates or pre-concatenates in an optimized way this math transform with the given one, if possible.
+     *
+     * @return the math transforms combined in an optimized way, or {@code null} if no such optimization is available.
+     */
+    @Override
+    protected MathTransform tryConcatenate(boolean applyOtherFirst, MathTransform other, MathTransformFactory factory)
+            throws FactoryException
+    {
+        if (other instanceof UnitConversion) {
+            final var that = (UnitConversion) other;
+            return create(applyOtherFirst
+                    ? that.converter.concatenate(this.converter)
+                    : this.converter.concatenate(that.converter));
+        }
+        return super.tryConcatenate(applyOtherFirst, other, factory);
+    }
+}
diff --git a/core/sis-referencing/src/test/java/org/apache/sis/referencing/operation/transform/UnitConversionTest.java b/core/sis-referencing/src/test/java/org/apache/sis/referencing/operation/transform/UnitConversionTest.java
new file mode 100644
index 0000000000..f19fdcc6fd
--- /dev/null
+++ b/core/sis-referencing/src/test/java/org/apache/sis/referencing/operation/transform/UnitConversionTest.java
@@ -0,0 +1,59 @@
+/*
+ * 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 org.opengis.referencing.operation.MathTransform;
+import org.opengis.referencing.operation.TransformException;
+import org.apache.sis.measure.Units;
+import org.apache.sis.test.TestCase;
+import org.junit.Test;
+
+import static org.junit.Assert.*;
+
+
+/**
+ * Tests {@link UnitConversion}.
+ *
+ * @author  Martin Desruisseaux (Geomatys)
+ * @version 1.4
+ * @since   1.4
+ */
+public final class UnitConversionTest extends TestCase {
+    /**
+     * Tests a linear conversion.
+     */
+    @Test
+    public void testLinear() {
+        final MathTransform tr = MathTransforms.convert(Units.KILOMETRE.getConverterTo(Units.METRE));
+        final var linear = (LinearTransform1D) tr;
+        assertEquals(1000, linear.scale,  STRICT);
+        assertEquals(   0, linear.offset, STRICT);
+    }
+
+    /**
+     * Tests a non-linear conversion.
+     *
+     * @throws TransformException if a test value cannot be transformed.
+     */
+    @Test
+    public void testLogarithmic() throws TransformException {
+        final MathTransform tr = MathTransforms.convert(Units.UNITY.getConverterTo(Units.DECIBEL));
+        final var wrapper = (UnitConversion) tr;
+        assertEquals(20, wrapper.transform(10), STRICT);
+        assertEquals(10, wrapper.inverse().transform(20), STRICT);
+    }
+}
diff --git a/core/sis-referencing/src/test/java/org/apache/sis/test/suite/ReferencingTestSuite.java b/core/sis-referencing/src/test/java/org/apache/sis/test/suite/ReferencingTestSuite.java
index cc9fa9ab8f..9102570e02 100644
--- a/core/sis-referencing/src/test/java/org/apache/sis/test/suite/ReferencingTestSuite.java
+++ b/core/sis-referencing/src/test/java/org/apache/sis/test/suite/ReferencingTestSuite.java
@@ -133,6 +133,7 @@ import org.junit.BeforeClass;
     org.apache.sis.referencing.operation.transform.ExponentialTransform1DTest.class,
     org.apache.sis.referencing.operation.transform.LogarithmicTransform1DTest.class,
     org.apache.sis.referencing.operation.transform.CopyTransformTest.class,
+    org.apache.sis.referencing.operation.transform.UnitConversionTest.class,
     org.apache.sis.referencing.operation.transform.PassThroughTransformTest.class,
     org.apache.sis.referencing.operation.transform.ConcatenatedTransformTest.class,
     org.apache.sis.referencing.operation.transform.TransformSeparatorTest.class,
diff --git a/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/WritableResourceSupport.java b/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/WritableResourceSupport.java
index c65932fdc8..bf8dfedfbf 100644
--- a/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/WritableResourceSupport.java
+++ b/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/WritableResourceSupport.java
@@ -20,6 +20,7 @@ import java.util.Locale;
 import java.io.IOException;
 import java.nio.channels.WritableByteChannel;
 import java.awt.geom.AffineTransform;
+import javax.measure.IncommensurableException;
 import org.opengis.util.FactoryException;
 import org.opengis.referencing.operation.MathTransform;
 import org.opengis.referencing.operation.TransformException;
@@ -50,7 +51,7 @@ import org.opengis.coverage.CannotEvaluateException;
  * Helper classes for the management of {@link WritableGridCoverageResource.CommonOption}.
  *
  * @author  Martin Desruisseaux (Geomatys)
- * @version 1.2
+ * @version 1.4
  * @since   1.2
  */
 public final class WritableResourceSupport implements Localized {
@@ -177,12 +178,12 @@ public final class WritableResourceSupport implements Localized {
      */
     public final GridCoverage update(final GridCoverage coverage) throws DataStoreException {
         final GridCoverage existing = resource.read(null, null);
-        final CoverageCombiner combiner = new CoverageCombiner(existing, 0, 1);
+        final CoverageCombiner combiner = new CoverageCombiner(existing);
         try {
-            if (!combiner.apply(coverage)) {
+            if (!combiner.acceptAll(coverage)) {
                 throw new ReadOnlyStorageException(canNotWrite());
             }
-        } catch (TransformException e) {
+        } catch (TransformException | IncommensurableException e) {
             throw new DataStoreReferencingException(canNotWrite(), e);
         }
         return existing;