You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@sis.apache.org by am...@apache.org on 2022/11/28 13:47:58 UTC

[sis] 03/03: feat(Storage): add GridCoverageResource resampling capability

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

amanin pushed a commit to branch feat/resource-processor
in repository https://gitbox.apache.org/repos/asf/sis.git

commit 5a6d0d24d06a7f8a57bdc6b586a07202f0685d5b
Author: Alexis Manin <al...@geomatys.com>
AuthorDate: Mon Nov 28 13:15:36 2022 +0100

    feat(Storage): add GridCoverageResource resampling capability
---
 .../storage/DerivedGridCoverageResource.java       | 72 ++++++++++++++++
 .../sis/storage/ResampledGridCoverageResource.java | 88 +++++++++++++++++++
 .../org/apache/sis/storage/ResourceProcessor.java  | 94 +++++++++++++++++++++
 .../apache/sis/storage/ResourceProcessorTest.java  | 98 ++++++++++++++++++++++
 .../apache/sis/test/suite/StorageTestSuite.java    |  3 +-
 5 files changed, 354 insertions(+), 1 deletion(-)

diff --git a/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/DerivedGridCoverageResource.java b/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/DerivedGridCoverageResource.java
new file mode 100644
index 0000000000..d5a4f718cf
--- /dev/null
+++ b/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/DerivedGridCoverageResource.java
@@ -0,0 +1,72 @@
+package org.apache.sis.internal.storage;
+
+import java.util.List;
+import java.util.Optional;
+import org.apache.sis.coverage.SampleDimension;
+import org.apache.sis.coverage.grid.GridGeometry;
+import org.apache.sis.storage.DataStoreException;
+import org.apache.sis.storage.GridCoverageResource;
+import org.apache.sis.storage.RasterLoadingStrategy;
+import org.apache.sis.storage.event.StoreEvent;
+import org.apache.sis.storage.event.StoreListener;
+import org.opengis.geometry.Envelope;
+import org.opengis.metadata.Metadata;
+import org.opengis.util.GenericName;
+
+import static org.apache.sis.util.ArgumentChecks.ensureNonNull;
+
+public abstract class DerivedGridCoverageResource implements GridCoverageResource {
+
+    protected final GenericName name;
+    protected final GridCoverageResource source;
+
+    protected DerivedGridCoverageResource(GenericName name, GridCoverageResource source) {
+        this.name = name;
+        ensureNonNull("Source", source);
+        this.source = source;
+    }
+
+    @Override
+    public Optional<Envelope> getEnvelope() throws DataStoreException { return source.getEnvelope(); }
+
+    @Override
+    public GridGeometry getGridGeometry() throws DataStoreException { return source.getGridGeometry(); }
+
+    @Override
+    public List<SampleDimension> getSampleDimensions() throws DataStoreException { return source.getSampleDimensions(); }
+
+    @Override
+    public RasterLoadingStrategy getLoadingStrategy() throws DataStoreException { return source.getLoadingStrategy(); }
+
+    @Override
+    public boolean setLoadingStrategy(RasterLoadingStrategy strategy) throws DataStoreException { return source.setLoadingStrategy(strategy); }
+
+    @Override
+    public Optional<GenericName> getIdentifier() throws DataStoreException { return Optional.ofNullable(name); }
+
+    @Override
+    public Metadata getMetadata() throws DataStoreException {
+        final MetadataBuilder builder = new MetadataBuilder();
+        builder.addSpatialRepresentation(null, getGridGeometry(), false);
+        builder.addSource(source.getMetadata());
+        return builder.buildAndFreeze();
+    }
+
+    @Override
+    public <T extends StoreEvent> void addListener(Class<T> eventType, StoreListener<? super T> listener) {
+        /*
+         * TODO: for now, consider it a no-op. Plugging directly into source might be a bad idea.
+         *  1. We do not know in advance what modifications are done over the source.
+         *     Therefore, we do not know how events should be amended to reflect the resource derivation.
+         *     For now, make derived resource not listenable by default.
+         *  2. We do not know if the same listener is already registered on source, and simply passing the listener
+         *     to the source might cause redondant work.
+         * Each implementation is free to implement it as it see fit.
+         */
+    }
+
+    @Override
+    public <T extends StoreEvent> void removeListener(Class<T> eventType, StoreListener<? super T> listener) {
+        // See addListener to know why it is a no-op by default.
+    }
+}
diff --git a/storage/sis-storage/src/main/java/org/apache/sis/storage/ResampledGridCoverageResource.java b/storage/sis-storage/src/main/java/org/apache/sis/storage/ResampledGridCoverageResource.java
new file mode 100644
index 0000000000..86bef731c2
--- /dev/null
+++ b/storage/sis-storage/src/main/java/org/apache/sis/storage/ResampledGridCoverageResource.java
@@ -0,0 +1,88 @@
+/*
+ * 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;
+
+import java.util.Optional;
+import org.apache.sis.coverage.grid.GridCoverage;
+import org.apache.sis.coverage.grid.GridCoverageProcessor;
+import org.apache.sis.coverage.grid.GridGeometry;
+import org.apache.sis.internal.storage.DerivedGridCoverageResource;
+import org.opengis.geometry.Envelope;
+import org.opengis.referencing.crs.CoordinateReferenceSystem;
+import org.opengis.referencing.operation.TransformException;
+import org.opengis.util.GenericName;
+
+/**
+ * A decoration over a resource to resample it on a specified {@link #outputGeometry grid geometry} when a {@link #read(GridGeometry, int...)} is triggered.
+ *
+ * @see ResourceProcessor#resample(GridCoverageResource, CoordinateReferenceSystem, GenericName)
+ * @see ResourceProcessor#resample(GridCoverageResource, GridGeometry, GenericName)
+ *
+ * @author Alexis Manin (Geomatys)
+ */
+final class ResampledGridCoverageResource extends DerivedGridCoverageResource {
+
+    private final GridCoverageProcessor processor;
+    private final GridGeometry outputGeometry;
+
+    ResampledGridCoverageResource(GridCoverageResource source, GridGeometry outputGeometry, GenericName name, GridCoverageProcessor processor) {
+        super(name, source);
+        this.processor = processor;
+        this.outputGeometry = outputGeometry;
+    }
+
+    @Override
+    public Optional<Envelope> getEnvelope() {
+        return outputGeometry.isDefined(GridGeometry.ENVELOPE)
+                ? Optional.of(outputGeometry.getEnvelope())
+                : Optional.empty();
+    }
+
+    @Override
+    public GridGeometry getGridGeometry() { return outputGeometry; }
+
+    @Override
+    public GridCoverageResource subset(Query query) throws DataStoreException {
+        if (query instanceof CoverageQuery) {
+            CoverageQuery cq = (CoverageQuery) query;
+            GridGeometry selection = cq.getSelection();
+            if (selection != null) {
+                selection = outputGeometry.derive().subgrid(selection).build();
+                final GridCoverageResource updatedResample = new ResampledGridCoverageResource(source, selection, null, processor);
+                cq = cq.clone();
+                cq.setSelection((GridGeometry) null);
+                return updatedResample.subset(cq);
+            }
+        }
+
+        return super.subset(query);
+    }
+
+    @Override
+    public GridCoverage read(GridGeometry domain, int... ranges) throws DataStoreException {
+        domain = domain == null
+                ? outputGeometry
+                : outputGeometry.derive().subgrid(domain).build();
+
+        GridCoverage rawRead = source.read(domain, ranges);
+        try {
+            return processor.resample(rawRead, domain);
+        } catch (TransformException e) {
+            throw new DataStoreException("Cannot adapt source to resampling domain", e);
+        }
+    }
+}
diff --git a/storage/sis-storage/src/main/java/org/apache/sis/storage/ResourceProcessor.java b/storage/sis-storage/src/main/java/org/apache/sis/storage/ResourceProcessor.java
index 250e4de68a..3a0b745988 100644
--- a/storage/sis-storage/src/main/java/org/apache/sis/storage/ResourceProcessor.java
+++ b/storage/sis-storage/src/main/java/org/apache/sis/storage/ResourceProcessor.java
@@ -18,15 +18,34 @@ package org.apache.sis.storage;
 
 import java.awt.image.ColorModel;
 import java.awt.image.RenderedImage;
