You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@sedona.apache.org by ji...@apache.org on 2026/02/19 10:15:13 UTC
(sedona) 03/03: Add CogOptions builder for configurable COG generation
This is an automated email from the ASF dual-hosted git repository.
jiayu pushed a commit to branch feature/cog-writer
in repository https://gitbox.apache.org/repos/asf/sedona.git
commit b31f4de563f996e6c1e7c0d06188aa56c2382fef
Author: Jia Yu <ji...@apache.org>
AuthorDate: Thu Feb 19 02:11:46 2026 -0800
Add CogOptions builder for configurable COG generation
- CogOptions: immutable builder with compression, compressionQuality,
tileSize, resampling (Nearest/Bilinear/Bicubic), and overviewCount
- CogWriter: new write(raster, CogOptions) primary method; existing
overloads delegate to it; generateOverview accepts Interpolation param
- RasterOutputs: new asCloudOptimizedGeoTiff(raster, CogOptions) overload
- 13 new tests (25 total): builder validation, resampling modes,
overviewCount=0/1, tileSize=512, RasterOutputs CogOptions path
---
.../apache/sedona/common/raster/RasterOutputs.java | 16 ++
.../sedona/common/raster/cog/CogOptions.java | 243 +++++++++++++++++++++
.../apache/sedona/common/raster/cog/CogWriter.java | 106 ++++++---
.../sedona/common/raster/cog/CogWriterTest.java | 229 +++++++++++++++++++
4 files changed, 564 insertions(+), 30 deletions(-)
diff --git a/common/src/main/java/org/apache/sedona/common/raster/RasterOutputs.java b/common/src/main/java/org/apache/sedona/common/raster/RasterOutputs.java
index 791e2d27e7..5a74fea38d 100644
--- a/common/src/main/java/org/apache/sedona/common/raster/RasterOutputs.java
+++ b/common/src/main/java/org/apache/sedona/common/raster/RasterOutputs.java
@@ -36,6 +36,7 @@ import javax.imageio.ImageWriteParam;
import javax.media.jai.InterpolationNearest;
import javax.media.jai.JAI;
import javax.media.jai.RenderedOp;
+import org.apache.sedona.common.raster.cog.CogOptions;
import org.apache.sedona.common.raster.cog.CogWriter;
import org.apache.sedona.common.utils.RasterUtils;
import org.geotools.api.coverage.grid.GridCoverageWriter;
@@ -123,6 +124,21 @@ public class RasterOutputs {
}
}
+ /**
+ * Creates a Cloud Optimized GeoTIFF (COG) byte array with the given options.
+ *
+ * @param raster The input raster
+ * @param options COG generation options (compression, tileSize, resampling, overviewCount)
+ * @return COG file as byte array
+ */
+ public static byte[] asCloudOptimizedGeoTiff(GridCoverage2D raster, CogOptions options) {
+ try {
+ return CogWriter.write(raster, options);
+ } catch (IOException e) {
+ throw new RuntimeException("Failed to write Cloud Optimized GeoTIFF", e);
+ }
+ }
+
/**
* Creates a GeoTiff file with the provided raster. Primarily used for testing.
*
diff --git a/common/src/main/java/org/apache/sedona/common/raster/cog/CogOptions.java b/common/src/main/java/org/apache/sedona/common/raster/cog/CogOptions.java
new file mode 100644
index 0000000000..d253be7428
--- /dev/null
+++ b/common/src/main/java/org/apache/sedona/common/raster/cog/CogOptions.java
@@ -0,0 +1,243 @@
+/*
+ * 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.sedona.common.raster.cog;
+
+import java.util.Arrays;
+import java.util.List;
+import java.util.Locale;
+
+/**
+ * Options for Cloud Optimized GeoTIFF (COG) generation.
+ *
+ * <p>Use the {@link Builder} to construct instances:
+ *
+ * <pre>{@code
+ * CogOptions opts = CogOptions.builder()
+ * .compression("LZW")
+ * .compressionQuality(0.5)
+ * .tileSize(512)
+ * .resampling("Bilinear")
+ * .overviewCount(3)
+ * .build();
+ * }</pre>
+ *
+ * <p>All fields are immutable once constructed. Validation is performed in {@link Builder#build()}.
+ */
+public final class CogOptions {
+
+ /** Supported resampling algorithms for overview generation. */
+ private static final List<String> VALID_RESAMPLING =
+ Arrays.asList("Nearest", "Bilinear", "Bicubic");
+
+ private final String compression;
+ private final double compressionQuality;
+ private final int tileSize;
+ private final String resampling;
+ private final int overviewCount;
+
+ private CogOptions(Builder builder) {
+ this.compression = builder.compression;
+ this.compressionQuality = builder.compressionQuality;
+ this.tileSize = builder.tileSize;
+ this.resampling = builder.resampling;
+ this.overviewCount = builder.overviewCount;
+ }
+
+ /**
+ * @return Compression type: "Deflate", "LZW", "JPEG", "PackBits"
+ */
+ public String getCompression() {
+ return compression;
+ }
+
+ /**
+ * @return Compression quality from 0.0 (max compression) to 1.0 (no compression)
+ */
+ public double getCompressionQuality() {
+ return compressionQuality;
+ }
+
+ /**
+ * @return Tile width and height in pixels (always a power of 2)
+ */
+ public int getTileSize() {
+ return tileSize;
+ }
+
+ /**
+ * @return Resampling algorithm for overview generation: "Nearest", "Bilinear", or "Bicubic"
+ */
+ public String getResampling() {
+ return resampling;
+ }
+
+ /**
+ * @return Number of overview levels. -1 means auto-compute based on image dimensions, 0 means no
+ * overviews.
+ */
+ public int getOverviewCount() {
+ return overviewCount;
+ }
+
+ /**
+ * @return A new builder initialized with default values
+ */
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ /**
+ * @return The default options (Deflate, quality 0.2, 256px tiles, Nearest, auto overviews)
+ */
+ public static CogOptions defaults() {
+ return new Builder().build();
+ }
+
+ @Override
+ public String toString() {
+ return "CogOptions{"
+ + "compression='"
+ + compression
+ + '\''
+ + ", compressionQuality="
+ + compressionQuality
+ + ", tileSize="
+ + tileSize
+ + ", resampling='"
+ + resampling
+ + '\''
+ + ", overviewCount="
+ + overviewCount
+ + '}';
+ }
+
+ /** Builder for {@link CogOptions}. */
+ public static final class Builder {
+ private String compression = "Deflate";
+ private double compressionQuality = 0.2;
+ private int tileSize = 256;
+ private String resampling = "Nearest";
+ private int overviewCount = -1;
+
+ private Builder() {}
+
+ /**
+ * Set the compression type. Default: "Deflate".
+ *
+ * @param compression One of "Deflate", "LZW", "JPEG", "PackBits"
+ * @return this builder
+ */
+ public Builder compression(String compression) {
+ this.compression = compression;
+ return this;
+ }
+
+ /**
+ * Set the compression quality. Default: 0.2.
+ *
+ * @param compressionQuality Value from 0.0 (max compression) to 1.0 (no compression)
+ * @return this builder
+ */
+ public Builder compressionQuality(double compressionQuality) {
+ this.compressionQuality = compressionQuality;
+ return this;
+ }
+
+ /**
+ * Set the tile size for both width and height. Default: 256.
+ *
+ * @param tileSize Must be a positive power of 2 (e.g. 128, 256, 512, 1024)
+ * @return this builder
+ */
+ public Builder tileSize(int tileSize) {
+ this.tileSize = tileSize;
+ return this;
+ }
+
+ /**
+ * Set the resampling algorithm for overview generation. Default: "Nearest".
+ *
+ * @param resampling One of "Nearest", "Bilinear", "Bicubic"
+ * @return this builder
+ */
+ public Builder resampling(String resampling) {
+ this.resampling = resampling;
+ return this;
+ }
+
+ /**
+ * Set the number of overview levels. Default: -1 (auto-compute).
+ *
+ * @param overviewCount -1 for auto, 0 for no overviews, or a positive count
+ * @return this builder
+ */
+ public Builder overviewCount(int overviewCount) {
+ this.overviewCount = overviewCount;
+ return this;
+ }
+
+ /**
+ * Build and validate the options.
+ *
+ * @return A validated, immutable {@link CogOptions} instance
+ * @throws IllegalArgumentException if any option is invalid
+ */
+ public CogOptions build() {
+ if (compression == null || compression.isEmpty()) {
+ throw new IllegalArgumentException("compression must not be null or empty");
+ }
+ if (compressionQuality < 0 || compressionQuality > 1.0) {
+ throw new IllegalArgumentException(
+ "compressionQuality must be between 0.0 and 1.0, got: " + compressionQuality);
+ }
+ if (tileSize <= 0) {
+ throw new IllegalArgumentException("tileSize must be positive, got: " + tileSize);
+ }
+ if ((tileSize & (tileSize - 1)) != 0) {
+ throw new IllegalArgumentException("tileSize must be a power of 2, got: " + tileSize);
+ }
+ if (overviewCount < -1) {
+ throw new IllegalArgumentException(
+ "overviewCount must be -1 (auto), 0 (none), or positive, got: " + overviewCount);
+ }
+
+ // Normalize resampling to title-case for matching
+ String normalized = normalizeResampling(resampling);
+ if (!VALID_RESAMPLING.contains(normalized)) {
+ throw new IllegalArgumentException(
+ "resampling must be one of " + VALID_RESAMPLING + ", got: '" + resampling + "'");
+ }
+ this.resampling = normalized;
+
+ return new CogOptions(this);
+ }
+
+ /**
+ * Normalize the resampling string to title-case (first letter uppercase, rest lowercase) so
+ * callers can pass "nearest", "BILINEAR", etc.
+ */
+ private static String normalizeResampling(String value) {
+ if (value == null || value.isEmpty()) {
+ return "Nearest";
+ }
+ String lower = value.toLowerCase(Locale.ROOT);
+ return Character.toUpperCase(lower.charAt(0)) + lower.substring(1);
+ }
+ }
+}
diff --git a/common/src/main/java/org/apache/sedona/common/raster/cog/CogWriter.java b/common/src/main/java/org/apache/sedona/common/raster/cog/CogWriter.java
index 4adadedd43..4cbf19ea67 100644
--- a/common/src/main/java/org/apache/sedona/common/raster/cog/CogWriter.java
+++ b/common/src/main/java/org/apache/sedona/common/raster/cog/CogWriter.java
@@ -25,6 +25,8 @@ import java.util.ArrayList;
import java.util.List;
import javax.imageio.ImageWriteParam;
import javax.media.jai.Interpolation;
+import javax.media.jai.InterpolationBicubic;
+import javax.media.jai.InterpolationBilinear;
import javax.media.jai.InterpolationNearest;
import org.geotools.api.coverage.grid.GridCoverageWriter;
import org.geotools.api.parameter.GeneralParameterValue;
@@ -65,49 +67,40 @@ public class CogWriter {
private static final int MIN_OVERVIEW_SIZE = 2;
/**
- * Write a GridCoverage2D as a Cloud Optimized GeoTIFF byte array.
+ * Write a GridCoverage2D as a Cloud Optimized GeoTIFF byte array using the given options.
*
* @param raster The input raster
- * @param compressionType Compression type: "Deflate", "LZW", "JPEG", "PackBits", or null for
- * default (Deflate)
- * @param compressionQuality Quality 0.0 (max compression) to 1.0 (no compression), or -1 for
- * default
- * @param tileSize Tile width and height in pixels
+ * @param options COG generation options (compression, tileSize, resampling, overviewCount)
* @return COG file as byte array
* @throws IOException if writing fails
*/
- public static byte[] write(
- GridCoverage2D raster, String compressionType, double compressionQuality, int tileSize)
- throws IOException {
-
- if (compressionType == null) {
- compressionType = "Deflate";
- }
- if (compressionQuality < 0) {
- compressionQuality = 0.2;
- }
- if (compressionQuality > 1.0) {
- throw new IllegalArgumentException(
- "compressionQuality must be between 0.0 and 1.0, got: " + compressionQuality);
- }
- if (tileSize <= 0) {
- throw new IllegalArgumentException("tileSize must be positive, got: " + tileSize);
- }
- if ((tileSize & (tileSize - 1)) != 0) {
- throw new IllegalArgumentException("tileSize must be a power of 2, got: " + tileSize);
- }
+ public static byte[] write(GridCoverage2D raster, CogOptions options) throws IOException {
+ String compressionType = options.getCompression();
+ double compressionQuality = options.getCompressionQuality();
+ int tileSize = options.getTileSize();
+ String resampling = options.getResampling();
+ int requestedOverviewCount = options.getOverviewCount();
RenderedImage image = raster.getRenderedImage();
int cols = image.getWidth();
int rows = image.getHeight();
// Step 1: Compute overview decimation factors
- List<Integer> decimations = computeOverviewDecimations(cols, rows, tileSize);
+ List<Integer> decimations;
+ if (requestedOverviewCount == 0) {
+ decimations = new ArrayList<>();
+ } else {
+ decimations = computeOverviewDecimations(cols, rows, tileSize);
+ if (requestedOverviewCount > 0 && requestedOverviewCount < decimations.size()) {
+ decimations = decimations.subList(0, requestedOverviewCount);
+ }
+ }
// Step 2: Generate overview coverages
+ Interpolation interpolation = getInterpolation(resampling);
List<GridCoverage2D> overviews = new ArrayList<>();
for (int decimation : decimations) {
- GridCoverage2D overview = generateOverview(raster, decimation);
+ GridCoverage2D overview = generateOverview(raster, decimation, interpolation);
overviews.add(overview);
}
@@ -128,6 +121,32 @@ public class CogWriter {
return CogAssembler.assemble(parsedTiffs);
}
+ /**
+ * Write a GridCoverage2D as a Cloud Optimized GeoTIFF byte array.
+ *
+ * @param raster The input raster
+ * @param compressionType Compression type: "Deflate", "LZW", "JPEG", "PackBits", or null for
+ * default (Deflate)
+ * @param compressionQuality Quality 0.0 (max compression) to 1.0 (no compression), or -1 for
+ * default
+ * @param tileSize Tile width and height in pixels
+ * @return COG file as byte array
+ * @throws IOException if writing fails
+ */
+ public static byte[] write(
+ GridCoverage2D raster, String compressionType, double compressionQuality, int tileSize)
+ throws IOException {
+
+ CogOptions.Builder builder = CogOptions.builder().tileSize(tileSize);
+ if (compressionType != null) {
+ builder.compression(compressionType);
+ }
+ if (compressionQuality >= 0) {
+ builder.compressionQuality(compressionQuality);
+ }
+ return write(raster, builder.build());
+ }
+
/**
* Write a GridCoverage2D as COG with default settings (Deflate compression, 256x256 tiles).
*
@@ -186,9 +205,11 @@ public class CogWriter {
*
* @param raster The full resolution raster
* @param decimationFactor Factor to reduce by (2 = half size, 4 = quarter, etc.)
+ * @param interpolation The interpolation method to use for resampling
* @return A new GridCoverage2D at reduced resolution
*/
- static GridCoverage2D generateOverview(GridCoverage2D raster, int decimationFactor) {
+ static GridCoverage2D generateOverview(
+ GridCoverage2D raster, int decimationFactor, Interpolation interpolation) {
RenderedImage image = raster.getRenderedImage();
int newWidth = (int) Math.ceil((double) image.getWidth() / decimationFactor);
int newHeight = (int) Math.ceil((double) image.getHeight() / decimationFactor);
@@ -218,10 +239,35 @@ public class CogWriter {
crs,
null);
- Interpolation interpolation = new InterpolationNearest();
return (GridCoverage2D) Operations.DEFAULT.resample(raster, null, gridGeometry, interpolation);
}
+ /**
+ * Generate an overview using default nearest-neighbor interpolation. Kept for backward
+ * compatibility with tests.
+ */
+ static GridCoverage2D generateOverview(GridCoverage2D raster, int decimationFactor) {
+ return generateOverview(raster, decimationFactor, new InterpolationNearest());
+ }
+
+ /**
+ * Map a resampling algorithm name to a JAI Interpolation instance.
+ *
+ * @param resampling One of "Nearest", "Bilinear", "Bicubic"
+ * @return The corresponding JAI Interpolation
+ */
+ private static Interpolation getInterpolation(String resampling) {
+ switch (resampling) {
+ case "Bilinear":
+ return new InterpolationBilinear();
+ case "Bicubic":
+ return new InterpolationBicubic(8);
+ case "Nearest":
+ default:
+ return new InterpolationNearest();
+ }
+ }
+
/**
* Write a GridCoverage2D as a tiled GeoTIFF byte array using GeoTools.
*
diff --git a/common/src/test/java/org/apache/sedona/common/raster/cog/CogWriterTest.java b/common/src/test/java/org/apache/sedona/common/raster/cog/CogWriterTest.java
index dfbbd07d0e..7989456e6b 100644
--- a/common/src/test/java/org/apache/sedona/common/raster/cog/CogWriterTest.java
+++ b/common/src/test/java/org/apache/sedona/common/raster/cog/CogWriterTest.java
@@ -419,4 +419,233 @@ public class CogWriterTest {
// The last IFD end should be well before the end of the file
assertTrue("IFD region should be at start of file", lastIfdEnd < cogBytes.length / 2);
}
+
+ // --- CogOptions tests ---
+
+ @Test
+ public void testCogOptionsDefaults() {
+ CogOptions opts = CogOptions.defaults();
+ assertEquals("Deflate", opts.getCompression());
+ assertEquals(0.2, opts.getCompressionQuality(), 0.001);
+ assertEquals(256, opts.getTileSize());
+ assertEquals("Nearest", opts.getResampling());
+ assertEquals(-1, opts.getOverviewCount());
+ }
+
+ @Test
+ public void testCogOptionsBuilder() {
+ CogOptions opts =
+ CogOptions.builder()
+ .compression("LZW")
+ .compressionQuality(0.8)
+ .tileSize(512)
+ .resampling("Bilinear")
+ .overviewCount(3)
+ .build();
+
+ assertEquals("LZW", opts.getCompression());
+ assertEquals(0.8, opts.getCompressionQuality(), 0.001);
+ assertEquals(512, opts.getTileSize());
+ assertEquals("Bilinear", opts.getResampling());
+ assertEquals(3, opts.getOverviewCount());
+ }
+
+ @Test
+ public void testCogOptionsResamplingNormalization() {
+ // Case-insensitive resampling names
+ assertEquals("Nearest", CogOptions.builder().resampling("nearest").build().getResampling());
+ assertEquals("Bilinear", CogOptions.builder().resampling("BILINEAR").build().getResampling());
+ assertEquals("Bicubic", CogOptions.builder().resampling("bicubic").build().getResampling());
+ }
+
+ @Test
+ public void testCogOptionsInvalidResampling() {
+ try {
+ CogOptions.builder().resampling("Lanczos").build();
+ fail("Expected IllegalArgumentException for invalid resampling");
+ } catch (IllegalArgumentException e) {
+ assertTrue(e.getMessage().contains("resampling"));
+ }
+ }
+
+ @Test
+ public void testCogOptionsInvalidTileSize() {
+ try {
+ CogOptions.builder().tileSize(300).build();
+ fail("Expected IllegalArgumentException for non-power-of-2 tileSize");
+ } catch (IllegalArgumentException e) {
+ assertTrue(e.getMessage().contains("power of 2"));
+ }
+ }
+
+ @Test
+ public void testCogOptionsInvalidOverviewCount() {
+ try {
+ CogOptions.builder().overviewCount(-2).build();
+ fail("Expected IllegalArgumentException for negative overviewCount");
+ } catch (IllegalArgumentException e) {
+ assertTrue(e.getMessage().contains("overviewCount"));
+ }
+ }
+
+ @Test
+ public void testWriteWithCogOptions() throws IOException {
+ double[] bandValues = new double[512 * 512];
+ for (int i = 0; i < bandValues.length; i++) {
+ bandValues[i] = (i * 7) % 256;
+ }
+ GridCoverage2D raster =
+ RasterConstructors.makeNonEmptyRaster(
+ 1, "d", 512, 512, 0, 0, 1, -1, 0, 0, 4326, new double[][] {bandValues});
+
+ CogOptions opts =
+ CogOptions.builder().compression("LZW").compressionQuality(0.5).tileSize(256).build();
+
+ byte[] cogBytes = CogWriter.write(raster, opts);
+ assertNotNull(cogBytes);
+ assertTrue(cogBytes.length > 0);
+
+ GridCoverage2D readBack = RasterConstructors.fromGeoTiff(cogBytes);
+ assertEquals(512, readBack.getRenderedImage().getWidth());
+ assertEquals(512, readBack.getRenderedImage().getHeight());
+
+ double[] originalValues = MapAlgebra.bandAsArray(raster, 1);
+ double[] readBackValues = MapAlgebra.bandAsArray(readBack, 1);
+ assertArrayEquals(originalValues, readBackValues, 0.01);
+ }
+
+ @Test
+ public void testWriteWithBilinearResampling() throws IOException {
+ double[] bandValues = new double[512 * 512];
+ for (int i = 0; i < bandValues.length; i++) {
+ bandValues[i] = (i * 3) % 256;
+ }
+ GridCoverage2D raster =
+ RasterConstructors.makeNonEmptyRaster(
+ 1, "d", 512, 512, 0, 0, 1, -1, 0, 0, 4326, new double[][] {bandValues});
+
+ CogOptions opts = CogOptions.builder().resampling("Bilinear").build();
+ byte[] cogBytes = CogWriter.write(raster, opts);
+ assertNotNull(cogBytes);
+ assertTrue(cogBytes.length > 0);
+
+ // Must be valid TIFF and readable
+ GridCoverage2D readBack = RasterConstructors.fromGeoTiff(cogBytes);
+ assertEquals(512, readBack.getRenderedImage().getWidth());
+ }
+
+ @Test
+ public void testWriteWithBicubicResampling() throws IOException {
+ double[] bandValues = new double[512 * 512];
+ for (int i = 0; i < bandValues.length; i++) {
+ bandValues[i] = (i * 5) % 256;
+ }
+ GridCoverage2D raster =
+ RasterConstructors.makeNonEmptyRaster(
+ 1, "d", 512, 512, 0, 0, 1, -1, 0, 0, 4326, new double[][] {bandValues});
+
+ CogOptions opts = CogOptions.builder().resampling("Bicubic").build();
+ byte[] cogBytes = CogWriter.write(raster, opts);
+ assertNotNull(cogBytes);
+
+ GridCoverage2D readBack = RasterConstructors.fromGeoTiff(cogBytes);
+ assertEquals(512, readBack.getRenderedImage().getWidth());
+ }
+
+ @Test
+ public void testWriteWithOverviewCountZero() throws IOException {
+ double[] bandValues = new double[512 * 512];
+ for (int i = 0; i < bandValues.length; i++) {
+ bandValues[i] = (i * 11) % 256;
+ }
+ GridCoverage2D raster =
+ RasterConstructors.makeNonEmptyRaster(
+ 1, "d", 512, 512, 0, 0, 1, -1, 0, 0, 4326, new double[][] {bandValues});
+
+ CogOptions opts = CogOptions.builder().overviewCount(0).build();
+ byte[] cogBytes = CogWriter.write(raster, opts);
+ assertNotNull(cogBytes);
+
+ // With overviewCount=0, there should be exactly 1 IFD (no overviews)
+ ByteOrder byteOrder = (cogBytes[0] == 'I') ? ByteOrder.LITTLE_ENDIAN : ByteOrder.BIG_ENDIAN;
+ ByteBuffer buf = ByteBuffer.wrap(cogBytes).order(byteOrder);
+ int firstIfdOffset = buf.getInt(4);
+ int tagCount = buf.getShort(firstIfdOffset) & 0xFFFF;
+ int nextIfdOffset = buf.getInt(firstIfdOffset + 2 + tagCount * 12);
+ assertEquals("Should have no overview IFD when overviewCount=0", 0, nextIfdOffset);
+
+ // Should still be readable
+ GridCoverage2D readBack = RasterConstructors.fromGeoTiff(cogBytes);
+ assertEquals(512, readBack.getRenderedImage().getWidth());
+ }
+
+ @Test
+ public void testWriteWithSpecificOverviewCount() throws IOException {
+ // 1024x1024 with tileSize=256 would normally produce 2 overviews (2, 4)
+ double[] bandValues = new double[1024 * 1024];
+ for (int i = 0; i < bandValues.length; i++) {
+ bandValues[i] = (i * 13) % 256;
+ }
+ GridCoverage2D raster =
+ RasterConstructors.makeNonEmptyRaster(
+ 1, "d", 1024, 1024, 0, 0, 1, -1, 0, 0, 4326, new double[][] {bandValues});
+
+ // Request only 1 overview
+ CogOptions opts = CogOptions.builder().overviewCount(1).build();
+ byte[] cogBytes = CogWriter.write(raster, opts);
+ assertNotNull(cogBytes);
+
+ // Count IFDs: should have exactly 2 (full-res + 1 overview)
+ ByteOrder byteOrder = (cogBytes[0] == 'I') ? ByteOrder.LITTLE_ENDIAN : ByteOrder.BIG_ENDIAN;
+ ByteBuffer buf = ByteBuffer.wrap(cogBytes).order(byteOrder);
+
+ int ifdCount = 0;
+ int ifdOffset = buf.getInt(4);
+ while (ifdOffset != 0) {
+ ifdCount++;
+ int tagCount = buf.getShort(ifdOffset) & 0xFFFF;
+ ifdOffset = buf.getInt(ifdOffset + 2 + tagCount * 12);
+ }
+ assertEquals("Should have exactly 2 IFDs (full-res + 1 overview)", 2, ifdCount);
+
+ GridCoverage2D readBack = RasterConstructors.fromGeoTiff(cogBytes);
+ assertEquals(1024, readBack.getRenderedImage().getWidth());
+ }
+
+ @Test
+ public void testWriteWithTileSize512() throws IOException {
+ double[] bandValues = new double[1024 * 1024];
+ for (int i = 0; i < bandValues.length; i++) {
+ bandValues[i] = (i * 17) % 256;
+ }
+ GridCoverage2D raster =
+ RasterConstructors.makeNonEmptyRaster(
+ 1, "d", 1024, 1024, 0, 0, 1, -1, 0, 0, 4326, new double[][] {bandValues});
+
+ CogOptions opts = CogOptions.builder().tileSize(512).build();
+ byte[] cogBytes = CogWriter.write(raster, opts);
+ assertNotNull(cogBytes);
+ assertTrue(cogBytes.length > 0);
+
+ GridCoverage2D readBack = RasterConstructors.fromGeoTiff(cogBytes);
+ assertEquals(1024, readBack.getRenderedImage().getWidth());
+ }
+
+ @Test
+ public void testRasterOutputsWithCogOptions() throws IOException {
+ double[] bandValues = new double[256 * 256];
+ for (int i = 0; i < bandValues.length; i++) {
+ bandValues[i] = i % 256;
+ }
+ GridCoverage2D raster =
+ RasterConstructors.makeNonEmptyRaster(
+ 1, "d", 256, 256, 0, 0, 1, -1, 0, 0, 4326, new double[][] {bandValues});
+
+ CogOptions opts = CogOptions.builder().compression("LZW").overviewCount(0).build();
+ byte[] cogBytes = RasterOutputs.asCloudOptimizedGeoTiff(raster, opts);
+ assertNotNull(cogBytes);
+
+ GridCoverage2D readBack = RasterConstructors.fromGeoTiff(cogBytes);
+ assertEquals(256, readBack.getRenderedImage().getWidth());
+ }
}