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 122eed78ed04aded4a9fd383e2772675b1c1ea33 Author: Martin Desruisseaux <[email protected]> AuthorDate: Fri Aug 8 17:57:21 2025 +0200 Add a clip operation in `GridCoverageProcessor` working on grid coordinates. --- .../sis/coverage/grid/ClippedGridCoverage.java | 126 +++++++++++++ .../apache/sis/coverage/grid/GridCoverage2D.java | 2 +- .../sis/coverage/grid/GridCoverageProcessor.java | 34 +++- .../sis/coverage/grid/TranslatedGridCoverage.java | 9 +- .../sis/coverage/grid/ClippedGridCoverageTest.java | 202 +++++++++++++++++++++ .../coverage/grid/TranslatedGridCoverageTest.java | 4 +- 6 files changed, 366 insertions(+), 11 deletions(-) diff --git a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/coverage/grid/ClippedGridCoverage.java b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/coverage/grid/ClippedGridCoverage.java new file mode 100644 index 0000000000..e59652b3f3 --- /dev/null +++ b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/coverage/grid/ClippedGridCoverage.java @@ -0,0 +1,126 @@ +/* + * 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.grid; + +import java.awt.image.RenderedImage; +import org.opengis.referencing.operation.TransformException; +import org.apache.sis.util.CorruptedObjectException; +import org.apache.sis.image.PlanarImage; +import org.apache.sis.image.privy.ReshapedImage; + + +/** + * A grid coverage for a subset of the data as the source coverage. + * + * @author Martin Desruisseaux (Geomatys) + */ +final class ClippedGridCoverage extends DerivedGridCoverage { + /** + * Constructs a new grid coverage which will delegate the rendering operation to the given source. + * This coverage will take the same sample dimensions as the source. + * + * @param source the source to which to delegate rendering operations. + * @param domain the grid extent, CRS and conversion from cell indices to CRS. + */ + private ClippedGridCoverage(final GridCoverage source, final GridGeometry domain) { + super(source, domain); + } + + /** + * Returns a grid coverage clipped to the given extent. + * + * @param source the source to which to delegate rendering operations. + * @param clip the clip to apply in units of source grid coordinates. + * @return the clipped coverage. May be the {@code source} returned as-is. + * @throws IncompleteGridGeometryException if the given coverage has no grid extent. + * @throws DisjointExtentException if the given extent does not intersect the given coverage. + * @throws IllegalArgumentException if axes of the given extent are inconsistent with the axes of the grid of the given coverage. + */ + static GridCoverage create(GridCoverage source, final GridExtent clip, final boolean allowSourceReplacement) { + GridGeometry gridGeometry = source.getGridGeometry(); + GridExtent extent = gridGeometry.getExtent(); + if (extent == (extent = extent.intersect(clip))) { + return source; + } + if (allowSourceReplacement) { + while (source instanceof ClippedGridCoverage) { + source = ((ClippedGridCoverage) source).source; + if (extent.equals(source.gridGeometry.extent)) { + return source; + } + } + } + try { + gridGeometry = new GridGeometry(gridGeometry, extent, null); + } catch (TransformException e) { + // Unable to transform an envelope which was successfully transformed before. + // If it happens, assume that something wrong happened with the objects. + throw new CorruptedObjectException(e); + } + return new ClippedGridCoverage(source, gridGeometry); + } + + /** + * Returns a grid coverage that contains real values or sample values, depending if {@code converted} + * is {@code true} or {@code false} respectively. This method delegates to the source and wraps the + * result in a {@link ClippedGridCoverage} with the same extent. + */ + @Override + protected final GridCoverage createConvertedValues(final boolean converted) { + final GridCoverage cs = source.forConvertedValues(converted); + return (cs == source) ? this : new ClippedGridCoverage(cs, gridGeometry); + } + + /** + * Returns a two-dimensional slice of grid data as a rendered image. + */ + @Override + public RenderedImage render(GridExtent sliceExtent) { + final GridExtent clipped; + if (sliceExtent == null) { + clipped = sliceExtent = gridGeometry.extent; + } else { + clipped = sliceExtent.intersect(gridGeometry.extent); + } + final RenderedImage image = source.render(clipped); + /* + * After `render(…)` execution, the (minX, minY) image coordinates are the differences + * between the extent that we requested and what we got. If the clipped extent that we + * specified in above method call has an origin different than the user-supplied extent, + * we need to adjust. + */ + if (clipped != sliceExtent) { // Slight optimization for a common case. + long any = 0; + final long[] translation = new long[clipped.getDimension()]; + for (int i=0; i<translation.length; i++) { + any |= (translation[i] = Math.subtractExact(clipped.getLow(i), sliceExtent.getLow(i))); + } + if (any != 0) { + final Object property = image.getProperty(PlanarImage.XY_DIMENSIONS_KEY); + final int[] gridDimensions; + if (property instanceof int[]) { + gridDimensions = (int[]) property; + } else { + gridDimensions = clipped.getSubspaceDimensions(GridCoverage2D.BIDIMENSIONAL); + } + final var t = new ReshapedImage(image, translation[gridDimensions[0]], translation[gridDimensions[1]]); + return t.isIdentity() ? t.source : t; + } + } + return image; + } +} diff --git a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/coverage/grid/GridCoverage2D.java b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/coverage/grid/GridCoverage2D.java index 86dd521f02..38d60c852f 100644 --- a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/coverage/grid/GridCoverage2D.java +++ b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/coverage/grid/GridCoverage2D.java @@ -647,7 +647,7 @@ public class GridCoverage2D extends GridCoverage { * may force data loading earlier than desired. */ final var result = new ReshapedImage(data, xmin, ymin, xmax, ymax); - return result.isIdentity() ? data : result; + return result.isIdentity() ? result.source : result; } catch (ArithmeticException e) { throw new CannotEvaluateException(e.getMessage(), e); } diff --git a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/coverage/grid/GridCoverageProcessor.java b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/coverage/grid/GridCoverageProcessor.java index 9d8a33a514..47c44ab22d 100644 --- a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/coverage/grid/GridCoverageProcessor.java +++ b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/coverage/grid/GridCoverageProcessor.java @@ -497,6 +497,34 @@ public class GridCoverageProcessor implements Cloneable { return TranslatedGridCoverage.create(source, null, translation, allowSourceReplacement); } + /** + * Returns the intersection of the given coverage with the given extent. + * The extent shall have the same number of dimensions than the coverage. + * The "grid to <abbr>CRS</abbr>" transform is unchanged. + * + * <p>This method is useful for taking a slice of a multi-dimensional grid. + * Having a slice allows to invoke {@link GridCoverage#render(GridExtent)} + * with a null argument value.</p> + * + * @param source the grid coverage to clip. + * @param clip the clip to apply in units of source grid coordinates. + * @return a coverage with grid coordinates contained inside the given clip. + * @throws IncompleteGridGeometryException if the given coverage has no grid extent. + * @throws DisjointExtentException if the given extent does not intersect the given coverage. + * @throws IllegalArgumentException if axes of the given extent are inconsistent with the axes of the grid of the given coverage. + * + * @since 1.5 + */ + public GridCoverage clip(final GridCoverage source, final GridExtent clip) { + ArgumentChecks.ensureNonNull("source", source); + ArgumentChecks.ensureNonNull("clip", clip); + final boolean allowSourceReplacement; + synchronized (this) { + allowSourceReplacement = optimizations.contains(Optimization.REPLACE_SOURCE); + } + return ClippedGridCoverage.create(source, clip, allowSourceReplacement); + } + /** * Creates a new coverage with a different grid extent, resolution or coordinate reference system. * The desired properties are specified by the {@link GridGeometry} argument, which may be incomplete. @@ -967,10 +995,12 @@ public class GridCoverageProcessor implements Cloneable { @Override public boolean equals(final Object object) { if (object != null && object.getClass() == getClass()) { - final GridCoverageProcessor other = (GridCoverageProcessor) object; + final var other = (GridCoverageProcessor) object; if (imageProcessor.equals(other.imageProcessor)) { + @SuppressWarnings("LocalVariableHidesMemberVariable") final EnumSet<?> optimizations; synchronized (this) { + // Clone for allowing comparison outside the synchronized block. optimizations = (EnumSet<?>) this.optimizations.clone(); } synchronized (other) { @@ -999,7 +1029,7 @@ public class GridCoverageProcessor implements Cloneable { @Override public GridCoverageProcessor clone() { try { - final GridCoverageProcessor clone = (GridCoverageProcessor) super.clone(); + final var clone = (GridCoverageProcessor) super.clone(); final Field f = GridCoverageProcessor.class.getDeclaredField("imageProcessor"); f.setAccessible(true); // Caller sensitive: must be invoked in same module. f.set(clone, imageProcessor.clone()); diff --git a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/coverage/grid/TranslatedGridCoverage.java b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/coverage/grid/TranslatedGridCoverage.java index 26225bd368..8f70ffa212 100644 --- a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/coverage/grid/TranslatedGridCoverage.java +++ b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/coverage/grid/TranslatedGridCoverage.java @@ -18,9 +18,6 @@ package org.apache.sis.coverage.grid; import java.awt.image.RenderedImage; -// Specific to the geoapi-3.1 and geoapi-4.0 branches: -import org.opengis.coverage.CannotEvaluateException; - /** * A grid coverage with the same data as the source coverage, @@ -68,7 +65,7 @@ final class TranslatedGridCoverage extends DerivedGridCoverage { { if (allowSourceReplacement) { while (source instanceof TranslatedGridCoverage) { - final TranslatedGridCoverage tc = (TranslatedGridCoverage) source; + final var tc = (TranslatedGridCoverage) source; final long[] shifted = tc.translation.clone(); long tm = 0; for (int i = Math.min(shifted.length, translation.length); --i >= 0;) { @@ -83,7 +80,7 @@ final class TranslatedGridCoverage extends DerivedGridCoverage { final GridGeometry gridGeometry = source.getGridGeometry(); if (domain == null) { domain = gridGeometry.shiftGrid(translation); - } else if (!domain.extent.isSameSize(gridGeometry.extent)) { + } else if (!domain.getExtent().isSameSize(gridGeometry.extent)) { return null; } if (domain.equals(gridGeometry)) { @@ -110,7 +107,7 @@ final class TranslatedGridCoverage extends DerivedGridCoverage { * the rendered image shall not be translated. */ @Override - public RenderedImage render(GridExtent sliceExtent) throws CannotEvaluateException { + public RenderedImage render(GridExtent sliceExtent) { if (sliceExtent == null) { sliceExtent = gridGeometry.extent; } diff --git a/endorsed/src/org.apache.sis.feature/test/org/apache/sis/coverage/grid/ClippedGridCoverageTest.java b/endorsed/src/org.apache.sis.feature/test/org/apache/sis/coverage/grid/ClippedGridCoverageTest.java new file mode 100644 index 0000000000..cc80efe1dd --- /dev/null +++ b/endorsed/src/org.apache.sis.feature/test/org/apache/sis/coverage/grid/ClippedGridCoverageTest.java @@ -0,0 +1,202 @@ +/* + * 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.grid; + +import java.awt.image.Raster; +import java.awt.image.WritableRaster; +import java.awt.image.RenderedImage; +import java.awt.image.BufferedImage; +import java.awt.image.DataBuffer; +import org.apache.sis.referencing.operation.transform.MathTransforms; +import org.apache.sis.image.privy.RasterFactory; +import org.apache.sis.geometry.DirectPosition2D; + +// 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.referencing.crs.HardCodedCRS; + + +/** + * Tests {@link ClippedGridCoverage}. + * + * @author Martin Desruisseaux (Geomatys) + */ +public final class ClippedGridCoverageTest extends TestCase { + /** + * Size of the test image, in pixels. + */ + private static final int WIDTH = 7, HEIGHT = 9; + + /** + * Origin of the grid extent used in the test. + */ + private static final int OX = 2, OY = 5; + + /** + * Creates a new test case. + */ + public ClippedGridCoverageTest() { + } + + /** + * Creates a test coverage. + * + * @param processor non-null for hiding the {@link BufferedImage} implementation class. + */ + private static GridCoverage createCoverage(final GridCoverageProcessor processor) { + final GridExtent extent = new GridExtent(WIDTH, HEIGHT).translate(OX, OY); + final GridGeometry domain = new GridGeometry(extent, PixelInCell.CELL_CORNER, MathTransforms.scale(4, 2), HardCodedCRS.WGS84); + final BufferedImage data = RasterFactory.createGrayScaleImage(DataBuffer.TYPE_BYTE, WIDTH, HEIGHT, 1, 0, 0, 100); + final WritableRaster raster = data.getRaster(); + for (int y=0; y<HEIGHT; y++) { + for (int x=0; x<WIDTH; x++) { + raster.setSample(x, y, 0, 10*y+x); + } + } + RenderedImage image = data; + if (processor != null) { + // We are not really interrested in statistics, we just want to get a different implementation class. + image = processor.imageProcessor.statistics(image, null, null); + } + return new GridCoverageBuilder().setDomain(domain).setValues(image).build(); + } + + /** + * Verifies that the given two-dimensional extent has the given low coordinates and size. + */ + private static void assertExtentStarts(final GridExtent extent, final long low0, final long low1, final long size0, final long size1) { + assertEquals(2, extent.getDimension()); + assertEquals(low0, extent.getLow (0)); + assertEquals(low1, extent.getLow (1)); + assertEquals(size0, extent.getSize(0)); + assertEquals(size1, extent.getSize(1)); + } + + /** + * Asserts that the value of the first pixel of the given image is equal to the given value. + */ + private static void assertFirstPixelEquals(final int expected, final RenderedImage image) { + final Raster tile = image.getTile(0, 0); + assertEquals(expected, tile.getSample(0, 0, 0)); + } + + /** + * Tests the clipping of a coverage backed by a {@link BufferedImage}. + */ + @Test + public void testWithBufferedImage() { + test(false); + } + + /** + * Tests the clipping of a coverage backed by something else than {@link BufferedImage}. + */ + @Test + public void testWithRenderedImage() { + test(true); + } + + /** + * Shared implementation of {@link #testWithBufferedImage()} and {@link #testWithRenderedImage()}. + * + * @param hide whether to hide the {@link BufferedImage} implementation. + */ + private static void test(final boolean hide) { + final var clip = new GridExtent(WIDTH - 3, HEIGHT - 2).translate(OX - 1, OY + 1); + final var processor = new GridCoverageProcessor(); + final GridCoverage source = createCoverage(hide ? processor : null); + final GridCoverage target = processor.clip(source, clip); + assertExtentStarts(source.getGridGeometry().getExtent(), OX, OY, WIDTH, HEIGHT); + assertExtentStarts(target.getGridGeometry().getExtent(), OX, OY+1, WIDTH-4, HEIGHT-2); + /* + * Verifications on the source as a matter of principle. + * This is for making sure that the verifications on the target are okay. + */ + RenderedImage image = source.render(null); + assertEquals(WIDTH, image.getWidth()); + assertEquals(HEIGHT, image.getHeight()); + assertEquals(0, image.getMinX()); // The returned image match exactly the request. + assertEquals(0, image.getMinY()); + assertFirstPixelEquals(0, image); + /* + * Verification on the target. In the general case, the image is translated instead of clipped + * for having at the (0,0) coordinates the pixel that we would have if the image was clipped. + * This is because `GridCoverage2D` does not know how to clip an arbitrary rendered image. + * Only in the particular case of `BufferedImage`, a real clip is expected. + */ + image = target.render(null); + if (hide) { + assertEquals(WIDTH, image.getWidth()); + assertEquals(HEIGHT, image.getHeight()); + assertEquals( 0, image.getMinX()); + assertEquals(-1, image.getMinY()); + } else { + assertEquals(WIDTH - 4, image.getWidth()); + assertEquals(HEIGHT - 2, image.getHeight()); + assertEquals(0, image.getMinX()); + assertEquals(0, image.getMinY()); + } + assertFirstPixelEquals(10, image); + /* + * The result for identical "real world" coordinates should be the same for both coverages. + */ + final var p = new DirectPosition2D(HardCodedCRS.WGS84, 15, 20); + assertArrayEquals(source.evaluator().apply(p), + target.evaluator().apply(p)); + /* + * Test again with a sub-region having its origin inside the clipped area. Because of that, + * `ClippedGridCoverage` does not apply any additional translation on the rendered image. + */ + image = target.render(new GridExtent(WIDTH, HEIGHT).translate(OX+1, OY+2)); + if (hide) { + assertEquals(WIDTH, image.getWidth()); + assertEquals(HEIGHT, image.getHeight()); + assertEquals(-1, image.getMinX()); + assertEquals(-2, image.getMinY()); + } else { + assertEquals(WIDTH - 5, image.getWidth()); + assertEquals(HEIGHT - 3, image.getHeight()); + assertEquals(0, image.getMinX()); + assertEquals(0, image.getMinY()); + } + assertFirstPixelEquals(21, image); + assertArrayEquals(source.evaluator().apply(p), + target.evaluator().apply(p)); + /* + * Test again with a sub-region having its origin outside the clipped area. + * `ClippedGridCoverage` must add a translation for the difference between + * the request and what we got. + */ + image = target.render(new GridExtent(WIDTH, HEIGHT).translate(OX+1, OY-1)); + if (hide) { + assertEquals(WIDTH, image.getWidth()); + assertEquals(HEIGHT, image.getHeight()); + assertEquals(-1, image.getMinX()); + assertEquals(+1, image.getMinY()); + } else { + assertEquals(WIDTH - 5, image.getWidth()); + assertEquals(HEIGHT - 2, image.getHeight()); + assertEquals(0, image.getMinX()); + assertEquals(2, image.getMinY()); + } + // Do not test pixel at (0,0) because it does not exist. + assertArrayEquals(source.evaluator().apply(p), + target.evaluator().apply(p)); + } +} diff --git a/endorsed/src/org.apache.sis.feature/test/org/apache/sis/coverage/grid/TranslatedGridCoverageTest.java b/endorsed/src/org.apache.sis.feature/test/org/apache/sis/coverage/grid/TranslatedGridCoverageTest.java index ce5645ebca..37aae6af4f 100644 --- a/endorsed/src/org.apache.sis.feature/test/org/apache/sis/coverage/grid/TranslatedGridCoverageTest.java +++ b/endorsed/src/org.apache.sis.feature/test/org/apache/sis/coverage/grid/TranslatedGridCoverageTest.java @@ -73,7 +73,7 @@ public final class TranslatedGridCoverageTest extends TestCase { */ @Test public void testUsingProcessor() { - final GridCoverageProcessor processor = new GridCoverageProcessor(); + final var processor = new GridCoverageProcessor(); final GridCoverage source = createCoverage(); final GridCoverage target = processor.shiftGrid(source, 30, -5); assertExtentStarts(source.getGridGeometry().getExtent(), -20, -10); @@ -81,7 +81,7 @@ public final class TranslatedGridCoverageTest extends TestCase { /* * The result for identical "real world" coordinates should be the same for both coverages. */ - final DirectPosition2D p = new DirectPosition2D(HardCodedCRS.WGS84, -75, -18); + final var p = new DirectPosition2D(HardCodedCRS.WGS84, -75, -18); assertArrayEquals(source.evaluator().apply(p), target.evaluator().apply(p)); }
