diff --git a/martin-core/Cargo.toml b/martin-core/Cargo.toml index 3118be545..dde0911ff 100644 --- a/martin-core/Cargo.toml +++ b/martin-core/Cargo.toml @@ -33,7 +33,13 @@ postgres = [ "dep:serde_json", "_tiles", ] -unstable-cog = ["dep:png", "dep:tiff", "_tiles"] +unstable-cog = [ + "dep:png", + "dep:tiff", + "dep:image", + "dep:serde_json", + "_tiles", +] unstable-rendering = ["styles", "dep:maplibre_native", "dep:image"] fonts = [ "dep:bit-set", @@ -67,6 +73,7 @@ bit-set = { workspace = true, optional = true } dashmap = { workspace = true, optional = true } deadpool-postgres = { workspace = true, optional = true } futures = { workspace = true, optional = true } +image = { workspace = true, optional = true } itertools = { workspace = true, optional = true } martin-tile-utils.workspace = true mbtiles = { workspace = true, optional = true } diff --git a/martin-core/src/tiles/cog/README.md b/martin-core/src/tiles/cog/README.md index 01345b93e..c5f64348d 100644 --- a/martin-core/src/tiles/cog/README.md +++ b/martin-core/src/tiles/cog/README.md @@ -1,29 +1,38 @@ ## COG Image Representation * COG file is an image container representing a tile grid -* A COG may have multiple images, also called subfiles, each indexed with an Image File Directory number - [`IFD`](https://download.osgeo.org/libtiff/doc/TIFF6.pdf#[{"num":209,"gen":0},{"name":"FitB"}]#[{"num":76,"gen":0},{"name":"FitB"}]#[{"num":76,"gen":0},{"name":"FitB"}]]) +* A COG may have multiple images, also called subfiles or overviews, each indexed with an Image File Directory number - [`IFD`](https://download.osgeo.org/libtiff/doc/TIFF6.pdf#[{"num":209,"gen":0},{"name":"FitB"}]#[{"num":76,"gen":0},{"name":"FitB"}]#[{"num":76,"gen":0},{"name":"FitB"}]]) * A COG must have at least one image. * The first image (IFD=0) must be a full resolution image, e.g., the one with the highest resolution. -* [Each image may also have a mask](https://docs.ogc.org/is/21-026/21-026.html#_requirement_reduced_resolution_subfiles), which is also indexed with an IFD. The mask is used to [defines a transparency mask](https://www.verypdf.com/document/tiff6/pg_0036.htm). We do not support masks yet. -* While uncommon, COG tile grid might be different from the common ones like Web Mercator. +* [Each image may also have an accompanying mask](https://docs.ogc.org/is/21-026/21-026.html#_requirement_reduced_resolution_subfiles), which is also indexed with an IFD. The mask is used to [define a transparency mask](https://www.verypdf.com/document/tiff6/pg_0036.htm). We do not support masks yet. +* While uncommon, COG tile matrix set ([2D TMS](https://docs.ogc.org/is/17-083r4/17-083r4.html#tilematrixset-requirements-class)) might be different from the common `WebMercatorQuad`. We do not support any TMS other than Web Mercator yet. -### COG structure example +### COG IFD Structure -Here is an example of a tile grid for a COG file with five images and five masks. +Here is an example of a tile grid for a COG file with five images. See [wiki.openstreetmap.org](https://wiki.openstreetmap.org/wiki/Slippy_map_tilenames#Resolution_and_Scale) for more information on resolution. -| ifd | image index | resolution | zoom | -| --- | ----------- | ---------- | ---- | -| 0 | 0 | 20 | 4 | -| 2 | 1 | 40 | 3 | -| 4 | 2 | 80 | 2 | -| 6 | 3 | 160 | 1 | -| 8 | 4 | 320 | 0 | +| ifd | `tile_size` | zoom | resolution (meters / px) | +| --- | ---------- | ---- | ------------------------ | +| 0 | 256 | 4 | 9783.94 | +| 1 | 256 | 3 | 19567.88 | +| 2 | 256 | 2 | 39135.76 | +| 3 | 256 | 1 | 78271.52 | +| 4 | 256 | 0 | 156543.03 | -### Tile grid code representation +### COG file requirements enforced by Martin -```rust, ignore -let images = vec![ image_0, image_1, image_2, image_3, image_4 ]; -let minzoom = 0; -let zoom_of_image = image_count - 1 - idx_in_vec; # TODO: what is this? -let maxzoom = images.len() - 1; +Due to the flexibility of the COG, GEOTIFF and TIFF file formats and the assumptions of Martin, not all COG files will be compatible. The following are a few requirements that Martin has of the COG file, some of which are defined in the COG or TIFF spec, some are constraints imposed by assumptions of Martin. If your file conforms to these requirements, it's more likely to work with Martin: + +* File MUST define the `ProjectedCRS` `GeoKey` with a value of 3857 (EPSG:3857) +* File MUST use PlanarConfiguration=1 aka. "Contiguous" or "Chunky" +* File MUST use Compression=1 "None", 5 "LZW", 7 "`ModernJPEG`", 8 "Deflate" or 50001 "WEBP" [See GDAL compression tag definitions](https://github.com/OSGeo/gdal/blob/c7d41bf263a1a3951546c5cfa66872fc05dfc8cc/frmts/gtiff/libtiff/tiff.h#L182-L219) +* File MUST use an 8-bit color depth +* File MUST use 3 (RGB) or 4 (RGBA) bands +* File MUST use tile blocks not strips (eg. `TileWidth`, `TileHeight` is defined, not `StripOffsets`, `StripByteCounts`, `RowsPerStrip`, etc.) +* File MUST use square tiles whose dimension is a power of 2 (eg. 256x256 or 512x512) + +Using GDAL, you can create a COG file with 5 zoom levels which meets most of these requirements using: + +```bash +gdal_translate original.tif compatible.tif -b 1 -b 2 -b 3 -of COG -co BIGTIFF=YES -co TILING_SCHEME=GoogleMapsCompatible -co ADD_ALPHA=YES -co OVERVIEWS=IGNORE_EXISTING -co COMPRESS=LZW -co OVERVIEW_COUNT=4 -co ALIGNED_LEVELS=5 -co NUM_THREADS=ALL_CPUS -co ZOOM_LEVEL_STRATEGY=LOWER -co BLOCKSIZE=512 -co SPARSE_OK=TRUE ``` diff --git a/martin-core/src/tiles/cog/errors.rs b/martin-core/src/tiles/cog/errors.rs index e1e327258..e33bdae5a 100644 --- a/martin-core/src/tiles/cog/errors.rs +++ b/martin-core/src/tiles/cog/errors.rs @@ -57,6 +57,10 @@ pub enum CogError { #[error("The color type {0:?} and its bit depth of the tiff file {1} is not supported yet")] NotSupportedColorTypeAndBitDepth(tiff::ColorType, PathBuf), + /// Unsupported compression. + #[error("The compression type {0:?} of the tiff file {1} is not supported yet")] + NotSupportedCompression(u16, PathBuf), + /// Striped TIFF format not supported. #[error("Striped tiff file is not supported, the tiff file is {0}")] NotSupportedChunkType(PathBuf), @@ -86,4 +90,8 @@ pub enum CogError { /// IO error. #[error("IO error {0}: {1}")] IoError(#[source] std::io::Error, PathBuf), + + /// Images are not tiled consistently within the file. + #[error("The size of each tile is not consistent.")] + InconsistentTiling(PathBuf), } diff --git a/martin-core/src/tiles/cog/image.rs b/martin-core/src/tiles/cog/image.rs index 7e4fef885..450d9eb08 100644 --- a/martin-core/src/tiles/cog/image.rs +++ b/martin-core/src/tiles/cog/image.rs @@ -1,194 +1,272 @@ use std::fs::File; -use std::io::BufWriter; +use std::io::{BufWriter, Read as _, Seek as _, SeekFrom}; use std::path::Path; -use martin_tile_utils::{TileCoord, TileData}; -use tiff::decoder::{Decoder, DecodingResult}; +use martin_tile_utils::{Format, TileCoord, TileData}; +use tiff::tags::CompressionMethod; +use tiff::{ColorType, decoder::Decoder}; -use crate::tiles::MartinCoreResult; use crate::tiles::cog::CogError; +/// WEBP compression code (not in standard TIFF, registered by GDAL) +pub const COMPRESSION_WEBP: u16 = 50001; + /// Image represents a single image in a COG file. A tiff file may contain many images. /// This struct contains information and methods for taking tiles from the image. #[derive(Clone, Debug)] pub struct Image { - /// The Image File Directory index represents IDF entry with the image pointers to the actual image data. + /// The Image File Directory index represents IFD entry with the image pointers to the actual image data. ifd_index: usize, + /// Zoom level which this image corresponds to + zoom_level: u8, + /// X and Y of the first tile in this image + tiles_origin: (u32, u32), /// Number of tiles in a row of this image tiles_across: u32, - /// Number of tiles in a column of this image + /// Number of tiles in a column of this image tiles_down: u32, + /// Tile size in pixels + tile_size: u32, + /// Compression method used for tiles + compression: u16, } impl Image { - /// Creates a new image with the specified IFD index and tile dimensions. - pub fn new(ifd_index: usize, tiles_across: u32, tiles_down: u32) -> Self { + #[allow(clippy::too_many_arguments)] + pub fn new( + ifd_index: usize, + zoom_level: u8, + tiles_origin: (u32, u32), + tiles_across: u32, + tiles_down: u32, + tile_size: u32, + compression: u16, + ) -> Self { Self { ifd_index, + zoom_level, + tiles_origin, tiles_across, tiles_down, + tile_size, + compression, + } + } + + /// Returns the output format for this image based on compression. + pub fn output_format(&self) -> Option { + if self.compression == COMPRESSION_WEBP { + return Some(Format::Webp); + } + match CompressionMethod::from_u16(self.compression) { + Some(CompressionMethod::ModernJPEG) => Some(Format::Jpeg), + Some(CompressionMethod::Deflate | CompressionMethod::LZW | CompressionMethod::None) => { + Some(Format::Png) + } + _ => None, } } - /// Retrieves a tile from the image, decodes it, and converts it to PNG format. - #[expect(clippy::cast_sign_loss, clippy::cast_possible_truncation)] + /// Returns true if this image uses a passthrough compression (WEBP or JPEG) + /// where raw tile bytes can be returned directly without re-encoding. + fn is_passthrough_compression(&self) -> bool { + if self.compression == COMPRESSION_WEBP { + return true; + } + matches!( + CompressionMethod::from_u16(self.compression), + Some(CompressionMethod::ModernJPEG) + ) + } + pub fn get_tile( &self, decoder: &mut Decoder, xyz: TileCoord, - nodata: Option, path: &Path, - ) -> MartinCoreResult { + ) -> Result { decoder - .seek_to_image(self.ifd_index()) - .map_err(|e| CogError::IfdSeekFailed(e, self.ifd_index(), path.to_path_buf()))?; + .seek_to_image(self.ifd_index) + .map_err(|e| CogError::IfdSeekFailed(e, self.ifd_index, path.to_path_buf()))?; - let tile_idx; - if let Some(idx) = self.get_tile_index(xyz) { - tile_idx = idx; - } else { + let Some(idx) = self.get_chunk_index(xyz) else { return Ok(Vec::new()); + }; + + // For WEBP and JPEG compression, return the raw tile bytes directly + if self.is_passthrough_compression() { + return self.read_raw_tile_bytes(decoder, idx, path); } - let decode_result = decoder.read_chunk(tile_idx).map_err(|e| { - CogError::ReadChunkFailed(e, tile_idx, self.ifd_index(), path.to_path_buf()) - })?; + + // For other compression types (LZW, Deflate, None), decode and re-encode as PNG let color_type = decoder .colortype() .map_err(|e| CogError::InvalidTiffFile(e, path.to_path_buf()))?; - let (tile_width, tile_height) = decoder.chunk_dimensions(); - let (data_width, data_height) = decoder.chunk_data_dimensions(tile_idx); - - // FIXME: do more research on the not u8 case, is this the right way to do it? - let png_file_bytes = match (decode_result, color_type) { - (DecodingResult::U8(vec), tiff::ColorType::RGB(_)) => rgb_to_png( - vec, - (tile_width, tile_height), - (data_width, data_height), - 3, - nodata.map(|v| v as u8), - path, - ), - (DecodingResult::U8(vec), tiff::ColorType::RGBA(_)) => rgb_to_png( - vec, - (tile_width, tile_height), - (data_width, data_height), - 4, - nodata.map(|v| v as u8), - path, - ), - (_, _) => Err(CogError::NotSupportedColorTypeAndBitDepth( - color_type, - path.to_path_buf(), - )), - // todo: do others in next PRs, a lot of discussion would be needed - }?; - Ok(png_file_bytes) + let mut pixels = vec![ + 0; + (self.tile_size * self.tile_size * u32::from(color_type.num_samples())) + as usize + ]; + if decoder.read_chunk_bytes(idx, &mut pixels).is_err() { + return Ok(Vec::new()); + } + + let png = encode_as_png(self.tile_size(), &pixels, path, color_type)?; + Ok(png) } - /// Returns the Image File Directory index for this image. - pub fn ifd_index(&self) -> usize { - self.ifd_index + /// Reads the raw bytes of a tile directly from the TIFF file without decompression. + /// This is used for WEBP and JPEG compressed tiles where we can pass through the bytes. + fn read_raw_tile_bytes( + &self, + decoder: &mut Decoder, + chunk_index: u32, + path: &Path, + ) -> Result { + use tiff::tags::Tag; + + // For JPEG compression, we may need to merge JPEGTables with tile data + let jpeg_tables = if CompressionMethod::from_u16(self.compression) + == Some(CompressionMethod::ModernJPEG) + { + decoder.get_tag_u8_vec(Tag::JPEGTables).ok() + } else { + None + }; + + // Get tile offsets and byte counts from the TIFF tags + let tile_offsets = decoder + .get_tag_u64_vec(Tag::TileOffsets) + .map_err(|e| CogError::InvalidTiffFile(e, path.to_path_buf()))?; + let tile_byte_counts = decoder + .get_tag_u64_vec(Tag::TileByteCounts) + .map_err(|e| CogError::InvalidTiffFile(e, path.to_path_buf()))?; + + let idx = chunk_index as usize; + if idx >= tile_offsets.len() || idx >= tile_byte_counts.len() { + return Ok(Vec::new()); + } + + let offset = tile_offsets[idx]; + let byte_count = usize::try_from(tile_byte_counts[idx]).unwrap_or(0); + + // If byte count is 0, this is an empty/sparse tile + if byte_count == 0 { + return Ok(Vec::new()); + } + + // Seek to the tile offset and read the raw bytes + let file = decoder.inner(); + file.seek(SeekFrom::Start(offset)) + .map_err(|e| CogError::IoError(e, path.to_path_buf()))?; + + let mut tile_data = vec![0u8; byte_count]; + file.read_exact(&mut tile_data) + .map_err(|e| CogError::IoError(e, path.to_path_buf()))?; + + // For JPEG, merge JPEGTables with tile data if tables are present + if let Some(tables) = jpeg_tables { + return Ok(merge_jpeg_tables_with_tile(&tables, &tile_data)); + } + + Ok(tile_data) } - fn get_tile_index(&self, xyz: TileCoord) -> Option { - if xyz.y >= self.tiles_down || xyz.x >= self.tiles_across { + pub fn compression(&self) -> u16 { + self.compression + } + + pub fn tile_size(&self) -> u32 { + self.tile_size + } + + pub fn zoom_level(&self) -> u8 { + self.zoom_level + } + + fn get_chunk_index(&self, xyz: TileCoord) -> Option { + if xyz.z != self.zoom_level { + return None; + } + + let x = i64::from(xyz.x) - i64::from(self.tiles_origin.0); + let y = i64::from(xyz.y) - i64::from(self.tiles_origin.1); + if 0 > x || x >= i64::from(self.tiles_across) || 0 > y || y >= i64::from(self.tiles_down) { return None; } - Some(xyz.y * self.tiles_across + xyz.x) + let idx = y * i64::from(self.tiles_across) + x; + u32::try_from(idx).ok() } } -/// Converts RGB/RGBA tile data to PNG format. -fn rgb_to_png( - data: Vec, - (tile_width, tile_height): (u32, u32), - (data_width, data_height): (u32, u32), - components_count: u32, - nodata: Option, - path: &Path, -) -> Result, CogError> { - let pixels = ensure_pixels_valid( - data, - (tile_width, tile_height), - (data_width, data_height), - components_count, - nodata, - ); - encode_rgba_as_png(tile_width, tile_height, &pixels, path) -} +/// JPEG marker constants +const JPEG_SOI: [u8; 2] = [0xFF, 0xD8]; // Start of Image +const JPEG_EOI: [u8; 2] = [0xFF, 0xD9]; // End of Image -/// Ensures pixel data is valid for PNG encoding by handling padding, alpha channel, and nodata values. -fn ensure_pixels_valid( - data: Vec, - (tile_width, tile_height): (u32, u32), - (data_width, data_height): (u32, u32), - components_count: u32, - nodata: Option, -) -> Vec { - let is_padded = data_width != tile_width || data_height != tile_height; - // FIXME: why not `== 3`? - let add_alpha = components_count != 4; - // 1. Check if the tile is padded. If so, we need to add padding part back. - // The decoded value might be smaller than the tile size. - // TIFF crate always cut off the padding part, so we would need to add the padding part back. - // 2. If the components count is 3 (RGB), we need to add the alpha channel to convert it to RGBA. - // 3. Check if nodata is provided. We need to make the pixels with nodata value transparent - // See https://gdal.org/en/stable/drivers/raster/gtiff.html#nodata-value - if nodata.is_some() || add_alpha || is_padded { - let mut result_vec = vec![0; (tile_width * tile_height * 4) as usize]; - for row in 0..data_height { - 'outer: for col in 0..data_width { - let idx_chunk = row * data_width * components_count + col * components_count; - let idx_result = row * tile_width * 4 + col * 4; - - // Copy component values one by one - for component_idx in 0..components_count { - // Before copying, check if this component == nodata. If so, skip because it's transparent. - // FIXME: Should we copy the RGB values anyway and just set alpha to 0? - // The visual result is the same (transparent), but the component values would differ. - // But it might be a little slower as we don't skip the copy. - // Source pixel: [4, 1, 2, 3] nodata: Some(1) - // Skip: - // result pixel: [4, 0, 0, 0] - // Do not skip: - // result pixel: [4, 1, 2, 0] - // So the visual result is the same, but the component values are different. - - let value = data[(idx_chunk + component_idx) as usize]; - if nodata == Some(value) { - continue 'outer; - } - // Copy this component to the result vector - result_vec[(idx_result + component_idx) as usize] = value; - } - if add_alpha { - result_vec[(idx_result + 3) as usize] = 255; // opaque - } - } - } - result_vec - } else { - data +/// Merges JPEG tables (from `JPEGTables` tag) with tile data to create a valid standalone JPEG. +/// +/// In TIFF JPEG compression, the quantization and Huffman tables are often stored +/// separately in the `JPEGTables` tag and shared across all tiles. Each tile then only +/// contains the frame data without these tables. +/// +/// `JPEGTables` format: SOI (FFD8) + tables (DQT, DHT, etc.) + EOI (FFD9) +/// Tile data format: SOI (FFD8) + frame header + scan data + EOI (FFD9) +/// +/// To merge: Take tables (without SOI/EOI) and insert after tile's SOI, before frame data. +fn merge_jpeg_tables_with_tile(jpeg_tables: &[u8], tile_data: &[u8]) -> Vec { + // Validate minimum sizes + if jpeg_tables.len() < 4 || tile_data.len() < 4 { + return tile_data.to_vec(); } + + // Verify both start with SOI marker + if jpeg_tables[0..2] != JPEG_SOI || tile_data[0..2] != JPEG_SOI { + return tile_data.to_vec(); + } + + // Extract tables content (skip SOI at start, skip EOI at end if present) + let tables_end = if jpeg_tables.len() >= 2 && jpeg_tables[jpeg_tables.len() - 2..] == JPEG_EOI { + jpeg_tables.len() - 2 + } else { + jpeg_tables.len() + }; + let tables_content = &jpeg_tables[2..tables_end]; + + // Build merged JPEG: SOI + tables + tile data (without SOI) + let mut result = Vec::with_capacity(2 + tables_content.len() + tile_data.len() - 2); + result.extend_from_slice(&JPEG_SOI); + result.extend_from_slice(tables_content); + result.extend_from_slice(&tile_data[2..]); // Skip tile's SOI + + result } /// Encodes RGBA pixel data to PNG format. -fn encode_rgba_as_png( - tile_width: u32, - tile_height: u32, +fn encode_as_png( + tile_size: u32, pixels: &[u8], path: &Path, + color_type: ColorType, ) -> Result, CogError> { let mut result_file_buffer = Vec::new(); + let png_color_type = match color_type { + ColorType::RGB(8) => Ok(png::ColorType::Rgb), + ColorType::RGBA(8) => Ok(png::ColorType::Rgba), + c => Err(CogError::NotSupportedColorTypeAndBitDepth( + c, + path.to_path_buf(), + )), + }?; + { let mut encoder = png::Encoder::new( BufWriter::new(&mut result_file_buffer), - tile_width, - tile_height, + tile_size, + tile_size, ); - encoder.set_color(png::ColorType::Rgba); + encoder.set_color(png_color_type); encoder.set_depth(png::BitDepth::Eight); let mut writer = encoder .write_header() @@ -197,95 +275,83 @@ fn encode_rgba_as_png( .write_image_data(pixels) .map_err(|e| CogError::WriteToPngFailed(path.to_path_buf(), e))?; } + Ok(result_file_buffer) } #[cfg(test)] mod tests { - use std::path::Path; - + use crate::tiles::cog::image::{Image, merge_jpeg_tables_with_tile}; use martin_tile_utils::TileCoord; - use rstest::rstest; - - use crate::tiles::cog::image::Image; #[test] - fn can_calc_tile_idx() { + fn can_calculate_correct_chunk_index() { let image = Image { ifd_index: 0, + zoom_level: 0, + tiles_origin: (0, 0), tiles_across: 3, tiles_down: 3, + tile_size: 256, + compression: 1, // None }; assert_eq!( Some(0), - image.get_tile_index(TileCoord { z: 0, x: 0, y: 0 }) + image.get_chunk_index(TileCoord { z: 0, x: 0, y: 0 }) ); - assert_eq!( - Some(8), - image.get_tile_index(TileCoord { z: 0, x: 2, y: 2 }) - ); - assert_eq!(None, image.get_tile_index(TileCoord { z: 0, x: 3, y: 0 })); - assert_eq!(None, image.get_tile_index(TileCoord { z: 0, x: 1, y: 9 })); + assert_eq!(None, image.get_chunk_index(TileCoord { z: 2, x: 2, y: 2 })); + assert_eq!(None, image.get_chunk_index(TileCoord { z: 0, x: 3, y: 0 })); + assert_eq!(None, image.get_chunk_index(TileCoord { z: 0, x: 1, y: 9 })); } - #[rstest] - // the right half should be transparent - #[case( - "right_padded.png", - (0, 0, 0, None), None, (128, 256), (256, 256) - )] - // the down half should be transparent - #[case( - "down_padded.png", - (0, 0, 0, None), None, (256, 128), (256, 256) - )] - // the up half should be half-transparent and down half should be transparent - #[case( - "down_padded_with_alpha.png", - (0, 0, 0, Some(128)), None, (256, 128), (256, 256) - )] - // the left half should be half-transparent and the right half should be transparent - #[case( - "right_padded_with_alpha.png", - (0, 0, 0, Some(128)), None, (128, 256), (256, 256) - )] - // should be all half transparent - #[case( - "not_padded.png", - (0, 0, 0, Some(128)), None, (256, 256), (256, 256) - )] - // all padded and with a no_data whose value is 128, and all the component is 128 - // so that should be all transparent - #[case( - "all_transparent.png", - (128, 128, 128, Some(128)), Some(128), (128, 128), (256, 256) - )] - fn test_padded_cases( - #[case] expected_file_path: &str, - #[case] components: (u8, u8, u8, Option), - #[case] no_value: Option, - #[case] (data_width, data_height): (u32, u32), - #[case] (tile_width, tile_height): (u32, u32), - ) { - let mut pixels = Vec::new(); - for _ in 0..(data_width * data_height) { - pixels.push(components.0); - pixels.push(components.1); - pixels.push(components.2); - if let Some(alpha) = components.3 { - pixels.push(alpha); - } - } - let components_count = if components.3.is_some() { 4 } else { 3 }; - let png_bytes = super::rgb_to_png( - pixels, - (tile_width, tile_height), - (data_width, data_height), - components_count, - no_value, - Path::new("not_exist.tif"), - ) - .unwrap(); - insta::assert_binary_snapshot!(expected_file_path, png_bytes); + #[test] + fn can_merge_jpeg_tables_with_tile() { + // JPEGTables: SOI + table data + EOI + let jpeg_tables = vec![ + 0xFF, 0xD8, // SOI + 0xFF, 0xDB, 0x00, 0x05, 0x00, 0x10, 0x20, // DQT marker with some data + 0xFF, 0xD9, // EOI + ]; + + // Tile data: SOI + frame data + EOI + let tile_data = vec![ + 0xFF, 0xD8, // SOI + 0xFF, 0xC0, 0x00, 0x04, 0x08, 0x10, // SOF marker with some data + 0xFF, 0xDA, 0x00, 0x02, // SOS marker + 0x12, 0x34, 0x56, // compressed data + 0xFF, 0xD9, // EOI + ]; + + let merged = merge_jpeg_tables_with_tile(&jpeg_tables, &tile_data); + + // Expected: SOI + table data (no EOI) + frame data (no SOI) + EOI + let expected = vec![ + 0xFF, 0xD8, // SOI (from tables) + 0xFF, 0xDB, 0x00, 0x05, 0x00, 0x10, 0x20, // DQT marker (tables content) + 0xFF, 0xC0, 0x00, 0x04, 0x08, 0x10, // SOF marker (tile without SOI) + 0xFF, 0xDA, 0x00, 0x02, // SOS marker + 0x12, 0x34, 0x56, // compressed data + 0xFF, 0xD9, // EOI + ]; + + assert_eq!(merged, expected); + } + + #[test] + fn merge_returns_tile_data_when_tables_too_short() { + let jpeg_tables = vec![0xFF, 0xD8]; // Just SOI, too short + let tile_data = vec![0xFF, 0xD8, 0xFF, 0xC0, 0x00, 0x02, 0xFF, 0xD9]; + + let merged = merge_jpeg_tables_with_tile(&jpeg_tables, &tile_data); + assert_eq!(merged, tile_data); + } + + #[test] + fn merge_returns_tile_data_when_invalid_markers() { + let jpeg_tables = vec![0x00, 0x00, 0x00, 0x00]; // No SOI + let tile_data = vec![0xFF, 0xD8, 0xFF, 0xC0, 0x00, 0x02, 0xFF, 0xD9]; + + let merged = merge_jpeg_tables_with_tile(&jpeg_tables, &tile_data); + assert_eq!(merged, tile_data); } } diff --git a/martin-core/src/tiles/cog/model.rs b/martin-core/src/tiles/cog/model.rs index 33d9357d2..721a3a649 100644 --- a/martin-core/src/tiles/cog/model.rs +++ b/martin-core/src/tiles/cog/model.rs @@ -66,6 +66,19 @@ pub struct ModelInfo { /// | 1 | | m n o p | | 1 | /// |- -| |- -| |- -| pub transformation: Option>, + /// This key is used to specify the projected coordinate reference system from the `GeoTIFF` CRS register or to indicate that the Model CRS is a user-defined projected coordinate reference system. + /// + /// Requirement 12.3 + /// + /// `ProjectedCRSGeoKey` values in the range 1-1023 SHALL be reserved. + /// + /// Requirement 12.4 + /// + /// `ProjectedCRSGeoKey` values in the range 1024-32766 SHALL be EPSG Projected CRS Codes + /// NOTE: In `GeoTIFF` v1.0 the range was 20000-32760. Several values in this range have been deprecated or deleted from the EPSG Dataset and should no longer be used. See Table G.1 - Deprecated and deleted EPSG Projected CRS codes + /// + /// Example: `Some(3857u16)` or `None` + pub projected_crs: Option, } impl ModelInfo { @@ -104,10 +117,33 @@ impl ModelInfo { ) }) .ok(); + + // See: https://docs.ogc.org/is/19-008r4/19-008r4.html#_requirements_class_geokeydirectorytag + let projected_crs = decoder + .get_tag_u16_vec(Tag::GeoKeyDirectoryTag) + .ok() + .and_then(|geokeys| { + let mut chunks = geokeys.chunks_exact(4); + + // Validate header: version=1, revision=1.0, with at least one key + match chunks.next()? { + [1, 1, 0, n_keys] if *n_keys > 0 => {} + _ => return None, + } + + // Search for ProjectedCRSGeoKey (3072) + // See: https://docs.ogc.org/is/19-008r4/19-008r4.html#_requirements_class_projectedcrsgeokey + chunks.find_map(|chunk| match chunk { + [3072, _, _, value] => Some(*value), + _ => None, + }) + }); + ModelInfo { pixel_scale, tie_points, transformation, + projected_crs, } } } diff --git a/martin-core/src/tiles/cog/source.rs b/martin-core/src/tiles/cog/source.rs index 7b8add6c7..6f85d9974 100644 --- a/martin-core/src/tiles/cog/source.rs +++ b/martin-core/src/tiles/cog/source.rs @@ -5,17 +5,23 @@ use std::path::{Path, PathBuf}; use std::vec; use async_trait::async_trait; -use martin_tile_utils::{Format, TileCoord, TileData, TileInfo}; +use martin_tile_utils::{ + EARTH_CIRCUMFERENCE, MAX_ZOOM, TileCoord, TileData, TileInfo, webmercator_to_wgs84, +}; +use serde_json::Value; use tiff::decoder::{ChunkType, Decoder}; -use tiff::tags::Tag::{self, GdalNodata}; -use tilejson::{TileJSON, tilejson}; -use tracing::warn; +use tiff::tags::Tag::{self}; +use tiff::tags::{CompressionMethod, PlanarConfiguration}; +use tilejson::{Bounds, Center, TileJSON, tilejson}; use crate::tiles::cog::CogError; -use crate::tiles::cog::image::Image; +use crate::tiles::cog::image::{COMPRESSION_WEBP, Image}; use crate::tiles::cog::model::ModelInfo; use crate::tiles::{MartinCoreResult, Source, UrlQuery}; +/// Maximum allowed difference from a matching `WebMercatorQuad` tile matrix zoom level. +pub const MAX_RESOLUTION_ERROR: f64 = 1e-12; + /// Tile source that reads from `Cloud Optimized GeoTIFF` files. #[derive(Clone, Debug)] pub struct CogSource { @@ -23,34 +29,22 @@ pub struct CogSource { path: PathBuf, min_zoom: u8, max_zoom: u8, - _model: ModelInfo, - // The geo coords of pixel(0, 0, 0) ordering in [x, y, z] - _origin: [f64; 3], - // [minx, miny, maxx, maxy] in its model space coordinate system - _extent: [f64; 4], images: HashMap, - nodata: Option, tilejson: TileJSON, tileinfo: TileInfo, } impl CogSource { - #[expect(clippy::cast_possible_truncation)] /// Creates a new COG tile source from a file path. + #[allow(clippy::too_many_lines)] pub fn new(id: String, path: PathBuf) -> Result { - let tileinfo = TileInfo::new(Format::Png, martin_tile_utils::Encoding::Uncompressed); let tif_file = File::open(&path).map_err(|e: std::io::Error| CogError::IoError(e, path.clone()))?; let mut decoder = Decoder::new(tif_file) .map_err(|e| CogError::InvalidTiffFile(e, path.clone()))? - .with_limits(tiff::decoder::Limits::unlimited()); + .with_limits(tiff::decoder::Limits::default()); let model = ModelInfo::decode(&mut decoder, &path); verify_requirements(&mut decoder, &model, &path.clone())?; - let nodata: Option = if let Ok(no_data) = decoder.get_tag_ascii_string(GdalNodata) { - no_data.parse().ok() - } else { - None - }; let origin = get_origin( model.tie_points.as_deref(), model.transformation.as_deref(), @@ -75,59 +69,121 @@ impl CogSource { let mut ifd_index = 0; loop { - let is_image = decoder - .get_tag_u32(Tag::NewSubfileType) - .map_or_else(|_| true, |v| v & 4 != 4); // see https://www.verypdf.com/document/tiff6/pg_0036.htm - if is_image { - // TODO: We should not ignore mask in the next PRs - images.push(get_image(&mut decoder, &path, ifd_index)?); - } else { - warn!( - "A subfile of {} is ignored in the tiff file as Martin currently does not support mask subfile in tiff. IFD={ifd_index}", - path.display(), - ); + if !decoder.more_images() { + break; } - - ifd_index += 1; - - let next_res = decoder.seek_to_image(ifd_index); - if next_res.is_err() { - // TODO: add warn!() here + if decoder.seek_to_image(ifd_index).is_err() { break; } + + let subfile_type_tag = decoder.get_tag_u32(Tag::NewSubfileType); + let is_source_image = subfile_type_tag.is_err(); + let is_reduced_resolution_subfile = + subfile_type_tag.map_or_else(|_| false, |v| v == 0b001); + if is_source_image || is_reduced_resolution_subfile { + let image_width = dimensions_in_pixel(&mut decoder, &path, ifd_index)?.0; + let resolution = full_width / f64::from(image_width); + images.push(get_image( + &mut decoder, + &path, + ifd_index, + origin, + resolution, + )?); + } + + ifd_index += 1; } - let min_zoom = 0; - let max_zoom = (images.len() - 1) as u8; + let images: HashMap = images .into_iter() - .enumerate() - .map(|(idx, image)| { - let zoom = max_zoom.saturating_sub(idx as u8); - (zoom, image) - }) + .map(|image| (image.zoom_level(), image)) .collect(); - let tilejson = tilejson! { + + let mut tile_size = None; + for image in images.values() { + match tile_size { + Some(current_tile_size) => { + if current_tile_size != image.tile_size() { + Err(CogError::InconsistentTiling(path.clone()))?; + } + } + None => { + tile_size = Some(image.tile_size()); + } + } + } + let min_zoom = *images + .keys() + .min() + .ok_or_else(|| CogError::NoImagesFound(path.clone()))?; + let max_zoom = *images + .keys() + .max() + .ok_or_else(|| CogError::NoImagesFound(path.clone()))?; + let min = webmercator_to_wgs84(extent[0], extent[1]); + let max = webmercator_to_wgs84(extent[2], extent[3]); + let center = webmercator_to_wgs84( + f64::midpoint(extent[0], extent[2]), + f64::midpoint(extent[1], extent[3]), + ); + let first_img = images + .values() + .next() + .ok_or_else(|| CogError::NoImagesFound(path.clone()))?; + let output_format = first_img.output_format().ok_or_else(|| { + CogError::NotSupportedCompression(first_img.compression(), path.clone()) + })?; + + let mut tilejson = tilejson! { tiles: vec![], + bounds: Bounds::new( + min.0, + min.1, + max.0, + max.1, + ), + center: Center{ + longitude: center.0, + latitude: center.1, + zoom: u8::midpoint(max_zoom, min_zoom), + }, minzoom: min_zoom, - maxzoom: max_zoom + maxzoom: max_zoom, }; + tilejson + .other + .insert("tileSize".to_string(), Value::from(tile_size)); + tilejson + .other + .insert("format".to_string(), Value::from(output_format.to_string())); + Ok(CogSource { id, path, min_zoom, max_zoom, - // FIXME: these are not yet used - _model: model, - _origin: origin, - _extent: extent, images, - nodata, tilejson, - tileinfo, + tileinfo: TileInfo::new(output_format, martin_tile_utils::Encoding::Internal), }) } } +/// Find a zoom level of [WebMercatorQuad](https://docs.ogc.org/is/17-083r2/17-083r2.html#72) that +/// is within the error tolerance difference from expected `WebMercatorQuad` zoom levels. +fn web_mercator_zoom(model_resolution: f64, tile_size: u32) -> Option { + for z in 0..=MAX_ZOOM { + let resolution_in_web_mercator = + EARTH_CIRCUMFERENCE / f64::from(1_u32 << z) / f64::from(tile_size); + if (model_resolution - resolution_in_web_mercator).abs() < MAX_RESOLUTION_ERROR { + return Some(z); + } + } + + None +} + #[async_trait] impl Source for CogSource { fn get_id(&self) -> &str { @@ -163,17 +219,15 @@ impl Source for CogSource { if xyz.z < self.min_zoom || xyz.z > self.max_zoom { return Ok(Vec::new()); } - let tif_file = - File::open(&self.path).map_err(|e| CogError::IoError(e, self.path.clone()))?; - let mut decoder = - Decoder::new(tif_file).map_err(|e| CogError::InvalidTiffFile(e, self.path.clone()))?; - decoder = decoder.with_limits(tiff::decoder::Limits::unlimited()); - let image = self.images.get(&(xyz.z)).ok_or_else(|| { CogError::ZoomOutOfRange(xyz.z, self.path.clone(), self.min_zoom, self.max_zoom) })?; - let bytes = image.get_tile(&mut decoder, xyz, self.nodata, &self.path)?; + let file = File::open(&self.path).map_err(|e| CogError::IoError(e, self.path.clone()))?; + let mut decoder = Decoder::new(file) + .map_err(|e| CogError::InvalidTiffFile(e, self.path.clone()))? + .with_limits(tiff::decoder::Limits::default()); + let bytes = image.get_tile(&mut decoder, xyz, &self.path)?; Ok(bytes) } } @@ -183,14 +237,12 @@ fn verify_requirements( model: &ModelInfo, path: &Path, ) -> Result<(), CogError> { - let chunk_type = decoder.get_chunk_type(); // see requirement 2 in https://docs.ogc.org/is/21-026/21-026.html#_tiles - if chunk_type != ChunkType::Tile { + if decoder.get_chunk_type() != ChunkType::Tile { Err(CogError::NotSupportedChunkType(path.to_path_buf()))?; } - // see https://docs.ogc.org/is/21-026/21-026.html#_planar_configuration_considerations and https://www.verypdf.com/document/tiff6/pg_0038.htm - // we might support planar configuration 2 in the future + // see note https://docs.ogc.org/is/21-026/21-026.html#_planar_configuration_considerations decoder .get_tag_unsigned(Tag::PlanarConfiguration) .map_err(|e| { @@ -202,7 +254,7 @@ fn verify_requirements( ) }) .and_then(|config| { - if config == 1 { + if config == PlanarConfiguration::Chunky.to_u16() { Ok(()) } else { Err(CogError::PlanarConfigurationNotSupported( @@ -213,19 +265,48 @@ fn verify_requirements( } })?; - let color_type = decoder + decoder .colortype() - .map_err(|e| CogError::InvalidTiffFile(e, path.to_path_buf()))?; + .map_err(|e| CogError::InvalidTiffFile(e, path.to_path_buf())) + .and_then(|color_type| { + if matches!( + color_type, + tiff::ColorType::RGB(8) | tiff::ColorType::RGBA(8) | tiff::ColorType::YCbCr(_), + ) { + Ok(()) + } else { + Err(CogError::NotSupportedColorTypeAndBitDepth( + color_type, + path.to_path_buf(), + )) + } + })?; - if !matches!( - color_type, - tiff::ColorType::RGB(8) | tiff::ColorType::RGBA(8) - ) { - Err(CogError::NotSupportedColorTypeAndBitDepth( - color_type, - path.to_path_buf(), - ))?; - } + decoder + .get_tag_unsigned(Tag::Compression) + .map_err(|e| { + CogError::TagsNotFound(e, vec![Tag::Compression.to_u16()], 0, path.to_path_buf()) + }) + .and_then(|compression: u16| { + if let Some( + CompressionMethod::ModernJPEG + | CompressionMethod::Deflate + | CompressionMethod::LZW + | CompressionMethod::None, + ) = CompressionMethod::from_u16(compression) + { + Ok(()) + } else { + if compression == COMPRESSION_WEBP { + return Ok(()); + } + + Err(CogError::NotSupportedCompression( + compression, + path.to_path_buf(), + )) + } + })?; match (&model.pixel_scale, &model.tie_points, &model.transformation) { (Some(pixel_scale), Some(tie_points), _) @@ -254,6 +335,13 @@ fn verify_requirements( _ => Err(CogError::InvalidGeoInformation(path.to_path_buf(), "Either a valid transformation (tag 34264) or both pixel scale (tag 33550) and tie points (tag 33922) must be provided".to_string())), }?; + if model.projected_crs.is_none_or(|crs| crs != 3857u16) { + return Err(CogError::InvalidGeoInformation( + path.to_path_buf(), + "The projected coordinate reference system must be EPSG:3857".to_string(), + )); + } + Ok(()) } @@ -261,13 +349,51 @@ fn get_image( decoder: &mut Decoder, path: &Path, ifd_index: usize, + origin: [f64; 3], + resolution: f64, ) -> Result { - let (tile_width, tile_height) = (decoder.chunk_dimensions().0, decoder.chunk_dimensions().1); + let tile_size = decoder.chunk_dimensions().0; let (image_width, image_length) = dimensions_in_pixel(decoder, path, ifd_index)?; - let tiles_across = image_width.div_ceil(tile_width); - let tiles_down = image_length.div_ceil(tile_height); + let zoom_level = web_mercator_zoom(resolution, tile_size) + .ok_or(CogError::GetOriginFailed(path.to_path_buf()))?; + let tiles_origin = get_tiles_origin(tile_size, resolution, [origin[0], origin[1]]) + .ok_or(CogError::GetOriginFailed(path.to_path_buf()))?; + let tiles_across = image_width.div_ceil(tile_size); + let tiles_down = image_length.div_ceil(tile_size); + + // Get compression method for this IFD + let compression: u16 = decoder.get_tag_unsigned(Tag::Compression).unwrap_or(1); // Default to None (1) if not found + + Ok(Image::new( + ifd_index, + zoom_level, + tiles_origin, + tiles_across, + tiles_down, + tile_size, + compression, + )) +} + +/// Calculates the origin of the first tile +fn get_tiles_origin(tile_size: u32, resolution: f64, origin: [f64; 2]) -> Option<(u32, u32)> { + let tile_size_mercator_metres = f64::from(tile_size) * resolution; + let xf = ((origin[0] + (EARTH_CIRCUMFERENCE / 2.0)) / tile_size_mercator_metres).floor(); + let yf = (((EARTH_CIRCUMFERENCE / 2.0) - origin[1]) / tile_size_mercator_metres).floor(); + let tile_origin_x = if xf.is_finite() && xf >= 0.0 && xf <= f64::from(u32::MAX) { + #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)] + Some(xf as u32) + } else { + None + }?; + let tile_origin_y = if yf.is_finite() && yf >= 0.0 && yf <= f64::from(u32::MAX) { + #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)] + Some(yf as u32) + } else { + None + }?; - Ok(Image::new(ifd_index, tiles_across, tiles_down)) + Some((tile_origin_x, tile_origin_y)) } /// Gets image pixel dimensions from TIFF decoder @@ -418,36 +544,89 @@ fn get_extent( #[cfg(test)] mod tests { - use std::fs::File; + use crate::tiles::cog::CogSource; + use rstest::rstest; use std::path::Path; + use tilejson::{Bounds, Center}; - use insta::assert_yaml_snapshot; - use rstest::rstest; - use tiff::decoder::Decoder; - - use crate::tiles::cog::model::ModelInfo; - - #[test] - fn can_get_model_info() { - let path = Path::new("../tests/fixtures/cog/rgb_u8.tif"); - let tif_file = File::open(path).unwrap(); - let mut decoder = Decoder::new(tif_file).unwrap(); - let model = ModelInfo::decode(&mut decoder, path); - - assert_yaml_snapshot!(model.pixel_scale, @r" - - 10 - - 10 - - 0 - "); - assert_yaml_snapshot!(model.tie_points, @r" - - 0 - - 0 - - 0 - - 1620750.2508 - - 4277012.7153 - - 0 - "); - assert_yaml_snapshot!(model.transformation, @"~"); + #[rstest] + #[case("usda_naip_256_lzw_z3".to_string(), Center { + longitude: -121.346_740_722_656_22, + latitude: 41.967_659_203_678_16, + zoom: 17, + }, Bounds { + left: -121.349_487_304_687_46, + top: 41.971_743_363_279_65, + right: -121.343_994_140_624_97, + bottom: 41.963_574_782_225_15, + }, 16, 18, 256, "png")] + #[case("usda_naip_512_deflate_z2".to_string(), Center { + longitude: -121.346_740_722_656_22, + latitude: 41.967_659_203_678_16, + zoom: 16, + }, Bounds { + left: -121.349_487_304_687_46, + top: 41.971_743_363_279_65, + right: -121.343_994_140_624_97, + bottom: 41.963_574_782_225_15, + }, 16, 17, 512, "png")] + #[case("usda_naip_512_jpeg_z5".to_string(), Center { + longitude: -121.354_980_468_749_96, + latitude: 41.967_659_203_678_146, + zoom: 15, + }, Bounds { + left: -121.376_953_124_999_94, + top: 42.000_325_148_316_2, + right: -121.333_007_812_499_96, + bottom: 41.934_976_500_546_576, + }, 13, 17, 512, "jpeg")] + #[case("usda_naip_512_webp_z5".to_string(), Center { + longitude: -121.354_980_468_749_96, + latitude: 41.967_659_203_678_146, + zoom: 15, + }, Bounds { + left: -121.376_953_124_999_94, + top: 42.000_325_148_316_2, + right: -121.333_007_812_499_96, + bottom: 41.934_976_500_546_576, + }, 13, 17, 512, "webp")] + #[case("usda_naip_128_none_z2".to_string(), Center { + longitude: -121.343_650_817_871_05, + latitude: 41.968_680_268_127_26, + zoom: 18, + }, Bounds { + left: -121.343_994_140_624_97, + top: 41.969_190_794_214_65, + right: -121.343_307_495_117_16, + bottom: 41.968_169_737_948_43, + }, 18, 19, 128, "png")] + fn can_generate_tilejson_from_source( + #[case] cog_file: String, + #[case] center: Center, + #[case] bounds: Bounds, + #[case] min_zoom: u8, + #[case] max_zoom: u8, + #[case] tile_size: u32, + #[case] format: String, + ) { + let path = format!("../tests/fixtures/cog/{cog_file}.tif"); + let source = CogSource::new(cog_file, Path::new(&path).to_path_buf()).unwrap(); + + assert_eq!(source.max_zoom, max_zoom); + assert_eq!(source.min_zoom, min_zoom); + assert_eq!( + source.tilejson.center.unwrap().to_string(), + center.to_string() + ); + assert_eq!( + source.tilejson.bounds.unwrap().to_string(), + bounds.to_string() + ); + assert_eq!(source.tilejson.other.get("tileSize").unwrap(), tile_size); + assert_eq!( + source.tilejson.other.get("format").unwrap().as_str(), + Some(format.as_str()) + ); } #[rstest] @@ -585,4 +764,26 @@ mod tests { assert_abs_diff_eq!(full_resolution[0], expected[0], epsilon = 0.00001); assert_abs_diff_eq!(full_resolution[1], expected[1], epsilon = 0.00001); } + + #[rstest] + #[case(156_543.033_928_041_03, 256, Some(0))] + #[case(78_271.516_964_020_51, 256, Some(1))] + #[case(39_135.758_482_010_26, 256, Some(2))] + #[case(19_567.879_241_005_13, 256, Some(3))] + #[case(78_271.516_964_020_51, 512, Some(0))] + #[case(39_135.758_482_010_26, 512, Some(1))] + #[case(19_567.879_241_005_13, 512, Some(2))] + #[case(9_783.939_620_502_564, 512, Some(3))] + #[case(39_135.758_482_010_26, 1024, Some(0))] + #[case(19_567.879_241_005_13, 1024, Some(1))] + #[case(9_783.939_620_502_564, 1024, Some(2))] + #[case(4_891.969_810_251_282, 1024, Some(3))] + fn can_get_web_mercator_zoom( + #[case] resolution: f64, + #[case] tile_size: u32, + #[case] expected_zoom: Option, + ) { + use crate::tiles::cog::source::web_mercator_zoom; + assert_eq!(web_mercator_zoom(resolution, tile_size), expected_zoom); + } } diff --git a/tests/fixtures/cog/rgb_u8.tif b/tests/fixtures/cog/rgb_u8.tif deleted file mode 100644 index b325dbfab..000000000 Binary files a/tests/fixtures/cog/rgb_u8.tif and /dev/null differ diff --git a/tests/fixtures/cog/rgba_u8.tif b/tests/fixtures/cog/rgba_u8.tif deleted file mode 100644 index 0ec52ccdf..000000000 Binary files a/tests/fixtures/cog/rgba_u8.tif and /dev/null differ diff --git a/tests/fixtures/cog/rgba_u8_nodata.tiff b/tests/fixtures/cog/rgba_u8_nodata.tiff deleted file mode 100644 index f5a5aaf41..000000000 Binary files a/tests/fixtures/cog/rgba_u8_nodata.tiff and /dev/null differ diff --git a/tests/fixtures/cog/usda_naip_128_none_z2.tif b/tests/fixtures/cog/usda_naip_128_none_z2.tif new file mode 100644 index 000000000..1f35d693f Binary files /dev/null and b/tests/fixtures/cog/usda_naip_128_none_z2.tif differ diff --git a/tests/fixtures/cog/usda_naip_256_lzw_z3.tif b/tests/fixtures/cog/usda_naip_256_lzw_z3.tif new file mode 100644 index 000000000..4cd31e042 Binary files /dev/null and b/tests/fixtures/cog/usda_naip_256_lzw_z3.tif differ diff --git a/tests/fixtures/cog/usda_naip_512_deflate_z2.tif b/tests/fixtures/cog/usda_naip_512_deflate_z2.tif new file mode 100644 index 000000000..25f750444 Binary files /dev/null and b/tests/fixtures/cog/usda_naip_512_deflate_z2.tif differ diff --git a/tests/fixtures/cog/usda_naip_512_jpeg_z5.tif b/tests/fixtures/cog/usda_naip_512_jpeg_z5.tif new file mode 100644 index 000000000..42e9499b6 Binary files /dev/null and b/tests/fixtures/cog/usda_naip_512_jpeg_z5.tif differ diff --git a/tests/fixtures/cog/usda_naip_512_webp_z5.tif b/tests/fixtures/cog/usda_naip_512_webp_z5.tif new file mode 100644 index 000000000..d5c310002 Binary files /dev/null and b/tests/fixtures/cog/usda_naip_512_webp_z5.tif differ