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 <alexis.ma...@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 { /**