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 c5b2f14be3b78275cc816f194c4d7085ea5a2480 Author: Alexis Manin <alexis.ma...@geomatys.com> AuthorDate: Mon Dec 5 13:15:27 2022 +0100 feat(Feature): Add a GridCoverageResource for band aggregation --- .../sis/storage/BandAggregateGridResource.java | 186 +++++++++++++++++++++ .../sis/storage/MultiSourceGridResource.java | 48 ++++++ .../org/apache/sis/storage/ResourceProcessor.java | 19 +++ .../apache/sis/storage/ResourceProcessorTest.java | 115 ++++++++++++- 4 files changed, 367 insertions(+), 1 deletion(-) diff --git a/storage/sis-storage/src/main/java/org/apache/sis/storage/BandAggregateGridResource.java b/storage/sis-storage/src/main/java/org/apache/sis/storage/BandAggregateGridResource.java new file mode 100644 index 0000000000..0493fe0703 --- /dev/null +++ b/storage/sis-storage/src/main/java/org/apache/sis/storage/BandAggregateGridResource.java @@ -0,0 +1,186 @@ +package org.apache.sis.storage; + +import java.awt.image.ColorModel; +import java.awt.image.RenderedImage; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.IntStream; +import java.util.stream.Stream; +import org.apache.sis.coverage.SampleDimension; +import org.apache.sis.coverage.grid.GridCoverage; +import org.apache.sis.coverage.grid.GridExtent; +import org.apache.sis.coverage.grid.GridGeometry; +import org.apache.sis.image.ImageProcessor; +import org.apache.sis.util.ComparisonMode; +import org.opengis.coverage.CannotEvaluateException; +import org.opengis.util.GenericName; + +/** + * Merge homogeneous {@link GridCoverageResource grid resources} by "stacking" their bands. + * + * <h3>Limitation</h3> + * For now, only datasets with <em>strictly</em> the same {@link GridCoverageResource#getGridGeometry() domain} can be merged. + * + * @see ImageProcessor#aggregateBands(List, List, ColorModel) + */ +class BandAggregateGridResource extends MultiSourceGridResource { + private final List<BandSelection> sources; + private final GridGeometry domain; + private final ColorModel userColors; + + BandAggregateGridResource(GenericName name, List<BandSelection> sources, ColorModel userColors) throws DataStoreException { + super(name); + this.sources = sources; + this.domain = verifyDomainEquality(sources); + this.userColors = userColors; + } + + @Override + List<GridCoverageResource> sources() { + return sources.stream().map(it -> it.data).collect(Collectors.toList()); + } + + @Override + public GridGeometry getGridGeometry() { return domain; } + + @Override + public List<SampleDimension> getSampleDimensions() throws DataStoreException { + return sources.stream() + .flatMap(BandSelection::selectSampleDimensions) + .collect(Collectors.toList()); + } + + @Override + public GridCoverage read(GridGeometry domain, int... ranges) throws DataStoreException { + if (domain == null) domain = getGridGeometry(); + else domain = getGridGeometry().derive().subgrid(domain).build(); + + final List<BandSelection> selection = select(ranges); + assert !selection.isEmpty(); + + final BandSelection firstSelection = selection.get(0); + final GridCoverage first = firstSelection.data.read(domain, firstSelection.selectedBands); + if (selection.size() == 1) return first; + + List<GridCoverage> readData = new ArrayList<>(selection.size()); + readData.add(first); + for (int i = 1 ; i < selection.size() ; i++) { + final BandSelection source = selection.get(i); + final GridCoverage data = source.data.read(domain, source.selectedBands); + if (!data.getGridGeometry().equals(first.getGridGeometry(), ComparisonMode.IGNORE_METADATA)) { + throw new UnsupportedOperationException("Band aggregation require all source datasets to provide the same domain"); + } + readData.add(data); + } + + final List<SampleDimension> outputSamples = readData.stream().flatMap(it -> it.getSampleDimensions().stream()).collect(Collectors.toList()); + return new BandAggregateGridCoverage(domain, outputSamples, readData); + } + + private GridGeometry verifyDomainEquality(List<BandSelection> sources) throws DataStoreException { + final GridGeometry first = sources.get(0).data.getGridGeometry(); + for (int i = 1 ; i < sources.size() ; i++) { + final GridGeometry other = sources.get(i).data.getGridGeometry(); + // TODO: rather than equality, we should check "alignment". It means that the coverage cells should be spatially aligned, + // but we should not require their grid extent to use the same offsets. + if (!first.equals(other, ComparisonMode.IGNORE_METADATA)) { + throw new IllegalArgumentException("Band merge only allow aligned datasets to be merged. Please resample your resources on a common grid beforehand"); + } + } + + return first; + } + + private List<BandSelection> select(int... bands) throws DataStoreException { + if (bands == null || bands.length < 1) return sources; + + class BandToData { + final int band; final GridCoverageResource source; + + BandToData(int band, GridCoverageResource source) { + this.band = band; + this.source = source; + } + } + + List<BandToData> perBandIndex = new ArrayList<>(); + for (BandSelection source : sources) { + final int[] sourceBands = source.selectedBands == null || source.selectedBands.length < 1 + ? IntStream.range(0, source.data.getSampleDimensions().size()).toArray() + : source.selectedBands; + for (int i : sourceBands) perBandIndex.add(new BandToData(i, source.data)); + } + + List<BandSelection> consolidated = new ArrayList<>(bands.length); + int previousIdx = 0; + BandToData previous = perBandIndex.get(bands[0]); + // Commodity: to avoid manipulating too many cursors, but also to avoid too many transformations, + // We use an array with a bigger size than needed to contain temporary source band indices. + // Its indices match target selected band numbers. + // Its content is the associated source band number for this target band. + int[] sourceSelectedBands = new int[bands.length]; + sourceSelectedBands[0] = previous.band; + for (int i = 1 ; i < bands.length ; i++) { + int band = bands[i]; + BandToData current = perBandIndex.get(band); + if (current.source != perBandIndex.get(bands[previousIdx]).source) { + final int[] sourceBands = Arrays.copyOfRange(sourceSelectedBands, previousIdx, i); + consolidated.add(new BandSelection(previous.source, sourceBands)); + previous = current; + previousIdx = i; + } + sourceSelectedBands[i] = current.band; + } + + consolidated.add(new BandSelection(previous.source, Arrays.copyOfRange(sourceSelectedBands, previousIdx, sourceSelectedBands.length))); + + return consolidated; + } + + private class BandAggregateGridCoverage extends GridCoverage { + + private final List<GridCoverage> sources; + + protected BandAggregateGridCoverage(GridGeometry domain, List<? extends SampleDimension> ranges, List<GridCoverage> sources) { + super(domain, ranges); + this.sources = sources; + assert sources != null && sources.size() > 1; + } + + + @Override + public RenderedImage render(GridExtent sliceExtent) throws CannotEvaluateException { + final List<RenderedImage> sourceImages = sources.stream() + .map(it -> it.render(sliceExtent)) + .collect(Collectors.toList()); + // TODO: parent resource should keep a reference to the resource processor that created it. + // Then, we should retrieve the embedded image processor and use it, instead of using a fresh image processor. + // However, that require an API change somewhere, and I do not know where yet. + return new ImageProcessor().aggregateBands(sourceImages, null, userColors); + } + } + + static class BandSelection { + final GridCoverageResource data; + final int[] selectedBands; + final List<SampleDimension> samples; + + BandSelection(GridCoverageResource data, int[] selectedBands) throws DataStoreException { + this.data = data; + this.selectedBands = selectedBands; + this.samples = data.getSampleDimensions(); + if (selectedBands != null) { + for (int band : selectedBands) { + if (band >= samples.size()) throw new IllegalArgumentException("Provided band selection is invalid. Input data provide only "+samples.size()+" bands, but band "+band+" was requested"); + } + } + } + + Stream<SampleDimension> selectSampleDimensions() { + if (selectedBands == null || selectedBands.length < 1) return samples.stream(); + return Arrays.stream(selectedBands).mapToObj(samples::get); + } + } +} diff --git a/storage/sis-storage/src/main/java/org/apache/sis/storage/MultiSourceGridResource.java b/storage/sis-storage/src/main/java/org/apache/sis/storage/MultiSourceGridResource.java new file mode 100644 index 0000000000..5a52764c89 --- /dev/null +++ b/storage/sis-storage/src/main/java/org/apache/sis/storage/MultiSourceGridResource.java @@ -0,0 +1,48 @@ +package org.apache.sis.storage; + +import java.util.List; +import java.util.Optional; +import org.apache.sis.internal.storage.MetadataBuilder; +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; + +abstract class MultiSourceGridResource implements GridCoverageResource { + + private final GenericName name; + + /** + * + * @param name Optional. The {@link #getIdentifier() identifier} of this resource. + */ + MultiSourceGridResource(GenericName name) { + this.name = name; + } + + abstract List<GridCoverageResource> sources(); + @Override + public Optional<Envelope> getEnvelope() { return Optional.empty(); } + + @Override + public Optional<GenericName> getIdentifier() { return Optional.ofNullable(name); } + + @Override + public Metadata getMetadata() throws DataStoreException { + MetadataBuilder builder = new MetadataBuilder(); + builder.addSpatialRepresentation(null, getGridGeometry(), false); + for (GridCoverageResource source : sources()) { + // TODO: not sure it is the right thing to do. I'm a little afraid of the performance impact. + builder.addSource(source.getMetadata()); + } + + return builder.buildAndFreeze(); + } + + @Override + public <T extends StoreEvent> void addListener(Class<T> eventType, StoreListener<? super T> listener) {} + + @Override + public <T extends StoreEvent> void removeListener(Class<T> eventType, StoreListener<? super T> listener) {} +} 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 3a0b745988..bce74739e5 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,6 +18,9 @@ package org.apache.sis.storage; import java.awt.image.ColorModel; import java.awt.image.RenderedImage; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; import java.util.Optional; import java.util.function.Function; import java.util.logging.Level; @@ -46,6 +49,7 @@ import org.opengis.util.FactoryException; import org.opengis.util.GenericName; import static org.apache.sis.util.ArgumentChecks.ensureNonNull; +import static org.apache.sis.util.ArgumentChecks.ensureValidIndex; /** * A predefined set of operations on resources as convenience methods. @@ -152,6 +156,21 @@ public class ResourceProcessor implements Cloneable { return new ResampledGridCoverageResource(source, reprojected, targetName, processor); } + public GridCoverageResource aggregateBands(GridCoverageResource... bands) throws DataStoreException { + return aggregateBands(null, Arrays.asList(bands), null, null); + } + + public GridCoverageResource aggregateBands(GenericName name, List<GridCoverageResource> resources, List<int[]> bandSelections, ColorModel userColors) throws DataStoreException { + ensureNonNull("resources", resources); + if (bandSelections != null) ensureValidIndex(resources.size(), bandSelections.size() - 1); + List<BandAggregateGridResource.BandSelection> selections = new ArrayList<>(resources.size()); + for (int i = 0 ; i < resources.size() ; i++) { + int[] bands = (bandSelections == null || bandSelections.size() <= i) ? null : bandSelections.get(i); + selections.add(new BandAggregateGridResource.BandSelection(resources.get(i), bands)); + } + return new BandAggregateGridResource(name, selections, userColors); + } + private static Optional<GeographicBoundingBox> searchGeographicExtent(GridCoverageResource source) throws DataStoreException { final Optional<GeographicBoundingBox> bbox = source.getMetadata().getIdentificationInfo().stream() .flatMap(it -> it.getExtents().stream()) 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 index 0f0211418e..59b4a4ef9d 100644 --- 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 @@ -3,7 +3,11 @@ package org.apache.sis.storage; import java.awt.image.DataBuffer; import java.awt.image.DataBufferInt; import java.awt.image.RenderedImage; +import java.util.Arrays; import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.IntStream; import org.apache.sis.coverage.SampleDimension; import org.apache.sis.coverage.grid.BufferedGridCoverage; import org.apache.sis.coverage.grid.GridCoverage; @@ -27,6 +31,7 @@ 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.assertNotNull; import static org.junit.Assert.fail; public class ResourceProcessorTest extends TestCase { @@ -76,6 +81,114 @@ public class ResourceProcessorTest extends TestCase { }, values); } + @Test + public void aggregateBandsFromSingleBandSources() throws Exception { + GridCoverageResource first = singleValuePerBand(1); + GridCoverageResource second = singleValuePerBand(2); + + final GridCoverageResource aggregation = nearestInterpol().aggregateBands(first, second); + final RenderedImage rendering = aggregation.read(null).render(null); + assertNotNull(rendering); + assertArrayEquals( + new int[] { + 1, 2, 1, 2, + 1, 2, 1, 2 + }, + rendering.getData().getPixels(0, 0, 2, 2, (int[]) null) + ); + + assertArrayEquals( + new int[] { + 1, 1, + 1, 1 + }, + aggregation.read(null, 0).render(null).getData().getPixels(0, 0, 2, 2, (int[]) null) + ); + + + assertArrayEquals( + new int[] { + 2, 2, + 2, 2 + }, + aggregation.read(null, 1).render(null).getData().getPixels(0, 0, 2, 2, (int[]) null) + ); + } + + @Test + public void aggregateBandsFromMultiBandSources() throws Exception { + GridCoverageResource firstAndSecondBands = singleValuePerBand(1, 2); + GridCoverageResource thirdAndFourthBands = singleValuePerBand(3, 4); + GridCoverageResource fifthAndSixthBands = singleValuePerBand(5, 6); + + GridCoverageResource aggregation = nearestInterpol().aggregateBands(firstAndSecondBands, thirdAndFourthBands, fifthAndSixthBands); + aggregation.getIdentifier().ifPresent(name -> fail("No name provided at creation, but one was returned: "+name)); + int[] values = aggregation.read(null).render(null).getData().getPixels(0, 0, 2, 2, (int[]) null); + assertArrayEquals( + new int[] { + 1, 2, 3, 4, 5, 6, 1, 2, 3, 4, 5, 6, + 1, 2, 3, 4, 5, 6, 1, 2, 3, 4, 5, 6, + }, + values + ); + + values = aggregation.read(null, 1, 2, 4, 5).render(null).getData().getPixels(0, 0, 2, 2, (int[]) null); + assertArrayEquals( + new int[] { + 2, 3, 5, 6, 2, 3, 5, 6, + 2, 3, 5, 6, 2, 3, 5, 6 + }, + values + ); + + values = aggregation.read(null, 3, 4).render(null).getData().getPixels(0, 0, 2, 2, (int[]) null); + assertArrayEquals( + new int[] { + 4, 5, 4, 5, + 4, 5, 4, 5 + }, + values + ); + + final LocalName testName = Names.createLocalName(null, null, "test-name"); + aggregation = nearestInterpol().aggregateBands(testName, Arrays.asList(firstAndSecondBands, thirdAndFourthBands, fifthAndSixthBands), Arrays.asList(null, new int[] { 0, 1 }, new int[] { 1 }), null); + + assertEquals(testName, aggregation.getIdentifier().orElse(null)); + + values = aggregation.read(null).render(null).getData().getPixels(0, 0, 2, 2, (int[]) null); + assertArrayEquals( + new int[] { + 1, 2, 3, 4, 6, 1, 2, 3, 4, 6, + 1, 2, 3, 4, 6, 1, 2, 3, 4, 6 + }, + values + ); + + values = aggregation.read(null, 2, 4).render(null).getData().getPixels(0, 0, 2, 2, (int[]) null); + assertArrayEquals( + new int[] { + 3, 6, 3, 6, + 3, 6, 3, 6 + }, + values + ); + } + + private static GridCoverageResource singleValuePerBand(int... bandValues) { + GridGeometry domain = new GridGeometry(new GridExtent(2, 2), PixelInCell.CELL_CENTER, identity(2), HardCodedCRS.WGS84); + final List<SampleDimension> samples = IntStream.of(bandValues) + .mapToObj(b -> new SampleDimension.Builder() + .setBackground(-1) + .addQuantitative("band-value", b, b + 1, 1, 0, Units.UNITY) + .build() + ) + .collect(Collectors.toList()); + + DataBuffer values = new DataBufferInt(IntStream.range(0, 4).flatMap(it -> Arrays.stream(bandValues)).toArray(), 4 * bandValues.length); + return new MemoryGridResource(null, new BufferedGridCoverage(domain, samples, 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. @@ -84,7 +197,7 @@ public class ResourceProcessorTest extends TestCase { 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) + .addQuantitative("1-based row-major order pixel number", 1, 5, 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));