+import java.util.Optional;
 import java.util.function.Function;
+import java.util.logging.Level;
+import java.util.logging.Logger;
 import org.apache.sis.coverage.SampleDimension;
 import org.apache.sis.coverage.grid.GridCoverage;
 import org.apache.sis.coverage.grid.GridCoverageProcessor;
+import org.apache.sis.coverage.grid.GridGeometry;
+import org.apache.sis.coverage.grid.GridRoundingMode;
+import org.apache.sis.coverage.grid.IncompleteGridGeometryException;
 import org.apache.sis.image.DataType;
 import org.apache.sis.image.ImageProcessor;
 import org.apache.sis.internal.storage.ConvertedCoverageResource;
+import org.apache.sis.internal.system.Loggers;
 import org.apache.sis.measure.NumberRange;
+import org.apache.sis.metadata.iso.extent.DefaultGeographicBoundingBox;
+import org.apache.sis.referencing.CRS;
+import org.apache.sis.referencing.operation.transform.MathTransforms;
+import org.opengis.metadata.extent.GeographicBoundingBox;
+import org.opengis.referencing.crs.CoordinateReferenceSystem;
+import org.opengis.referencing.datum.PixelInCell;
+import org.opengis.referencing.operation.CoordinateOperation;
 import org.opengis.referencing.operation.MathTransform1D;
