diff --git a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/internal/shared/ReshapedImage.java b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/internal/shared/ReshapedImage.java index 7db4d356607..17bebfaab57 100644 --- a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/internal/shared/ReshapedImage.java +++ b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/internal/shared/ReshapedImage.java @@ -128,7 +128,7 @@ public static RenderedImage singleTile(final RenderedImage source, final int til 0, 0, tileWidth, tileHeight, - 0, 0); + tileX, tileY); return image.isIdentity() ? image.source : image; } @@ -298,6 +298,14 @@ private Raster offset(final Raster data) { */ @Override public Raster getTile(final int tileX, final int tileY) { + // Ensure reshaped image strictly respect its boundaries and does not access source tiles outside its domain. + if (!( + getMinTileX() <= tileX && tileX < getMinTileX() + getNumXTiles() + && + getMinTileY() <= tileY && tileY < getMinTileY() + getNumYTiles() + )) { + throw new IllegalArgumentException("Requested tile is outside Image domain"); + } return offset(source.getTile(tileX, tileY)); } diff --git a/endorsed/src/org.apache.sis.feature/test/org/apache/sis/image/internal/shared/ReshapedImageTest.java b/endorsed/src/org.apache.sis.feature/test/org/apache/sis/image/internal/shared/ReshapedImageTest.java index 35973b69080..10884f8d3bd 100644 --- a/endorsed/src/org.apache.sis.feature/test/org/apache/sis/image/internal/shared/ReshapedImageTest.java +++ b/endorsed/src/org.apache.sis.feature/test/org/apache/sis/image/internal/shared/ReshapedImageTest.java @@ -198,4 +198,28 @@ public void testMultiTiles() { {1210, 1211, 1212 , 1310, 1311, 1312 , 1410, 1411, 1412} }); } + + /** + * Verify a reshaped image created to expose a single tile from a source tiled image only serves the requested tile. + */ + @Test + public void testExposeSingleTileFromTiledImage() { + var source = new TiledImageMock(DataBuffer.TYPE_USHORT, 1, 0, 0, 4, 4, 2, 2, 0, 0, false); + source.validate(); + source.initializeAllTiles(0); + + var lastTile = ReshapedImage.singleTile(source, 1, 1); + try { + lastTile.getTile(0, 0); + fail("Tile (0, 0) should not be available"); + } catch (IllegalArgumentException e) { + // Expected + } + + final var exposedTile = lastTile.getTile(1, 1); + assertValuesEqual(exposedTile, 0, new int[][] { + { 400, 401 }, + { 410, 411 } + }); + } } diff --git a/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/tiling/ImageTileMatrix.java b/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/tiling/ImageTileMatrix.java index 38d09e96e85..c0bf1791e23 100644 --- a/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/tiling/ImageTileMatrix.java +++ b/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/tiling/ImageTileMatrix.java @@ -33,7 +33,6 @@ import org.apache.sis.storage.DataStoreException; import org.apache.sis.storage.NoSuchDataException; import org.apache.sis.storage.UnsupportedQueryException; -import org.apache.sis.storage.InternalDataStoreException; import org.apache.sis.storage.Resource; import org.apache.sis.coverage.grid.GridExtent; import org.apache.sis.coverage.grid.GridGeometry; @@ -132,6 +131,12 @@ final class ImageTileMatrix implements TileMatrix { */ private RenderedImage image; + /** + * Extent of {@link #image} in the {@link #coverage} {@link TileMatrix#getTilingScheme() tiling scheme}. + * Id {@link #image} is null, this should also be null. If image is not null, this must not be null. + */ + private GridExtent imageTilingExtent; + /** * The grid coverage processor to use when tiles use a subset of the bands. * @@ -350,6 +355,15 @@ public Stream getTiles(GridExtent indiceRanges, final boolean parallel) th if (indiceRanges == null) { indiceRanges = tilingScheme.getExtent(); } + + final var coverage = coverage(); + for (int dim = 0 ; dim < indiceRanges.getDimension(); dim++) { + if (dim != coverage.xDimension && dim != coverage.yDimension && dim > 1) { + return slice(indiceRanges, coverage.xDimension, coverage.yDimension, parallel); + } + } + + assert indiceRanges.getDegreesOfFreedom() <= 2 : "This code should only be reached if requested extent is 2D"; try { return StreamSupport.stream(iterator(indiceRanges).iterator(), parallel); } catch (ArithmeticException e) { @@ -357,6 +371,46 @@ public Stream getTiles(GridExtent indiceRanges, final boolean parallel) th } } + /** + * Split given extent in a lazy sequence of 2D extents. For each 2D slice, + * we query all tiles contained in user requested area. + * + * @param indiceRanges N-D tile extent to slice and to load tiles for. + * @param xDim X dimension of the coverage, first axis of the 2D part to preserve in slices. + * @param yDim Y dimension of the coverage, second axis of the 2D part to preserve in slices. + * @param parallel True if we want a parallel stream returned, false otherwise. + * @return A lazy sequence of all tiles contained in the given tile range. + */ + private Stream slice(GridExtent indiceRanges, int xDim, int yDim, boolean parallel) { + final var slicingStart = indiceRanges.getLow().getCoordinateValues(); + final var slicingEnd = indiceRanges.getHigh().getCoordinateValues(); + final long xMax = slicingEnd[xDim]; + final long yMax = slicingEnd[yDim]; + slicingEnd[xDim] = slicingStart[xDim]; + slicingEnd[yDim] = slicingStart[yDim]; + + final var slicingExtent = new GridExtent(null, slicingStart, slicingEnd, true); + // NOTE: not sure here, but depending on stream fork policy, + // allowing parallel extents ould hurt performance, + // because it potentially allows to load tiles from different slices in parallel. + // As this class image caching strategy is based on 2D extents, + // we instead push parallelism down on tile loading level directly (see flatMap block). + return slicingExtent.latticePointStream(false) + .map(slicePoint -> { + final var sliceHigh = Arrays.copyOf(slicePoint, slicePoint.length); + sliceHigh[xDim] = xMax; + sliceHigh[yDim] = yMax; + return new GridExtent(null, slicePoint, sliceHigh, true); + }) + .flatMap(slice -> { + try { + return StreamSupport.stream(iterator(slice).iterator(), parallel); + } catch (DataStoreException e) { + throw new BackingStoreException("Cannot load tiles for 2D extent", e); + } + }); + } + /** * Creates an object which can be used for retrieving a single tile or a stream tiles. * @@ -368,25 +422,12 @@ public Stream getTiles(GridExtent indiceRanges, final boolean parallel) th private synchronized IterationDomain iterator(final GridExtent indiceRanges) throws DataStoreException { @SuppressWarnings("LocalVariableHidesMemberVariable") final TiledGridCoverage coverage = coverage(); - boolean retry = false; - do { // This loop will be executed only 1 or 2 times. - if (image != null) { - final long xmin, ymin, xmax, ymax; - xmin = Math.subtractExact(indiceRanges.getLow (coverage.xDimension), imageToTileX); - xmax = Math.subtractExact(indiceRanges.getHigh(coverage.xDimension), imageToTileX); - final long x0 = image.getMinTileX(); - if (xmin >= x0 && xmax < x0 + image.getNumXTiles()) { - ymin = Math.subtractExact(indiceRanges.getLow (coverage.yDimension), imageToTileY); - ymax = Math.subtractExact(indiceRanges.getHigh(coverage.yDimension), imageToTileY); - final long y0 = image.getMinTileY(); - if (ymin >= y0 && ymax < y0 + image.getNumYTiles()) { - return new Iterator(Math.toIntExact(xmin), - Math.toIntExact(ymin), - Math.toIntExact(xmax), - Math.toIntExact(ymax)); - } - } - } + assert Arrays.equals(indiceRanges.getSubspaceDimensions(2), new int[] {coverage.xDimension, coverage.yDimension}) + : "Iterator can only return tiles for a 2D slice over coverage XY dimensions."; + + // Returns currently cached image if it + final var indiceRangesLow = indiceRanges.getLow().getCoordinateValues(); + if (image == null || !imageTilingExtent.contains(indiceRanges)) { /* * Gets the bounds of the image to read. If deferred reading is supported, * we can expand to the bounds of the whole coverage in order to perform a @@ -399,7 +440,7 @@ private synchronized IterationDomain iterator(final GridExtent indiceRange for (int i=0; i Integer.MAX_VALUE) { throw new ArithmeticException(resource.errors().getString(Errors.Keys.IntegerOverflow_1, Integer.SIZE)); @@ -412,11 +453,24 @@ private synchronized IterationDomain iterator(final GridExtent indiceRange high[i] += after; } } - image = coverage.render(extent.reshape(low, high, false)); + + + final var imagePixelExtent = extent.reshape(low, high, false); + imageTilingExtent = imagePixelExtent + .translate(Arrays.stream(tileToCell).map(v -> -v).toArray()) + .subsample(Arrays.stream(tileSize).mapToLong(v -> v).toArray()); + image = coverage.render(imagePixelExtent); imageToTileX = low[coverage.xDimension]; imageToTileY = low[coverage.yDimension]; - } while ((retry = !retry) == true); - throw new InternalDataStoreException(); // Should never happen. + } + + return new Iterator( + Math.toIntExact(Math.subtractExact(indiceRangesLow[coverage.xDimension], imageToTileX)), + Math.toIntExact(Math.subtractExact(indiceRangesLow[coverage.yDimension], imageToTileY)), + Math.toIntExact(Math.subtractExact(indiceRanges.getHigh(coverage.xDimension), imageToTileX)), + Math.toIntExact(Math.subtractExact(indiceRanges.getHigh(coverage.yDimension), imageToTileY)), + indiceRangesLow + ); } /** @@ -448,19 +502,42 @@ private final class Iterator extends IterationDomain { */ private final long offsetX, offsetY; + /** + * Dimension indices for the X and Y axes in the tile matrix coordinate system. + */ + private final int xDim, yDim; + + /** + * Base template of tile indices. + * Used when returning {@link Tile#getIndices() tile indices}. + * Tiles will use these coordinates, replacing {link #xDim X} and {link #yDim Y} dimensions. + */ + private final long[] baseIndices; + /** * Creates a new request for tile iterators. * - * @param xmin first column index of tiles, inclusive. - * @param xmin first row index of tiles, inclusive. - * @param xmax last column index of tiles, inclusive. - * @param ymax last row index of tiles, inclusive. + * @param xmin first column index of tiles, inclusive. + * @param ymin first row index of tiles, inclusive. + * @param xmax last column index of tiles, inclusive. + * @param ymax last row index of tiles, inclusive. + * @param baseIndices tile coordinate template. + * It can be any valid coordinate of the tiles managed by this iterator. + * It serves as base to build correct tile coordinate for each returned tile. + * Each tile will clone this array and replace its X and Y indices with its own. + * Therefore, it is important that it represent properly the "slice" of extra-dimensions + * this iterator operates on. */ - Iterator(final int xmin, final int ymin, final int xmax, final int ymax) { + Iterator(final int xmin, final int ymin, final int xmax, final int ymax, + final long[] baseIndices) + { super(xmin, ymin, xmax, ymax); tiles = image; offsetX = imageToTileX; offsetY = imageToTileY; + xDim = coverage.xDimension; + yDim = coverage.yDimension; + this.baseIndices = baseIndices; } /** @@ -481,7 +558,10 @@ protected Tile createTile(final int tileX, final int tileY) { /** Returns the indices of this tile in the {@code TileMatrix}. */ @Override public long[] getIndices() { - return new long[] {offsetX + tileX, offsetY + tileY}; + final long[] indices = baseIndices.clone(); + indices[xDim] = offsetX + tileX; + indices[yDim] = offsetY + tileY; + return indices; } /** Returns information about whether the tile failed to load. */ diff --git a/endorsed/src/org.apache.sis.storage/test/org/apache/sis/storage/tiling/ImageTileMatrixTest.java b/endorsed/src/org.apache.sis.storage/test/org/apache/sis/storage/tiling/ImageTileMatrixTest.java new file mode 100644 index 00000000000..899f80e4b78 --- /dev/null +++ b/endorsed/src/org.apache.sis.storage/test/org/apache/sis/storage/tiling/ImageTileMatrixTest.java @@ -0,0 +1,288 @@ +/* + * 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.tiling; + +import java.awt.image.BufferedImage; +import java.awt.image.ColorModel; +import java.awt.image.Raster; +import java.awt.image.SampleModel; +import java.awt.image.WritableRaster; +import java.util.Arrays; +import java.util.List; +import org.apache.sis.storage.GridCoverageResource; +import org.opengis.metadata.spatial.DimensionNameType; +import org.opengis.util.GenericName; +import org.apache.sis.coverage.SampleDimension; +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.referencing.operation.matrix.Matrices; +import org.apache.sis.referencing.operation.matrix.MatrixSIS; +import org.apache.sis.referencing.operation.transform.MathTransforms; +import org.apache.sis.storage.DataStoreException; +import org.apache.sis.util.iso.Names; + +// 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; + + +/** + * Verify behavior of {@link ImageTileMatrix} in a 3D space. + *
+ * This test creates a {@link MockTiledResource coverage mockup} that contains two 2D slices. + * Each slice contains {@link #NUM_COLS} tile columns and {@link #NUM_ROWS} tile rows. + * Each tile is a single band image whose diagonal is filled with its tile indices. + * + * @author Alexis Manin (Geomatys) + */ +@SuppressWarnings("exports") +public final class ImageTileMatrixTest extends TestCase { + + private static final BufferedImage MODEL = new BufferedImage(4, 4, BufferedImage.TYPE_BYTE_GRAY); + private static final int TILE_WIDTH = 4; + private static final int TILE_HEIGHT = 4; + private static final int NUM_COLS = 3; + private static final int NUM_ROWS = 3; + private static final int NUM_SLICES = 2; + + /** + * Test that a tile from a 3D dataset can return its associated resource. + * This is an anti-regression test because of a bug encountered while fetching tile resource. + */ + @Test + public void testGetIndividualTiles3D() throws DataStoreException { + final var matrix = get3DMockupTileMatrix(); + final var tilingExtent = matrix.getTilingScheme().getExtent(); + assertEquals(3, tilingExtent.getDimension()); + tilingExtent.latticePointStream(true) + .map(tileCoord -> loadTile(tileCoord, matrix)) + .forEach(ImageTileMatrixTest::checkTileContent); + } + + /** + * Check another path to obtain and validate tiles: get a batch of (all in fact) tiles using {@link TileMatrix#getTiles(GridExtent, boolean)} + */ + @Test + public void testGetTileBatch3D() throws DataStoreException { + final var matrix = get3DMockupTileMatrix(); + matrix.getTiles(null, true) + .forEach(ImageTileMatrixTest::checkTileContent); + } + + /** + * Check tile content by querying a full rendering on each slice in the 3D coverage mockup. + * The aim is to ensure that coverage rendering properly returns a tiled image, + * and that each tile in the rendered image gives back its associated Tile content. + * Said otherwise, it checks that there's no tile mismatch or mixup when using ImageIO accessor. + */ + @Test + public void testGetTilesViaRender() throws DataStoreException { + final var resource = new MockTiledResource(); + final var coverage = resource.read((GridGeometry) null); + final var overallExtent = coverage.getGridGeometry().getExtent(); + overallExtent.resize(1, 1).latticePointStream(true) + .forEach(temporalSlice -> { + var sliceHigh = temporalSlice.clone(); + sliceHigh[0] = overallExtent.getHigh(0); + sliceHigh[1] = overallExtent.getHigh(1); + final var renderExtent = new GridExtent(null, temporalSlice, sliceHigh, true); + + final var image = coverage.render(renderExtent); + for (int col = 0; col < NUM_COLS; col++) { + for (int row = 0; row < NUM_ROWS; row++) { + final var tileIndices = temporalSlice.clone(); + tileIndices[0] = col; + tileIndices[1] = row; + final var tileRaster = image.getTile(col, row); + checkTileOrigin(tileRaster, tileIndices); + checkTileRaster(tileRaster, tileIndices); + } + } + }); + } + + private void checkTileOrigin(Raster tileRaster, long[] tileIndices) { + final var expectedTileOrigin = new int[] { + Math.toIntExact(Math.multiplyExact(tileIndices[0], TILE_WIDTH)), + Math.toIntExact(Math.multiplyExact(tileIndices[1], TILE_HEIGHT)) + }; + final var actualTileOrigin = new int[] { tileRaster.getMinX(), tileRaster.getMinY() }; + assertArrayEquals(expectedTileOrigin, actualTileOrigin, + () -> String.format( + "Tile (%s): raster origin does not match rendering location. Expected: (%s) but was (%s)", + Arrays.toString(tileIndices), Arrays.toString(expectedTileOrigin), Arrays.toString(actualTileOrigin) + ) + ); + } + + private TileMatrix get3DMockupTileMatrix() throws DataStoreException { + final var resource = new MockTiledResource(); + final var tileMatrixSets = resource.getTileMatrixSets(); + assertFalse(tileMatrixSets.isEmpty()); + final var tms = tileMatrixSets.iterator().next(); + final var tileMatrixIterator = tms.getTileMatrices().values().iterator(); + assertTrue(tileMatrixIterator.hasNext()); + final var matrix = tileMatrixIterator.next(); + assertFalse(tileMatrixIterator.hasNext()); + return matrix; + } + + /** + * Load a specific tile from the given tile matrix. + * This method expects that tile content will respect this test + */ + private Tile loadTile(long[] requestedTileIndices, TileMatrix matrix) { + try { + final var optTile = matrix.getTile(requestedTileIndices); + assertTrue(optTile.isPresent()); + final var tile = optTile.get(); + final var tIndices = tile.getIndices(); + assertArrayEquals(requestedTileIndices, tIndices, "Tile indices differ from request"); + return tile; + } catch (DataStoreException e) { + throw new AssertionError("Extraction of tile ("+ Arrays.toString(requestedTileIndices)+") failed", e); + } + } + + private static void checkTileContent(Tile tile) { + final var tileIndices = tile.getIndices(); + try { + assertEquals(TileStatus.EXISTS, tile.getStatus()); + final var tResource = tile.getResource(); + assertNotNull(tResource); + if (!(tResource instanceof GridCoverageResource tileGridResource)) { + throw new AssertionError("Tile resource is not a grid resource"); + } + final var tileImage = tileGridResource.read(null).render(null); + assertEquals(TILE_WIDTH, tileImage.getWidth()); + assertEquals(TILE_HEIGHT, tileImage.getHeight()); + + assertEquals(1, tileImage.getNumXTiles() * tileImage.getNumYTiles(), + "Tile image should contain only a single raster tile."); + final var tileRaster = tileImage.getTile(tileImage.getMinTileX(), tileImage.getMinTileY()); + checkTileRaster(tileRaster, tileIndices); + } catch (DataStoreException e) { + fail("Validation of tile ("+ Arrays.toString(tileIndices)+") failed", e); + } + } + + private static void checkTileRaster(Raster tileRaster, long[] tileIndices) { + for (int i=0 ; i < tileIndices.length ; i++) { + final var index = i; + assertEquals(tileIndices[i], tileRaster.getSample(tileRaster.getMinX() + i, tileRaster.getMinY() + i, 0), + () -> String.format("Tile sample at coordinate (%1$d, %1$d) should be the tile coordinate at dimension %1$d", index)); + } + } + + /** + * A minimal {@link TiledGridCoverageResource} for testing. + */ + private static final class MockTiledResource extends TiledGridCoverageResource { + + private final GridGeometry gridGeometry; + private final List sampleDimensions; + + MockTiledResource() { + super(null); + final var extent = new GridExtent( + new DimensionNameType[] { + DimensionNameType.COLUMN, + DimensionNameType.ROW, + DimensionNameType.VERTICAL + }, + new long[3], + new long[] { + NUM_COLS * TILE_WIDTH - 1, + NUM_ROWS * TILE_HEIGHT - 1, + NUM_SLICES - 1 + }, + true); + + final int dimension = 3; + final MatrixSIS gridToCRS = Matrices.createIdentity(dimension + 1); + gridToCRS.setNumber(0, 0, 0.5); + gridToCRS.setNumber(1, 1, 0.5); + gridToCRS.setNumber(2, 2, 100); + gridGeometry = new GridGeometry(extent, PixelInCell.CELL_CORNER, + MathTransforms.linear(gridToCRS), HardCodedCRS.WGS84_3D); + + sampleDimensions = List.of(new SampleDimension.Builder().setName("data").build()); + } + + @Override + public GridGeometry getGridGeometry() { + return gridGeometry; + } + + @Override + public List getSampleDimensions() { + return sampleDimensions; + } + + @Override + protected int[] getTileSize() { + return new int[] {TILE_WIDTH, TILE_HEIGHT, 1}; + } + + @Override + protected ColorModel getColorModel(int[] bands) { + return MODEL.getColorModel(); + } + + @Override + protected SampleModel getSampleModel(int[] bands) { + return MODEL.getSampleModel(); + } + + @Override + protected TiledGridCoverage read(Subset subset) { + return new MockTiledCoverage(subset); + } + } + + /** + * A minimal {@link TiledGridCoverage} that creates empty rasters on demand. + */ + private static final class MockTiledCoverage extends TiledGridCoverage { + + MockTiledCoverage(TiledGridCoverageResource.Subset subset) { + super(subset); + } + + @Override + protected GenericName getIdentifier() { + return Names.createLocalName(null, null, "test"); + } + + @Override + protected Raster[] readTiles(TileIterator iterator) { + final Raster[] tiles = new Raster[iterator.tileCountInQuery]; + do { + final WritableRaster raster = iterator.createRaster(); + final var tileCoords = iterator.getTileCoordinatesInResource(); + for (int i = 0; i < tileCoords.length; i++) { + raster.setSample(raster.getMinX() + i, raster.getMinY() + i, 0, tileCoords[i]); + } + tiles[iterator.getTileIndexInResultArray()] = raster; + } while (iterator.next()); + return tiles; + } + } +} \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index a4413138c96..aaaabb3cb9f 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.8-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.4-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME