This is an automated email from the ASF dual-hosted git repository. asf-gitbox-commits pushed a commit to branch geoapi-4.0 in repository https://gitbox.apache.org/repos/asf/sis.git
commit ca60298d0caedd58e3382fe49b423edfb8e082be Author: eidee <[email protected]> AuthorDate: Wed Jun 10 17:55:28 2026 +0200 Add support for writing pyramided GeoTIFF. This is an important step toward a COG writer, but not yet fully COG compliant. Co-authored-by: Martin Desruisseaux <[email protected]> --- .../main/org/apache/sis/image/ImageProcessor.java | 34 ++- .../main/org/apache/sis/image/OverviewImage.java | 313 +++++++++++++++++++++ .../org/apache/sis/image/OverviewImageTest.java | 138 +++++++++ .../test/org/apache/sis/image/TiledImageMock.java | 17 ++ .../apache/sis/storage/geotiff/FormatModifier.java | 19 +- .../apache/sis/storage/geotiff/GeoTiffStore.java | 22 +- .../org/apache/sis/storage/geotiff/Writer.java | 31 +- .../sis/storage/geotiff/GeoTiffStoreTest.java | 78 +++++ 8 files changed, 639 insertions(+), 13 deletions(-) diff --git a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/ImageProcessor.java b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/ImageProcessor.java index 2d4391ebb9..c72874dbfe 100644 --- a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/ImageProcessor.java +++ b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/ImageProcessor.java @@ -136,7 +136,7 @@ import org.apache.sis.measure.Units; * * @author Martin Desruisseaux (Geomatys) * @author Alexis Manin (Geomatys) - * @version 1.6 + * @version 1.7 * * @see org.apache.sis.coverage.grid.GridCoverageProcessor * @@ -1234,6 +1234,38 @@ public class ImageProcessor implements Cloneable { return RecoloredImage.applySameColors(resampled, colored); } + /** + * Creates an overview of the given image computed by the average of 4 pixels. + * NaN values are omitted from the average. If all values in a block of 4 pixels are NaN, + * then the first value (the upper-left corner of the 4 pixels block) is retained. + * + * <p>This overview is equivalent to the {@link #resample(RenderedImage, Rectangle, MathTransform) resample(…)} + * operation with a bilinear interpolation and the following transform, except that an overview can be created + * from the value of another overview, thus creating a pyramid. By contrast, the {@code resample(…)} operation + * may optimize with {@link MathTransform} concatenations, which is undesirable when creating a pyramid:</p> + * + * {@snippet lang="text" : + * ┌ ┐ + * │ 2.0 0 0.5 │ + * │ 0 2.0 0.5 │ + * │ 0 0 1 │ + * └ ┘ + * } + * + * <h4>Result relationship with source</h4> + * Changes in the source image are reflected in the returned images + * if the source image notifies {@linkplain java.awt.image.TileObserver tile observers}. + * + * @param source the image for which to compute an overview. + * @return image overview. + * + * @since 1.7 + */ + public RenderedImage overview(final RenderedImage source) { + ArgumentChecks.ensureNonNull("source", source); + return unique(new OverviewImage(source)); + } + /** * Computes immediately all tiles in the given region of interest, then returns an image with those tiles ready. * Computations will use many threads if {@linkplain #getExecutionMode() execution mode} is parallel. diff --git a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/OverviewImage.java b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/OverviewImage.java new file mode 100644 index 0000000000..1c381c7e96 --- /dev/null +++ b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/OverviewImage.java @@ -0,0 +1,313 @@ +/* + * 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.image; + +import java.awt.Rectangle; +import java.awt.image.ColorModel; +import java.awt.image.Raster; +import java.awt.image.WritableRaster; +import java.awt.image.RenderedImage; +import java.awt.image.ImagingOpException; +import org.apache.sis.feature.internal.Resources; +import org.apache.sis.util.Disposable; +import org.apache.sis.util.internal.shared.Numerics; +import org.apache.sis.image.internal.shared.ImageUtilities; + +// Specific to the geoapi-3.1 and geoapi-4.0 branches: +import org.opengis.coverage.grid.SequenceType; + + +/** + * An image which is the result of averaging 4 pixels of the image at higher resolution. + * It can be seen as a special case of {@link ResampledImage} with bilinear interpolation + * at the exact center of a block of 4 pixels. + * + * @todo Add an auxiliary image with contains the rest of the division by 4 (when sample values are integers) + * or the number of averaged sample values (when sample values are floating points). + * Use that information when computing overview of overviews, for better accuracy. + * + * @author Estelle Idée (Geomatys) + * @author Martin Desruisseaux (Geomatys) + */ +final class OverviewImage extends ComputedImage { + /** + * The image origin. + */ + private final int minX, minY; + + /** + * The image size in pixels. + */ + private final int width, height; + + /** + * The offset to add after conversion from target to source pixel coordinates. + * This is either 0 or 1. + */ + private final byte offsetX, offsetY; + + /** + * Creates a new image which will create an overview of the given image. + * + * @param source the image at higher resolution. + */ + OverviewImage(final RenderedImage source) { + this(targetBounds(ImageUtilities.getBounds(source)), source); + } + + /** Workaround for RFE #4093999 ("Relax constraint on placement of this()/super() call in constructors"). */ + private static Rectangle targetBounds(final Rectangle bounds) { + bounds.x >>= 1; // Round toward negative infinity. + bounds.y >>= 1; + bounds.width /= 2; // Round toward 0. + bounds.height /= 2; + return bounds; + } + + /** Workaround for RFE #4093999 ("Relax constraint on placement of this()/super() call in constructors"). */ + private OverviewImage(final Rectangle bounds, final RenderedImage source) { + super(ImageLayout.DEFAULT.createCompatibleSampleModel(source, bounds), source); + offsetX = (byte) (source.getMinX() & 1); // TODO: move before `targetBounds(…)` after RFE #4093999. + offsetY = (byte) (source.getMinY() & 1); + minX = bounds.x; + minY = bounds.y; + width = bounds.width; + height = bounds.height; + } + + /** + * Returns the color model of this resampled image. + * Default implementation assumes that this image has the same color model as the source image. + * + * @return the color model, or {@code null} if unspecified. + */ + @Override + public ColorModel getColorModel() { + return getSource().getColorModel(); + } + + /** + * Returns the minimum tile index in the <var>x</var> direction. + */ + @Override + public final int getMinTileX() { + return getSource().getMinTileX() / 2; // Round toward zero. + } + + /** + * Returns the minimum tile index in the <var>y</var> direction. + */ + @Override + public final int getMinTileY() { + return getSource().getMinTileY() / 2; + } + + /** + * Returns the minimum <var>x</var> coordinate (inclusive) of this image. + */ + @Override + public final int getMinX() { + return minX; + } + + /** + * Returns the minimum <var>y</var> coordinate (inclusive) of this image. + */ + @Override + public final int getMinY() { + return minY; + } + + /** + * Returns the number of columns in this image. + */ + @Override + public final int getWidth() { + return width; + } + + /** + * Returns the number of rows in this image. + */ + @Override + public final int getHeight() { + return height; + } + + /** + * Invoked when a tile needs to be computed or updated. + * + * @param tileX the column index of the tile to compute. + * @param tileY the row index of the tile to compute. + * @param tile if the tile already exists but needs to be updated, the tile to update. Otherwise {@code null}. + * @return computed tile for the given indices. + */ + @Override + protected Raster computeTile(final int tileX, final int tileY, WritableRaster tile) { + if (tile == null) { + tile = createTile(tileX, tileY); + } + Rectangle bounds = tile.getBounds(); + bounds.width <<= 1; + bounds.height <<= 1; + bounds.x <<= 1; + bounds.y <<= 1; + bounds.x += offsetX; + bounds.y += offsetY; + final PixelIterator it = new PixelIterator.Builder() + .setIteratorOrder(SequenceType.LINEAR) + .setRegionOfInterest(bounds) + .create(getSource()); + /* + * The iterator may have intersected the given bounds with the source image bounds. + * Therefore, we derive the limits from these bounds instead of from tile bounds. + * It should cover the whole valid area of the tile. + */ + bounds = it.getDomain(); + if (((bounds.width | bounds.height) & 1) != 0) { + throw new ImagingOpException(Resources.format(Resources.Keys.IncompatibleTile_2, tileX, tileY)); + } + bounds.x >>= 1; // Round toward negative infinity. + bounds.y >>= 1; + bounds.width >>= 1; + bounds.height >>= 1; + final int numBands = tile.getNumBands(); + final var buffer = new double[Math.multiplyExact(bounds.width, numBands)]; + final var counts = new byte[buffer.length]; + double[] left = null; + double[] right = null; + final int ymax = bounds.y + bounds.height; + for (int y = bounds.y; y < ymax; y++) { + int x = bounds.x; + /* + * Memorize the sum of two consecutive pixels for all pixels in the current source row. + * The `counts` array contains the number of valid values, which will by 2, 3 or 4 on + * the assumption that the next row will not contain NaN value (verified in next loop). + */ + for (int i=0; i < buffer.length;) { + if (it.next()) { + left = it.getPixel(left); + if (it.next()) { + right = it.getPixel(right); + for (int b=0; b<numBands; b++) { + byte count = 4; + double sum = left[b] + right[b]; + if (Double.isNaN(sum)) { + // Give precedence to the left side if both sides are NaN. + count = (Double.isNaN(sum = right[b]) && + Double.isNaN(sum = left[b])) ? (byte) 2 : (byte) 3; + } + buffer[i] = sum; + counts[i++] = count; + } + continue; + } + } + throw new ImagingOpException(Resources.format(Resources.Keys.OutOfIteratorDomain_2, i/numBands + x, y)); + } + /* + * Read the next row and compute the average with the previous row which was memorized by above loop. + * If some values are NaN, the number of valid values gien by `counts` is adjusted. + */ + for (int i=0; i < buffer.length; x++) { + if (it.next()) { + left = it.getPixel(left); + if (it.next()) { + right = it.getPixel(right); + for (int b=0; b<numBands; b++, i++) { + int count = counts[i]; + double add = left[b] + right[b]; + double sum = add + buffer[i]; + // Test `isNaN(sum)` first because it will be false in the vast majority of cases. + if (Double.isNaN(sum) && Double.isNaN(sum = add)) { + sum = buffer[i]; + if (Double.isNaN(add = right[b]) && Double.isNaN(add = left[b])) { + count -= 2; // The two values of the current row are invalid. + } else { + count--; // Exactly one value of the current row is valid. + sum = Double.isNaN(sum) ? add : sum + add; + } + if (count <= 1) { + // Avoid a division by 0 in order to preserve the NaN bits pattern. + left[b] = sum; + continue; + } + } + left[b] = sum / count; + } + tile.setPixel(x, y, left); + continue; + } + } + throw new ImagingOpException(Resources.format(Resources.Keys.OutOfIteratorDomain_2, x, y)); + } + } + return tile; + } + + /** + * Notifies the source image that tiles will be computed soon in the given region. + * If the source image is an instance of {@link ComputedImage}, then this method + * forwards the notification to it. + */ + @Override + protected Disposable prefetch(final Rectangle tiles) { + final RenderedImage source = getSource(); + if (source instanceof PlanarImage) { + final long xmin = 2L * tiles.x + offsetX; + final long ymin = 2L * tiles.y + offsetY; + final long xmax = 2L * tiles.width + xmin; + final long ymax = 2L * tiles.height + ymin; + final int x = Numerics.clamp(xmin); + final int y = Numerics.clamp(ymin); + return ((PlanarImage) source).prefetch( + new Rectangle(x, y, Numerics.clamp(xmax - x), + Numerics.clamp(ymax - y))); + } + return super.prefetch(tiles); + } + + /** + * Compares the given object with this image for equality. + * + * @param object the object to compare with this image. + * @return {@code true} if the given object is an image performing the same overview as this image. + */ + @Override + public boolean equals(final Object object) { + if (equalsBase(object)) { + final var other = (OverviewImage) object; + return minX == other.minX && + minY == other.minY && + width == other.width && + height == other.height && + offsetX == other.offsetX && + offsetY == other.offsetY; + } + return false; + } + + /** + * Returns a hash code value for this image. The {@link #minX}, {@link #minY}, {@link #width} and {@link #height} + * fields are included in the hash computation as a matter of principle, but this is actually not very important + * because they are derived information. + */ + @Override + public int hashCode() { + return hashCodeBase() + (minX + 31*(minY + 31*(width + 31*height))); + } +} diff --git a/endorsed/src/org.apache.sis.feature/test/org/apache/sis/image/OverviewImageTest.java b/endorsed/src/org.apache.sis.feature/test/org/apache/sis/image/OverviewImageTest.java new file mode 100644 index 0000000000..e67639cbd0 --- /dev/null +++ b/endorsed/src/org.apache.sis.feature/test/org/apache/sis/image/OverviewImageTest.java @@ -0,0 +1,138 @@ +/* + * 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.image; + +import java.awt.Point; +import java.awt.image.RenderedImage; +import java.util.Random; + +// Specific to the geoapi-3.1 and geoapi-4.0 branches: +import org.opengis.coverage.grid.SequenceType; + +// 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.test.TestUtilities; + + +/** + * Tests {@link OverviewImage}. + * + * @author Estelle Idée (Geomatys) + */ +@SuppressWarnings("exports") +public final class OverviewImageTest extends TestCase { + /** + * Creates a new test case. + */ + public OverviewImageTest() { + } + + /** + * Tests on an image filled with integer values. + */ + @Test + public void testOnIntegers() { + testForType(DataType.INT); + } + + /** + * Tests on an image filled with floating point values. + * Some random values are set to NaN. + */ + @Test + public void testOnFloats() { + testForType(DataType.DOUBLE); + } + + /** + * Runs the test on an image of the specified type. + * + * @param type type of data stored in the image. + */ + private static void testForType(final DataType type) { + final Random r = TestUtilities.createRandomNumberGenerator(); + final var source = new TiledImageMock( + type.toDataBufferType(), + r.nextInt( 2) + 1, // num bands + r.nextInt( 9) - 4, // min X + r.nextInt( 9) - 4, // min Y + r.nextInt(20) + 10, // width + r.nextInt(20) + 10, // height + r.nextInt( 5) + 5, // tile width + r.nextInt( 5) + 5, // tile height + r.nextInt( 9) - 4, // min tile X + r.nextInt( 9) - 4, // min tile Y + true); // banded + + source.initializeAllTiles(); + if (!type.isInteger()) { + source.setRandomNaN(r); + } + verify(source, new OverviewImage(source), type.isInteger()); + } + + /** + * Verifies an image which is expected to be the result of an image overview operation. + * + * @param source the image used for computing the overview. + * @param target the result of the image overview operation. + * @param isInteger whether the images use an integer type. + */ + public static void verify(final RenderedImage source, final RenderedImage target, final boolean isInteger) { + assertEquals(source.getWidth() / 2, target.getWidth()); + assertEquals(source.getHeight() / 2, target.getHeight()); + final int offsetX = source.getMinX() & 1; + final int offsetY = source.getMinY() & 1; + + double[] p00 = null, p01 = null, p10 = null, p11 = null; + double[] actual = null; + + final PixelIterator itSource = new PixelIterator.Builder().setIteratorOrder(SequenceType.LINEAR).create(source); + final PixelIterator itTarget = PixelIterator.create(target); + int count = 0; + while (itTarget.next()) { + final Point p = itTarget.getPosition(); + final int sx = p.x * 2 + offsetX; + final int sy = p.y * 2 + offsetY; + + // Read 2×2 block from source. + itSource.moveTo(sx, sy); p00 = itSource.getPixel(p00); + assertTrue(itSource.next()); p01 = itSource.getPixel(p01); + itSource.moveTo(sx, sy + 1); p10 = itSource.getPixel(p10); + assertTrue(itSource.next()); p11 = itSource.getPixel(p11); + + actual = itTarget.getPixel(actual); + for (int b = 0; b < actual.length; b++) { + int n = 0; + double sum = 0, v; + if (!Double.isNaN(v = p00[b])) {sum += v; n++;} + if (!Double.isNaN(v = p01[b])) {sum += v; n++;} + if (!Double.isNaN(v = p10[b])) {sum += v; n++;} + if (!Double.isNaN(v = p11[b])) {sum += v; n++;} + double expected = (n != 0) ? sum / n : Double.NaN; + if (isInteger) { + expected = (int) expected; + } + assertEquals(expected, actual[b], 1E-10, () -> "Mismatch at (" + p.x + ", " + p.y + ')'); + } + count++; + } + assertEquals(target.getWidth() * target.getHeight(), count); + } +} diff --git a/endorsed/src/org.apache.sis.feature/test/org/apache/sis/image/TiledImageMock.java b/endorsed/src/org.apache.sis.feature/test/org/apache/sis/image/TiledImageMock.java index 1db6fcd075..70989ebf3b 100644 --- a/endorsed/src/org.apache.sis.feature/test/org/apache/sis/image/TiledImageMock.java +++ b/endorsed/src/org.apache.sis.feature/test/org/apache/sis/image/TiledImageMock.java @@ -262,6 +262,23 @@ public final class TiledImageMock extends PlanarImage implements WritableRendere } } + /** + * Sets random values to NaN. This method assumes that the image use floating points. + * + * @param random random number generator to use. + */ + public synchronized void setRandomNaN(final Random random) { + final int numBands = sampleModel.getNumBands(); + for (int i = random.nextInt(StrictMath.max(StrictMath.multiplyExact(width, height) / 8, 4)) + 5; --i >= 0;) { + final int ox = random.nextInt(width); + final int oy = random.nextInt(height); + final int b = random.nextInt(numBands); + tile(StrictMath.floorDiv(ox, tileWidth) + minTileX, + StrictMath.floorDiv(oy, tileHeight) + minTileY, true) + .setSample(ox + minX, oy + minY, b, Double.NaN); + } + } + /** * Sets a sample value at the given location in pixel coordinates. * This is a helper method for testing purpose on small images only, diff --git a/endorsed/src/org.apache.sis.storage.geotiff/main/org/apache/sis/storage/geotiff/FormatModifier.java b/endorsed/src/org.apache.sis.storage.geotiff/main/org/apache/sis/storage/geotiff/FormatModifier.java index 23e2dbfe91..612327917b 100644 --- a/endorsed/src/org.apache.sis.storage.geotiff/main/org/apache/sis/storage/geotiff/FormatModifier.java +++ b/endorsed/src/org.apache.sis.storage.geotiff/main/org/apache/sis/storage/geotiff/FormatModifier.java @@ -23,7 +23,8 @@ import org.apache.sis.io.stream.InternalOptionKey; /** * Characteristics of the GeoTIFF file to write. - * The modifiers can control, for example, the maximal size and number of images that can be stored in a TIFF file. + * The modifiers can control, for example, the maximal size and number of images + * that can be stored in a <abbr>TIFF</abbr> file. * * <p>The modifiers can be specified as an option when opening the data store. * For example for writing a BigTIFF file, the following code can be used:</p> @@ -39,7 +40,8 @@ import org.apache.sis.io.stream.InternalOptionKey; * } * * @author Martin Desruisseaux (Geomatys) - * @version 1.5 + * @author Estelle Idée (Geomatys) + * @version 1.7 * * @see GeoTiffStore#getModifiers() * @@ -47,7 +49,7 @@ import org.apache.sis.io.stream.InternalOptionKey; */ public enum FormatModifier { /** - * The Big TIFF extension (non-standard). + * The Big <abbr>TIFF</abbr> extension (non-standard). * When this modifier is absent (which is the default), the standard TIFF format as defined by Adobe is used. * That standard uses the addressable space of 32-bits integers, which allows a maximal file size of about 4 GB. * When the {@code BIG_TIFF} modifier is present, the addressable space of 64-bits integers is used. @@ -55,6 +57,17 @@ public enum FormatModifier { */ BIG_TIFF, + /** + * A pyramided GeoTIFF format in which overviews are generated automatically from the base image. + * This is almost the Cloud Optimized GeoTIFF (<abbr>COG</abbr>) format, except that the overviews + * are written in unspecified order, not in the order mandated by the <abbr>COG</abbr> conventions. + * This flexibility makes possible to write the <abbr>TIFF</abbr> file directly, + * with no need to write in a temporary file and reorder the images at the end. + * + * @since 1.7 + */ + PYRAMIDED, + // TODO: COG, SPARSE. /** diff --git a/endorsed/src/org.apache.sis.storage.geotiff/main/org/apache/sis/storage/geotiff/GeoTiffStore.java b/endorsed/src/org.apache.sis.storage.geotiff/main/org/apache/sis/storage/geotiff/GeoTiffStore.java index e7b4c4dd79..97657c40af 100644 --- a/endorsed/src/org.apache.sis.storage.geotiff/main/org/apache/sis/storage/geotiff/GeoTiffStore.java +++ b/endorsed/src/org.apache.sis.storage.geotiff/main/org/apache/sis/storage/geotiff/GeoTiffStore.java @@ -82,6 +82,7 @@ import org.apache.sis.util.resources.Errors; * @author Martin Desruisseaux (Geomatys) * @author Thi Phuong Hao Nguyen (VNSC) * @author Alexis Manin (Geomatys) + * @author Estelle Idée (Geomatys) * @version 1.7 * @since 0.8 */ @@ -690,7 +691,8 @@ public class GeoTiffStore extends DataStore implements Aggregate { /** * Encodes the given image in the GeoTIFF file. * The image is appended after any existing images in the GeoTIFF file. - * This method does not handle pyramids such as Cloud Optimized GeoTIFF (COG). + * If the {@link FormatModifier#PYRAMIDED} was given at construction time, + * then overviews are automatically written. * * @param image the image to encode. * @param grid mapping from pixel coordinates to "real world" coordinates, or {@code null} if none. @@ -703,7 +705,7 @@ public class GeoTiffStore extends DataStore implements Aggregate { * @since 1.5 */ @SuppressWarnings("LocalVariableHidesMemberVariable") - public synchronized GridCoverageResource append(final RenderedImage image, final GridGeometry grid, final Metadata metadata) + public synchronized GridCoverageResource append(RenderedImage image, final GridGeometry grid, final Metadata metadata) throws DataStoreException { final int index; @@ -711,9 +713,18 @@ public class GeoTiffStore extends DataStore implements Aggregate { final Reader reader = this.reader; final Writer writer = writer(); writer.synchronize(reader, false); - final long offsetIFD; + long offsetIFD; try { - offsetIFD = writer.append(image, grid, metadata); + offsetIFD = writer.append(image, grid, metadata, false); + if (writer.isPyramided) { + while (image.getWidth() > Writer.OVERVIEW_SIZE || + image.getHeight() > Writer.OVERVIEW_SIZE) + { + image = writer.processor().overview(image); + offsetIFD = writer.append(image, null, null, true); + // Grid and metadata are null as we don't want to repeat metadata in overviews. + } + } } finally { writer.synchronize(reader, true); } @@ -755,7 +766,8 @@ public class GeoTiffStore extends DataStore implements Aggregate { /** * Adds a new grid coverage in the GeoTIFF file. * The coverage is appended after any existing images in the GeoTIFF file. - * This method does not handle pyramids such as Cloud Optimized GeoTIFF (COG). + * If the {@link FormatModifier#PYRAMIDED} was given at construction time, + * then overviews are automatically written. * * @param coverage the grid coverage to encode. * @param metadata title, author and other information, or {@code null} if none. diff --git a/endorsed/src/org.apache.sis.storage.geotiff/main/org/apache/sis/storage/geotiff/Writer.java b/endorsed/src/org.apache.sis.storage.geotiff/main/org/apache/sis/storage/geotiff/Writer.java index be885bc0f8..9e7022f300 100644 --- a/endorsed/src/org.apache.sis.storage.geotiff/main/org/apache/sis/storage/geotiff/Writer.java +++ b/endorsed/src/org.apache.sis.storage.geotiff/main/org/apache/sis/storage/geotiff/Writer.java @@ -26,6 +26,7 @@ import java.util.List; import java.util.Deque; import java.util.Queue; import java.util.Set; +import java.util.EnumSet; import java.awt.image.RenderedImage; import java.awt.image.SampleModel; import java.awt.image.BandedSampleModel; @@ -39,6 +40,7 @@ import org.opengis.util.FactoryException; import org.opengis.metadata.Metadata; import org.opengis.referencing.operation.TransformException; import org.apache.sis.image.ImageProcessor; +import org.apache.sis.image.ImageLayout; import org.apache.sis.image.DataType; import org.apache.sis.coverage.grid.GridGeometry; import org.apache.sis.coverage.grid.IncompleteGridGeometryException; @@ -78,8 +80,15 @@ import org.opengis.coverage.CannotEvaluateException; * * @author Erwan Roussel (Geomatys) * @author Martin Desruisseaux (Geomatys) + * @author Estelle Idée (Geomatys) */ final class Writer extends IOBase implements Flushable { + /** + * Maximal size of the highest overview. + * Used as a criteria for deciding when to stop creating overviews in a pyramided file. + */ + static final int OVERVIEW_SIZE = ImageLayout.DEFAULT_TILE_SIZE; + /** * BigTIFF code for unsigned 64-bits integer type. * @@ -138,6 +147,14 @@ final class Writer extends IOBase implements Flushable { */ private final boolean isBigTIFF; + /** + * Whether to write a pyramid of overviews. This is one requirement of Cloud Optimized GeoTIFF <abbr>COG</abbr>, + * but not sufficient for claiming compliance because this option does not force <abbr>COG</abbr> image order. + * + * @see #getFormat() + */ + final boolean isPyramided; + /** * Whether to disable the <abbr>TIFF</abbr> requirement that tile sizes are multiple of 16 pixels. */ @@ -192,6 +209,7 @@ final class Writer extends IOBase implements Flushable { super(store); this.output = output; isBigTIFF = ArraysExt.contains(options, FormatModifier.BIG_TIFF); + isPyramided = ArraysExt.contains(options, FormatModifier.PYRAMIDED); anyTileSize = ArraysExt.contains(options, FormatModifier.ANY_TILE_SIZE); /* * Write the TIFF file header before first IFD. Stream position matter and must start at zero. @@ -223,6 +241,7 @@ final class Writer extends IOBase implements Flushable { Writer(final Reader reader) throws IOException, DataStoreException { super(reader.store); isBigTIFF = (reader.intSizeExpansion != 0); + isPyramided = false; anyTileSize = false; try { output = new ChannelDataOutput(reader.input); @@ -266,14 +285,17 @@ final class Writer extends IOBase implements Flushable { */ @Override public final Set<FormatModifier> getModifiers() { - return isBigTIFF ? Set.of(FormatModifier.BIG_TIFF) : Set.of(); + final var modifiers = EnumSet.noneOf(FormatModifier.class); + if (isBigTIFF) modifiers.add(FormatModifier.BIG_TIFF); + if (isPyramided) modifiers.add(FormatModifier.PYRAMIDED); + return modifiers; } /** * Returns the processor to use for reformatting the image before to write it. * The processor is created only when this method is first invoked. */ - private ImageProcessor processor() { + final ImageProcessor processor() { if (processor == null) { processor = new ImageProcessor(); } @@ -289,6 +311,7 @@ final class Writer extends IOBase implements Flushable { * @param image the image to encode. * @param grid mapping from pixel coordinates to "real world" coordinates, or {@code null} if none. * @param metadata title, author and other information, or {@code null} if none. + * @param overview whether the image is an overview of another image. * @return offset in {@link #output} where the Image File Directory (IFD) starts. * @throws RasterFormatException if the raster uses an unsupported sample model. * @throws ArithmeticException if an integer overflow occurs. @@ -297,7 +320,7 @@ final class Writer extends IOBase implements Flushable { * which is not supported by TIFF specification or by this writer. */ @SuppressWarnings("UseSpecificCatch") - public final long append(final RenderedImage image, final GridGeometry grid, final Metadata metadata) + public final long append(final RenderedImage image, final GridGeometry grid, final Metadata metadata, final boolean overview) throws IOException, DataStoreException { final var exportable = new ReformattedImage(image, this::processor, anyTileSize); @@ -321,7 +344,7 @@ final class Writer extends IOBase implements Flushable { try { final TileMatrix tiles; try { - tiles = writeImageFileDirectory(exportable, grid, metadata, false); + tiles = writeImageFileDirectory(exportable, grid, metadata, overview); } finally { largeTagData.clear(); // For making sure that there is no memory retention. } diff --git a/endorsed/src/org.apache.sis.storage.geotiff/test/org/apache/sis/storage/geotiff/GeoTiffStoreTest.java b/endorsed/src/org.apache.sis.storage.geotiff/test/org/apache/sis/storage/geotiff/GeoTiffStoreTest.java index 5d495e9544..2b5804e8a5 100644 --- a/endorsed/src/org.apache.sis.storage.geotiff/test/org/apache/sis/storage/geotiff/GeoTiffStoreTest.java +++ b/endorsed/src/org.apache.sis.storage.geotiff/test/org/apache/sis/storage/geotiff/GeoTiffStoreTest.java @@ -16,23 +16,31 @@ */ package org.apache.sis.storage.geotiff; +import java.util.Random; +import java.util.Iterator; import java.io.IOException; import java.io.InputStream; import java.io.ByteArrayOutputStream; import java.nio.file.Path; import java.nio.file.Files; +import java.nio.file.StandardOpenOption; import java.awt.Dimension; import java.awt.Rectangle; import java.awt.image.BufferedImage; import java.awt.image.RenderedImage; +import javax.imageio.ImageIO; +import javax.imageio.ImageReader; +import javax.imageio.stream.ImageInputStream; import org.opengis.referencing.cs.AxisDirection; import org.opengis.referencing.operation.TransformException; import org.apache.sis.geometry.Envelopes; import org.apache.sis.geometry.GeneralEnvelope; import org.apache.sis.image.DataType; +import org.apache.sis.storage.OptionKey; import org.apache.sis.storage.DataStore; import org.apache.sis.storage.DataStores; import org.apache.sis.storage.DataStoreException; +import org.apache.sis.storage.StorageConnector; import org.apache.sis.storage.GridCoverageResource; import org.apache.sis.coverage.grid.GridExtent; import org.apache.sis.coverage.grid.GridGeometry; @@ -48,7 +56,9 @@ import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.*; import static org.apache.sis.test.Assertions.assertSingleton; import static org.apache.sis.feature.Assertions.assertGridToCornerEquals; +import org.apache.sis.image.OverviewImageTest; import org.apache.sis.test.TestCase; +import org.apache.sis.test.TestUtilities; import org.apache.sis.referencing.crs.HardCodedCRS; import org.apache.sis.referencing.operation.HardCodedConversions; @@ -61,6 +71,7 @@ import static org.opengis.test.Assertions.assertAxisDirectionsEqual; * This class tests indirectly (via {@link GeoTiffStore}) the {@link Reader} and {@link Writer} classes. * * @author Martin Desruisseaux (Geomatys) + * @author Estelle Idée (Geomatys) */ @SuppressWarnings("exports") public final class GeoTiffStoreTest extends TestCase { @@ -190,4 +201,71 @@ public final class GeoTiffStoreTest extends TestCase { assertArrayEquals(expected, actual); assertEquals(length, actual.length); } + + /** + * Tests writing a pyramided image. + * + * @throws Exception if an error occurred while preparing or running the test. + */ + @Test + public void testPyramided() throws Exception { + final Random r = TestUtilities.createRandomNumberGenerator(); + final int width = r.nextInt(2 * Writer.OVERVIEW_SIZE) + 3 * Writer.OVERVIEW_SIZE; + final int height = r.nextInt(2 * Writer.OVERVIEW_SIZE) + 3 * Writer.OVERVIEW_SIZE; + final var area = new GeneralEnvelope(HardCodedCRS.WGS84); + area.setRange(0, 132, 145); // Range of longitude values. + area.setRange(1, 30, 42); // Range of latitude values. + final GridCoverage coverage = new GridCoverageBuilder() + .setDomain(Envelopes.transform(area, HardCodedCRS.WGS84)) + .setValues(DataType.BYTE, new Rectangle(width, height), null, (x, y) -> 100 * y + x) + .flipGridAxis(1) + .build(); + + final Path path = Files.createTempFile("pyramided", ".tiff"); + try { + final var connector = new StorageConnector(path); + connector.setOption(FormatModifier.OPTION_KEY, new FormatModifier[] { + FormatModifier.PYRAMIDED + }); + connector.setOption(OptionKey.OPEN_OPTIONS, new StandardOpenOption[] { + StandardOpenOption.CREATE, + StandardOpenOption.WRITE + }); + try (var store = new GeoTiffStore(null, connector)) { + assertNotNull(store.append(coverage, null)); + } + /* + * Try to read the image using the standard TIFF reader, which is used as a reference implementation. + * We expect at least one implementation. If there is more implementations, test will all of them. + */ + final Iterator<ImageReader> imageReaders = ImageIO.getImageReadersByFormatName("TIFF"); + assertTrue(imageReaders.hasNext()); + do { + final ImageReader reader = imageReaders.next(); + try (ImageInputStream input = ImageIO.createImageInputStream(path.toFile())) { + reader.setInput(input); + RenderedImage image = reader.read(0); + assertEquals(width, image.getWidth()); + assertEquals(height, image.getHeight()); + int imageIndex = 1; + while (image.getWidth() > Writer.OVERVIEW_SIZE || image.getHeight() > Writer.OVERVIEW_SIZE) { + final RenderedImage overview = reader.read(imageIndex); + OverviewImageTest.verify(image, overview, true); + image = overview; + imageIndex++; + } + try { + reader.read(imageIndex); + fail("Expected no more images."); + } catch (IndexOutOfBoundsException e) { + // Ignore expected exception. + } + assertTrue(imageIndex > 1); // Expect at least one overview. + } + reader.dispose(); + } while (imageReaders.hasNext()); + } finally { + Files.delete(path); + } + } }