+import org.opengis.referencing.operation.TransformException;
+import org.opengis.util.FactoryException;
+import org.opengis.util.GenericName;
+
+import static org.apache.sis.util.ArgumentChecks.ensureNonNull;
 
 /**
  * A predefined set of operations on resources as convenience methods.
@@ -87,4 +106,79 @@ public class ResourceProcessor implements Cloneable {
     {
         return new ConvertedCoverageResource(source, converters, sampleDimensionModifier);
     }
+
+    /**
+     * Wraps a given resource with a resample operator, to ensure it fits a provided grid geometry.
+     * Neither resampling nor read is triggered immediately. Instead, a virtual resource is returned.
+     * It will launch read then resample operations upon call to {@link GridCoverageResource#read(GridGeometry, int...)}.
+     *
+     * @param source Resource to resample. Must not be null.
+     * @param target Grid geometry to use for output/resampled resource. Must not be null.
+     * @param targetName An optional name for returned resource. If null, output {@link Resource#getIdentifier() resource identifier} will not be present.
+     * @return A resource decorating provided one. It triggers a {@link GridCoverageProcessor#resample(GridCoverage, GridGeometry) resampling operation} upon reads. Never null.
+     */
+    public GridCoverageResource resample(final GridCoverageResource source, final GridGeometry target, GenericName targetName) {
+        return new ResampledGridCoverageResource(source, target, targetName, processor);
+    }
+
+    /**
+     * Reprojects provided resource. Note that:
+     * <ul>
+     *     <li>Provided resource metadata and grid geometry will be immediately fetched</li>
+     *     <li>Resampling will be postponed until a call to {@link GridCoverageResource#read(GridGeometry, int...)}</li>
+     * </ul>
+     * @return Either the input resource if no reprojection is needed for conversion to target CRS. Otherwise, a virtual dataset performing resample on read.
+     * @throws DataStoreException If input resource metadata or grid geometry cannot be acquired.
+     * @throws FactoryException If referencing database is not reachable, or if it is not possible to find any valid operation from input resource system to provided CRS.
+     * @throws TransformException If an error occurs while transforming input resource geometry to the target CRS.
+     * @throws IncompleteGridGeometryException If input resource geometry does not provide enough information to build a resampling pipeline (i.e. No CRS or no envelope).
+     */
+    public GridCoverageResource resample(final GridCoverageResource source, final CoordinateReferenceSystem target, GenericName targetName) throws DataStoreException, FactoryException, TransformException {
+        ensureNonNull("Source", source);
+        ensureNonNull("Target CRS", target);
+        final GridGeometry sourceGeom = source.getGridGeometry();
+        final CoordinateReferenceSystem sourceCrs = sourceGeom.getCoordinateReferenceSystem();
+
+        final GridGeometry reprojected;
+        if (sourceGeom.isDefined(GridGeometry.GRID_TO_CRS + GridGeometry.EXTENT)) {
+            final CoordinateOperation op = CRS.findOperation(sourceCrs, target, searchGeographicExtent(source).orElse(null));
+            if (op.getMathTransform() == null || op.getMathTransform().isIdentity()) return source;
+            reprojected = new GridGeometry(sourceGeom.getExtent(), PixelInCell.CELL_CENTER,
+                    MathTransforms.concatenate(sourceGeom.getGridToCRS(PixelInCell.CELL_CENTER), op.getMathTransform()), target);
+        } else if (sourceGeom.isDefined(GridGeometry.ENVELOPE)) {
+            reprojected = new GridGeometry(null, null, sourceGeom.getEnvelope(target), GridRoundingMode.ENCLOSING);
+        } else throw new IncompleteGridGeometryException("Cannot reproject a grid coverage resource whose geometry defines neither an envelope nor a conversion for grid to CRS");
+
+        return new ResampledGridCoverageResource(source, reprojected, targetName, processor);
+    }
+
+    private static Optional<GeographicBoundingBox> searchGeographicExtent(GridCoverageResource source) throws DataStoreException {
+        final Optional<GeographicBoundingBox> bbox = source.getMetadata().getIdentificationInfo().stream()
+                .flatMap(it -> it.getExtents().stream())
+                .flatMap(it -> it.getGeographicElements().stream())
+                .filter(GeographicBoundingBox.class::isInstance)
+                .map(it -> (GeographicBoundingBox) it)
+                .reduce(ResourceProcessor::union);
+
+        if (bbox.isPresent()) return bbox;
+
+        return source.getEnvelope()
+                .map(it -> {
+                    DefaultGeographicBoundingBox g = new DefaultGeographicBoundingBox();
+                    try {
+                        g.setBounds(it);
+                    } catch (TransformException e) {
+                        Logger.getLogger(Loggers.COORDINATE_OPERATION)
+                                .log(Level.FINE, "Cannot extract geographic extent from source resource", e);
+                        return null;
+                    }
+                    return g;
+                });
+    }
+
+    private static GeographicBoundingBox union(GeographicBoundingBox g1, GeographicBoundingBox g2) {
+        final DefaultGeographicBoundingBox union = new DefaultGeographicBoundingBox(g1);
+        union.add(g2);
+        return union;
+    }
 }
diff --git a/storage/sis-storage/src/test/java/org/apache/sis/storage/ResourceProcessorTest.java b/storage/sis-storage/src/test/java/org/apache/sis/storage/ResourceProcessorTest.java
new file mode 100644
index 0000000000..0f0211418e
--- /dev/null
+++ b/storage/sis-storage/src/test/java/org/apache/sis/storage/ResourceProcessorTest.java
@@ -0,0 +1,98 @@
+package org.apache.sis.storage;
+
+import java.awt.image.DataBuffer;
+import java.awt.image.DataBufferInt;
+import java.awt.image.RenderedImage;
+import java.util.Collections;
+import org.apache.sis.coverage.SampleDimension;
+import org.apache.sis.coverage.grid.BufferedGridCoverage;
+import org.apache.sis.coverage.grid.GridCoverage;
+import org.apache.sis.coverage.grid.GridCoverageProcessor;
+import org.apache.sis.coverage.grid.GridExtent;
+import org.apache.sis.coverage.grid.GridGeometry;
+import org.apache.sis.coverage.grid.GridOrientation;
+import org.apache.sis.image.ImageProcessor;
+import org.apache.sis.image.Interpolation;
+import org.apache.sis.internal.referencing.j2d.AffineTransform2D;
+import org.apache.sis.internal.storage.MemoryGridResource;
+import org.apache.sis.measure.Units;
+import org.apache.sis.referencing.crs.HardCodedCRS;
+import org.apache.sis.test.TestCase;
+import org.apache.sis.util.iso.Names;
+import org.junit.Test;
+import org.opengis.referencing.datum.PixelInCell;
+import org.opengis.util.GenericName;
+import org.opengis.util.LocalName;
+
+import static org.apache.sis.referencing.operation.transform.MathTransforms.identity;
+import static org.junit.Assert.assertArrayEquals;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.fail;
+
+public class ResourceProcessorTest extends TestCase {
+
+    /**
+     * Verify that resampling is activated as ordered when inverting CRS axes.
+     *
+     * Note: the test assertion is implementation specific. We assume that due to the trivial transform in play, the
+     * resample will only modify the conversion from grid to space, without changing associated image data.
+     */
+    @Test
+    public void resampleByCrs() throws Exception {
+        final LocalName name = Names.createLocalName(null, null, "resample-by-crs");
+        final GridCoverageResource resampled = nearestInterpol().resample(grid1234(), HardCodedCRS.WGS84_LATITUDE_FIRST, name);
+        GenericName queriedName = resampled.getIdentifier().orElseThrow(() -> new AssertionError("No name defined, but one was provided"));
+        assertEquals("resampled resource name", name, queriedName);
+        final GridCoverage read = resampled.read(null);
+        assertEquals(new AffineTransform2D(0, 1, 1, 0, 0, 0), read.getGridGeometry().getGridToCRS(PixelInCell.CELL_CENTER));
+        final RenderedImage rendered = read.render(null);
+        assertEquals("Resample dimensions: width", 2, rendered.getWidth());
+        assertEquals("Resample dimensions: height", 2, rendered.getHeight());
+
+        final int[] values = rendered.getData().getPixels(0, 0, 2, 2, (int[]) null);
+        assertArrayEquals(new int[] { 1, 2, 3, 4 }, values);
+    }
+
+    /**
+     * Force a simple x2 upsampling to ensure that resample is well activated.
+     */
+    @Test
+    public void resampleByGridGeometry() throws Exception {
+        final GridCoverageResource source = grid1234();
+        final GridGeometry sourceGG = source.getGridGeometry();
+        final GridGeometry upsampledGeom = new GridGeometry(new GridExtent(4, 4), sourceGG.getEnvelope(), GridOrientation.HOMOTHETY);
+        final GridCoverageResource resampled = nearestInterpol().resample(source, upsampledGeom, null);
+        resampled.getIdentifier().ifPresent(name -> fail("Name should be null, but a value was returned: "+name));
+        final RenderedImage rendered = resampled.read(null).render(null);
+        assertEquals("Resample dimensions: width", 4, rendered.getWidth());
+        assertEquals("Resample dimensions: height", 4, rendered.getHeight());
+
+        final int[] values = rendered.getData().getPixels(0, 0, 4, 4, (int[]) null);
+        assertArrayEquals(new int[] {
+                1, 1, 2, 2,
+                1, 1, 2, 2,
+                3, 3, 4, 4,
+                3, 3, 4, 4,
+        }, values);
+    }
+
+    /**
+     * Create a trivial 2D grid coverage of dimension 2x2. It uses an identity transform for grid to space conversion,
+     * and a common WGS84 coordinate reference system, with longitude first.
+     */
+    private static GridCoverageResource grid1234() {
+        GridGeometry domain = new GridGeometry(new GridExtent(2, 2), PixelInCell.CELL_CENTER, identity(2), HardCodedCRS.WGS84);
+        SampleDimension band = new SampleDimension.Builder()
+                .setBackground(0)
+                .addQuantitative("1-based row-major order pixel number", 1, 4, 1, 0, Units.UNITY)
+                .build();
+        DataBuffer values = new DataBufferInt(new int[] {1, 2, 3, 4}, 4);
+        return new MemoryGridResource(null, new BufferedGridCoverage(domain, Collections.singletonList(band), values));
+    }
+
+    private static ResourceProcessor nearestInterpol() {
+        final ImageProcessor imp = new ImageProcessor();
+        imp.setInterpolation(Interpolation.NEAREST);
+        return new ResourceProcessor(new GridCoverageProcessor(imp));
+    }
+}
diff --git a/storage/sis-storage/src/test/java/org/apache/sis/test/suite/StorageTestSuite.java b/storage/sis-storage/src/test/java/org/apache/sis/test/suite/StorageTestSuite.java
index af2d2b5554..0c84a6090a 100644
--- a/storage/sis-storage/src/test/java/org/apache/sis/test/suite/StorageTestSuite.java
+++ b/storage/sis-storage/src/test/java/org/apache/sis/test/suite/StorageTestSuite.java
@@ -68,7 +68,8 @@ import org.junit.BeforeClass;
     org.apache.sis.internal.storage.folder.StoreTest.class,
     org.apache.sis.storage.aggregate.JoinFeatureSetTest.class,
     org.apache.sis.storage.aggregate.ConcatenatedFeatureSetTest.class,
-    org.apache.sis.storage.DataStoresTest.class
+    org.apache.sis.storage.DataStoresTest.class,
+    org.apache.sis.storage.ResourceProcessorTest.class
 })
 public final strictfp class StorageTestSuite extends TestSuite {
     /**