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);
+        }
+    }
 }


Reply via email to