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 a95fbd4ab4177bcf1e5d8d46c1ce4c688758ab2f
Author: Martin Desruisseaux <martin.desruisse...@geomatys.com>
AuthorDate: Thu Dec 19 13:18:43 2024 +0100

    Allow GeoTIFF writer to encode "grid to CRS" where the vertical component 
is non-linear.
---
 .../org/apache/sis/storage/geotiff/Writer.java     |  10 +-
 .../sis/storage/geotiff/writer/GeoEncoder.java     | 144 +++++++++++++--------
 .../sis/storage/geotiff/GeoTiffStoreTest.java      | 111 ++++++++++++++++
 3 files changed, 211 insertions(+), 54 deletions(-)

diff --git 
a/endorsed/src/org.apache.sis.storage.geotiff/main/org/apache/sis/storage/geotiff/Writer.java
 
b/endorsed/src/org.apache.sis.storage.geotiff/main/org/apache/sis/storage/geotiff/Writer.java
index 1ce0d8140c..6801c886f5 100644
--- 
a/endorsed/src/org.apache.sis.storage.geotiff/main/org/apache/sis/storage/geotiff/Writer.java
+++ 
b/endorsed/src/org.apache.sis.storage.geotiff/main/org/apache/sis/storage/geotiff/Writer.java
@@ -39,11 +39,14 @@ import javax.measure.IncommensurableException;
 import org.opengis.util.FactoryException;
 import org.opengis.metadata.Metadata;
 import org.opengis.metadata.citation.CitationDate;
+import org.opengis.referencing.operation.TransformException;
 import org.apache.sis.image.ImageProcessor;
 import org.apache.sis.coverage.grid.GridGeometry;
+import org.apache.sis.coverage.grid.IncompleteGridGeometryException;
 import org.apache.sis.coverage.privy.ImageUtilities;
 import org.apache.sis.storage.DataStoreException;
 import org.apache.sis.storage.DataStoreReferencingException;
+import org.apache.sis.storage.IncompatibleResourceException;
 import org.apache.sis.storage.ReadOnlyStorageException;
 import org.apache.sis.storage.base.MetadataFetcher;
 import org.apache.sis.storage.geotiff.writer.TagValue;
@@ -58,6 +61,9 @@ import org.apache.sis.util.privy.Numerics;
 import org.apache.sis.util.resources.Errors;
 import org.apache.sis.math.Fraction;
 
+// Specific to the geoapi-3.1 and geoapi-4.0 branches:
+import org.opengis.coverage.CannotEvaluateException;
+
 
 /**
  * An image writer for GeoTIFF files. This writer duplicates the 
implementations performed by other libraries,
@@ -403,9 +409,11 @@ final class Writer extends IOBase implements Flushable {
         };
         mf.accept(metadata);
         GeoEncoder geoKeys = null;
-        if (grid != null && grid.isDefined(GridGeometry.GRID_TO_CRS)) try {
+        if (grid != null) try {
             geoKeys = new GeoEncoder(store.listeners());
             geoKeys.write(grid, mf);
+        } catch (IncompleteGridGeometryException | CannotEvaluateException | 
TransformException e) {
+            throw new IncompatibleResourceException(e.getMessage(), 
e).addAspect("gridGeometry");
         } catch (FactoryException | IncommensurableException | 
RuntimeException e) {
             throw new DataStoreReferencingException(e.getMessage(), e);
         }
diff --git 
a/endorsed/src/org.apache.sis.storage.geotiff/main/org/apache/sis/storage/geotiff/writer/GeoEncoder.java
 
b/endorsed/src/org.apache.sis.storage.geotiff/main/org/apache/sis/storage/geotiff/writer/GeoEncoder.java
index 25536bca7b..1ce9787fc4 100644
--- 
a/endorsed/src/org.apache.sis.storage.geotiff/main/org/apache/sis/storage/geotiff/writer/GeoEncoder.java
+++ 
b/endorsed/src/org.apache.sis.storage.geotiff/main/org/apache/sis/storage/geotiff/writer/GeoEncoder.java
@@ -16,6 +16,7 @@
  */
 package org.apache.sis.storage.geotiff.writer;
 
+import java.util.Arrays;
 import java.util.List;
 import java.util.EnumMap;
 import java.util.logging.Level;
@@ -45,6 +46,7 @@ import org.opengis.referencing.datum.GeodeticDatum;
 import org.opengis.referencing.datum.VerticalDatum;
 import org.opengis.referencing.operation.Conversion;
 import org.opengis.referencing.operation.Matrix;
+import org.opengis.referencing.operation.TransformException;
 import org.opengis.parameter.GeneralParameterValue;
 import org.opengis.parameter.ParameterValue;
 import org.apache.sis.measure.Units;
@@ -59,6 +61,7 @@ import org.apache.sis.referencing.cs.CoordinateSystems;
 import org.apache.sis.referencing.operation.matrix.Matrices;
 import org.apache.sis.referencing.operation.transform.MathTransforms;
 import org.apache.sis.referencing.factory.UnavailableFactoryException;
+import org.apache.sis.referencing.privy.AxisDirections;
 import org.apache.sis.referencing.privy.ReferencingUtilities;
 import org.apache.sis.referencing.privy.WKTKeywords;
 import org.apache.sis.coverage.grid.GridGeometry;
@@ -84,6 +87,12 @@ import org.apache.sis.pending.jdk.JDK15;
  * @author  Martin Desruisseaux (Geomatys)
  */
 public final class GeoEncoder {
+    /**
+     * Number of dimensions in a rendered image.
+     * Used for identifying codes where a two-dimensional slice is assumed.
+     */
+    private static final int BIDIMENSIONAL = 2;
+
     /**
      * Size of the model transformation matrix, in number of rows and columns.
      * This size is fixed by the GeoTIFF specification.
@@ -102,16 +111,11 @@ public final class GeoEncoder {
     private String citation;
 
     /**
-     * The coordinate reference system of the grid geometry, or {@code null} 
if none.
-     * This CRS may contain more dimensions than the 3 dimensions allowed by 
GeoTIFF.
+     * The axis directions of the grid geometry, or {@code null} if none.
+     * The array length is 2 or 3, with the vertical axis always last.
      * Axis order and axis directions may be different than the (east, north, 
up) directions mandated by GeoTIFF.
      */
-    private CoordinateReferenceSystem fullCRS;
-
-    /**
-     * Whether the coordinate system has a vertical component.
-     */
-    private boolean hasVerticalAxis;
+    private AxisDirection[] axisDirections;
 
     /**
      * The conversion from grid coordinates to full CRS, which determines the 
model transformation.
@@ -228,31 +232,72 @@ public final class GeoEncoder {
      * @throws IncompatibleResourceException if the grid geometry cannot be 
encoded.
      */
     public void write(final GridGeometry grid, final MetadataFetcher<?> 
metadata)
-            throws FactoryException, IncommensurableException, 
IncompatibleResourceException
+            throws FactoryException, TransformException, 
IncommensurableException, IncompatibleResourceException
     {
-        citation  = CollectionsExt.first(metadata.transformationDimension);
-        isPoint   = CollectionsExt.first(metadata.cellGeometry) == 
CellGeometry.POINT;
-        gridToCRS = MathTransforms.getMatrix(grid.getGridToCRS(isPoint ? 
PixelInCell.CELL_CENTER : PixelInCell.CELL_CORNER));
-        if (gridToCRS == null) {
-            String message = 
resources().getString(Resources.Keys.CanNotEncodeNonLinearModel);
-            throw new 
IncompatibleResourceException(message).addAspect("gridToCRS");
-        }
-        if (grid.isDefined(GridGeometry.CRS)) {
-            fullCRS = grid.getCoordinateReferenceSystem();
-            final CoordinateReferenceSystem crs = 
CRS.getHorizontalComponent(fullCRS);
-            if (crs instanceof ProjectedCRS) {
-                writeCRS((ProjectedCRS) crs);
-                writeCRS(CRS.getVerticalComponent(fullCRS, true));
-            } else if (crs instanceof GeodeticCRS) {
-                writeCRS((GeodeticCRS) crs, false);
-                writeCRS(CRS.getVerticalComponent(fullCRS, true));
-            } else if (fullCRS instanceof EngineeringCRS && 
ReferencingUtilities.getDimension(fullCRS) == 2) {
-                writeModelType(GeoCodes.userDefined);
-            } else {
-                throw unsupportedType(fullCRS);
+        citation = CollectionsExt.first(metadata.transformationDimension);
+        isPoint  = CollectionsExt.first(metadata.cellGeometry) == 
CellGeometry.POINT;
+        final var anchor = isPoint ? PixelInCell.CELL_CENTER : 
PixelInCell.CELL_CORNER;
+        /*
+         * Get the dimension indices of the two-dimensional slice to write. 
They should be the first dimensions,
+         * but we allow those dimensions to appear elsewhere.
+         */
+        final int[] dimensions = 
grid.getExtent().getSubspaceDimensions(BIDIMENSIONAL);
+        final GridGeometry horizontal = grid.selectDimensions(dimensions);
+        if (grid.isDefined(GridGeometry.GRID_TO_CRS)) {
+            gridToCRS = 
MathTransforms.getMatrix(horizontal.getGridToCRS(anchor));
+            if (gridToCRS == null) {
+                String message = 
resources().getString(Resources.Keys.CanNotEncodeNonLinearModel);
+                throw new 
IncompatibleResourceException(message).addAspect("gridToCRS");
             }
-        } else {
+        }
+        /*
+         * Write the horizontal component of the CRS. We need to take the CRS
+         * at the same dimensions than the ones selected for the `gridToCRS`.
+         */
+        if (!grid.isDefined(GridGeometry.CRS)) {
             writeModelType(GeoCodes.undefined);
+            return;
+        }
+        final CoordinateReferenceSystem crs = 
horizontal.getCoordinateReferenceSystem();
+        axisDirections = 
CoordinateSystems.getAxisDirections(crs.getCoordinateSystem());
+        if (crs instanceof ProjectedCRS) {
+            writeCRS((ProjectedCRS) crs);
+        } else if (crs instanceof GeodeticCRS) {
+            writeCRS((GeodeticCRS) crs, false);
+        } else if (crs instanceof EngineeringCRS) {
+            writeModelType(GeoCodes.userDefined);
+            return;
+        } else {
+            throw unsupportedType(crs);
+        }
+        /*
+         * Write the vertical component of the CRS, if any. We restrict the 
type to `VerticalCRS`,
+         * because this is the semantic of the GeoKeys.
+         */
+        final CoordinateReferenceSystem fullCRS = 
grid.getCoordinateReferenceSystem();
+        final VerticalCRS vertical = CRS.getVerticalComponent(fullCRS, true);
+        if (vertical != null) {
+            final CoordinateSystem cs = vertical.getCoordinateSystem();
+            final int vi = 
AxisDirections.indexOfColinear(fullCRS.getCoordinateSystem(), cs);
+            if (vi >= 0 && Arrays.binarySearch(dimensions, vi) < 0) {
+                writeCRS(vertical);
+                axisDirections = Arrays.copyOf(axisDirections, 
BIDIMENSIONAL+1);
+                axisDirections[BIDIMENSIONAL] = cs.getAxis(0).getDirection();
+                if (gridToCRS != null) {
+                    gridToCRS = Matrices.resizeAffine(gridToCRS, MATRIX_SIZE, 
MATRIX_SIZE);
+                    Matrix more = grid.getLinearGridToCRS(anchor).getMatrix();
+                    for (int i=0; i<MATRIX_SIZE; i++) {
+                        final int s;
+                        switch (i) {
+                            default:            s = dimensions[i];        
break;    // Shear from horizontal dimensions.
+                            case BIDIMENSIONAL: s = vi;                   
break;    // Scale from vertical dimension.
+                            case MATRIX_SIZE-1: s = more.getNumCol() - 1; 
break;    // Translation.
+                        }
+                        // Copy the rows of the third dimension.
+                        gridToCRS.setElement(BIDIMENSIONAL, i, 
more.getElement(BIDIMENSIONAL, s));
+                    }
+                }
+            }
         }
     }
 
@@ -280,22 +325,19 @@ public final class GeoEncoder {
      * @throws IncompatibleResourceException if a unit of measurement cannot 
be encoded.
      */
     private void writeCRS(final VerticalCRS crs) throws FactoryException, 
IncompatibleResourceException {
-        if (crs != null) {
-            hasVerticalAxis = true;
-            if (writeEPSG(GeoKeys.Vertical, crs)) {
-                writeName(GeoKeys.VerticalCitation, null, crs);
-                addUnits(UnitKey.VERTICAL, crs.getCoordinateSystem());
-                final VerticalDatum datum = PseudoDatum.of(crs);
-                if (writeEPSG(GeoKeys.VerticalDatum, datum)) {
-                    /*
-                     * OGC requirement 25.5 said "VerticalCitationGeoKey SHALL 
be populated."
-                     * But how? Using the same multiple-names convention as 
for geodetic CRS?
-                     *
-                     * https://github.com/opengeospatial/geotiff/issues/59
-                     */
-                }
-                writeUnit(UnitKey.VERTICAL);
+        if (writeEPSG(GeoKeys.Vertical, crs)) {
+            writeName(GeoKeys.VerticalCitation, null, crs);
+            addUnits(UnitKey.VERTICAL, crs.getCoordinateSystem());
+            final VerticalDatum datum = PseudoDatum.of(crs);
+            if (writeEPSG(GeoKeys.VerticalDatum, datum)) {
+                /*
+                 * OGC requirement 25.5 said "VerticalCitationGeoKey SHALL be 
populated."
+                 * But how? Using the same multiple-names convention as for 
geodetic CRS?
+                 *
+                 * https://github.com/opengeospatial/geotiff/issues/59
+                 */
             }
+            writeUnit(UnitKey.VERTICAL);
         }
     }
 
@@ -716,16 +758,12 @@ public final class GeoEncoder {
          * If the CRS of the grid geometry has different axis order, we need 
to adjust the
          * "grid to CRS" transform.
          */
-        if (fullCRS != null) {
-            final AxisDirection[] source = 
CoordinateSystems.getAxisDirections(fullCRS.getCoordinateSystem());
-            final AxisDirection[] target = new AxisDirection[hasVerticalAxis ? 
3 : 2];
+        if (axisDirections != null) {
+            final var target = axisDirections.clone();  // Preserve vertical 
axis direction.
             target[0] = AxisDirection.EAST;
             target[1] = AxisDirection.NORTH;
-            if (hasVerticalAxis) {
-                target[2] = AxisDirection.UP;
-            }
-            gridToCRS = Matrices.createTransform(source, 
target).multiply(gridToCRS);
-            fullCRS   = null;       // For avoiding to do the multiplication 
again.
+            gridToCRS = Matrices.createTransform(axisDirections, 
target).multiply(gridToCRS);
+            axisDirections = null;      // For avoiding to do the 
multiplication again.
         }
         /*
          * Copy matrix coefficients. This matrix size is always 4×4, no matter 
the size of the `gridToCRS` matrix.
diff --git 
a/endorsed/src/org.apache.sis.storage.geotiff/test/org/apache/sis/storage/geotiff/GeoTiffStoreTest.java
 
b/endorsed/src/org.apache.sis.storage.geotiff/test/org/apache/sis/storage/geotiff/GeoTiffStoreTest.java
new file mode 100644
index 0000000000..feb90f7f13
--- /dev/null
+++ 
b/endorsed/src/org.apache.sis.storage.geotiff/test/org/apache/sis/storage/geotiff/GeoTiffStoreTest.java
@@ -0,0 +1,111 @@
+/*
+ * 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.storage.geotiff;
+
+import java.nio.file.Path;
+import java.nio.file.Files;
+import java.awt.image.BufferedImage;
+import java.awt.image.RenderedImage;
+import org.opengis.referencing.cs.AxisDirection;
+import org.apache.sis.storage.DataStore;
+import org.apache.sis.storage.DataStores;
+import org.apache.sis.storage.StorageConnector;
+import org.apache.sis.storage.GridCoverageResource;
+import org.apache.sis.coverage.grid.GridExtent;
+import org.apache.sis.coverage.grid.GridGeometry;
+import org.apache.sis.coverage.grid.PixelInCell;
+import org.apache.sis.coverage.grid.GridCoverage;
+import org.apache.sis.coverage.grid.GridCoverageBuilder;
+import org.apache.sis.referencing.operation.transform.MathTransforms;
+import org.apache.sis.referencing.operation.matrix.Matrix4;
+import org.apache.sis.referencing.CRS;
+
+// Test dependencies
+import org.junit.jupiter.api.Test;
+import static org.junit.jupiter.api.Assertions.*;
+import org.apache.sis.test.TestCase;
+import org.apache.sis.test.TestUtilities;
+import org.apache.sis.referencing.crs.HardCodedCRS;
+
+// Specific to the geoapi-3.1 and geoapi-4.0 branches:
+import static org.opengis.test.Assertions.assertMatrixEquals;
+import static org.opengis.test.Assertions.assertAxisDirectionsEqual;
+
+
+/**
+ * integration tests for {@link GeoTiffStore}.
+ *
+ * @author  Martin Desruisseaux (Geomatys)
+ */
+public final class GeoTiffStoreTest extends TestCase {
+    /**
+     * Creates a new test case.
+     */
+    public GeoTiffStoreTest() {
+    }
+
+    /**
+     * Tests writing an image with a non-linear vertical component in the 
"grid to CRS" transform.
+     * This method merely tests that no exception is thrown during the 
execution, and that reading
+     * the image back can give back the three-dimensional <abbr>CRS</abbr>.
+     *
+     * @throws Exception if an error occurred while preparing or running the 
test.
+     */
+    @Test
+    public void testNonLinearVerticalTransform() throws Exception {
+        final int width     = 5;
+        final int height    = 4;
+        final var builder   = new GridCoverageBuilder();
+        final var extent    = new GridExtent(null, null, new long[] {width, 
height, 1}, false);
+        final var crs       = CRS.compound(HardCodedCRS.WGS84_LATITUDE_FIRST, 
HardCodedCRS.DEPTH);
+        final var gridToCRS = MathTransforms.compound(
+                MathTransforms.scale(0.25, -0.25),
+                MathTransforms.interpolate(new double[] {0, 1, 2}, new 
double[] {3, 4, 9}));
+        builder.setDomain(new GridGeometry(extent, PixelInCell.CELL_CORNER, 
gridToCRS, crs));
+        builder.setValues(new BufferedImage(width, height, 
BufferedImage.TYPE_INT_ARGB));
+        final GridCoverage coverage = builder.build();
+        final Path file = Files.createTempFile("sis-test-", ".tiff");
+        try {
+            try (DataStore store = DataStores.openWritable(new 
StorageConnector(file), "GeoTIFF")) {
+                assertInstanceOf(GeoTiffStore.class, store).append(coverage, 
null);
+            }
+            /*
+             * Read the image that we wrote in above block. This block merely 
tests that no exception is thrown,
+             * and that the result has the expected number of dimensions, axis 
order and scale factors.
+             */
+            try (DataStore store = DataStores.open(new StorageConnector(file), 
"GeoTIFF")) {
+                GridCoverageResource r = 
TestUtilities.getSingleton(assertInstanceOf(GeoTiffStore.class, 
store).components());
+                GridGeometry gg = r.getGridGeometry();
+                assertEquals(3, gg.getDimension());
+                
assertAxisDirectionsEqual(gg.getCoordinateReferenceSystem().getCoordinateSystem(),
+                        AxisDirection.EAST, AxisDirection.NORTH, 
AxisDirection.DOWN);
+
+                assertMatrixEquals(new Matrix4(0, -0.25, 0, 0,
+                                               0.25,  0, 0, 0,
+                                               0,     0, 1, 3,
+                                               0,     0, 0, 1),
+                        
MathTransforms.getMatrix(gg.getGridToCRS(PixelInCell.CELL_CORNER)), 0, 
"gridToCRS");
+
+                RenderedImage image = r.read(null).render(null);
+                assertEquals(width,  image.getWidth(),  "width");
+                assertEquals(height, image.getHeight(), "height");
+            }
+        } finally {
+            Files.delete(file);
+        }
+    }
+}

Reply via email to