"""rio_cogeo.cogeo: translate a file to a cloud optimized geotiff.""" import os import pathlib import sys import tempfile import warnings from contextlib import ExitStack, contextmanager from typing import Any, Dict, List, Literal, Optional, Sequence, TextIO, Tuple, Union import click import morecantile import rasterio from rasterio.enums import ColorInterp from rasterio.enums import Resampling as ResamplingEnums from rasterio.env import GDALVersion from rasterio.io import DatasetReader, DatasetWriter, MemoryFile from rasterio.rio.overview import get_maximum_overview_level from rasterio.shutil import copy from rasterio.transform import from_gcps as transform_from_gcps from rasterio.vrt import WarpedVRT from rio_cogeo import models, utils from rio_cogeo.errors import IncompatibleOptions, NodataAlphaMaskWarning IN_MEMORY_THRESHOLD = int(os.environ.get("IN_MEMORY_THRESHOLD", 10980 * 10980)) @contextmanager def TemporaryRasterFile(dst_path: Union[str, pathlib.PurePath], suffix: str = ".tif"): """Create temporary file.""" # For local file we should create temporary file in the same directory tmpdir = ( pathlib.Path(dst_path).parent if pathlib.Path(dst_path).parent.is_dir() else None ) fileobj = tempfile.NamedTemporaryFile(dir=tmpdir, suffix=suffix, delete=False) fileobj.close() try: yield fileobj finally: os.remove(fileobj.name) # RasterIO() resampling method. # ref: https://gdal.org/api/raster_c_api.html#_CPPv418GDALRIOResampleAlg RIOResampling = Literal[ "nearest", "bilinear", "cubic", "cubic_spline", "lanczos", "average", "mode", "gauss", "rms", ] # WarpKernel resampling method. # ref: https://gdal.org/api/gdalwarp_cpp.html#_CPPv4N14GDALWarpKernel9eResampleE WarpResampling = Literal[ "nearest", "bilinear", "cubic", "cubic_spline", "lanczos", "average", "mode", "sum", "rms", ] def cog_translate( # noqa: C901 source: Union[str, pathlib.PurePath, DatasetReader, DatasetWriter, WarpedVRT], dst_path: Union[str, pathlib.PurePath], dst_kwargs: Dict, indexes: Optional[Sequence[int]] = None, nodata: Optional[Union[str, int, float]] = None, dtype: Optional[str] = None, add_mask: bool = False, overview_level: Optional[int] = None, overview_resampling: RIOResampling = "nearest", web_optimized: bool = False, tms: Optional[morecantile.TileMatrixSet] = None, zoom_level_strategy: str = "auto", zoom_level: Optional[int] = None, aligned_levels: Optional[int] = None, resampling: WarpResampling = "nearest", in_memory: Optional[bool] = None, config: Optional[Dict] = None, allow_intermediate_compression: bool = False, forward_band_tags: bool = False, forward_ns_tags: bool = False, quiet: bool = False, progress_out: Optional[TextIO] = None, temporary_compression: str = "DEFLATE", colormap: Optional[Dict] = None, additional_cog_metadata: Optional[Dict] = None, use_cog_driver: bool = False, decimation_base: int = 2, ): """ Create Cloud Optimized Geotiff. Parameters ---------- source : str, PathLike object or rasterio.io.DatasetReader A dataset path, URL or rasterio.io.DatasetReader object. Will be opened in "r" mode. dst_path : str or PathLike object An output dataset path or or PathLike object. Will be opened in "w" mode. dst_kwargs: dict Output dataset creation options. indexes : tuple or int, optional Raster band indexes to copy. nodata, int, optional Overwrite nodata masking values for input dataset. dtype: str, optional Overwrite output data type. Default will be the input data type. add_mask, bool, optional Force output dataset creation with a mask. overview_level : int, optional (default: None) COGEO overview (decimation) level. By default, inferred from data size. overview_resampling : str, optional (default: "nearest") RasterIO Resampling algorithm for overviews web_optimized: bool, optional (default: False) Create web-optimized cogeo. Deprecated: Behavior changed since 5.1.2. See deprecation warning at the bottom of function doc. tms: morecantile.TileMatrixSet, optional TileMatrixSet to use for reprojection, resolution and alignment. zoom_level_strategy: str, optional (default: auto) Strategy to determine zoom level (same as in GDAL 3.2). Used only when `tms` is not None. LOWER will select the zoom level immediately below the theoretical computed non-integral zoom level, leading to subsampling. On the contrary, UPPER will select the immediately above zoom level, leading to oversampling. Defaults to AUTO which selects the closest zoom level. ref: https://gdal.org/drivers/raster/cog.html#raster-cog zoom_level: int, optional. Zoom level number (starting at 0 for coarsest zoom level). If this option is specified, `--zoom-level-strategy` is ignored. In any case, it is used only when `tms` is not None. aligned_levels: int, optional. Number of overview levels for which GeoTIFF tile and tiles defined in the tiling scheme match. Used only when `tms` is not None. Default is to use the maximum overview levels. Note: GDAL use number of resolution levels instead of overview levels. resampling : str, optional (default: "nearest") Warp Resampling algorithm. in_memory: bool, optional Force processing raster in memory (default: process in memory if small) config : dict Rasterio Env options. allow_intermediate_compression: bool, optional (default: False) Allow intermediate file compression to reduce memory/disk footprint. Note: This could reduce the speed of the process. Ref: https://github.com/cogeotiff/rio-cogeo/issues/103 forward_band_tags: bool, optional Forward band tags to output bands. Ref: https://github.com/cogeotiff/rio-cogeo/issues/19 forward_ns_tags: bool, optional Forward namespaces tags to output dataset. quiet: bool, optional (default: False) Mask processing steps. progress_out: TextIO, optional Output progress steps to alternative text buffer. Quiet must be False. temporary_compression: str, optional Compression used for the intermediate file, default is deflate. colormap: dict, optional Overwrite or add a colormap to the output COG. additional_cog_metadata: dict, optional Additional dataset metadata to add to the COG. use_cog_driver: bool, optional (default: False) Use GDAL COG driver if set to True. COG driver is available starting with GDAL 3.1. decimation_base: int, default: 2 How overviews are divided at each zoom level (default is 2). Must be greater than 1. Also requires that `overview_level` is provided for `decimation_base` values greater than 2. .. deprecated:: 5.1.2 `web_optimized` is deprecated in favor of `tms`. Previously, `tms` usage was conditioned by `web_optimized` state. The behaviour has changed to allow setting a tile matrix set without the need of `web_optimized` flag. `web_optimized` now only serve to activate a default `WebMercatorQuad` tile matrix set. Set to be removed 6.0. """ if web_optimized: warnings.warn( "'web_optomized' option is deprecated and will be removed in 6.0. Please use the `tms` option", DeprecationWarning, ) tms = tms or morecantile.tms.get("WebMercatorQuad") if decimation_base <= 1: raise ValueError( "Decimation base must be greater than 1 for building overviews." ) elif decimation_base > 2 and overview_level is None: raise ValueError( "Decimation base values greater than 2 require that overview_level is defined." ) dst_kwargs = dst_kwargs.copy() if isinstance(indexes, int): indexes = (indexes,) config = config or {} with rasterio.Env(**config): with ExitStack() as ctx: if isinstance(source, (DatasetReader, DatasetWriter, WarpedVRT)): src_dst = source else: src_dst = ctx.enter_context(rasterio.open(source)) meta = src_dst.meta indexes = indexes if indexes else src_dst.indexes nodata = nodata if nodata is not None else src_dst.nodata dtype = dtype if dtype else src_dst.dtypes[0] alpha = utils.has_alpha_band(src_dst) mask = utils.has_mask_band(src_dst) if nodata is not None and (alpha or mask): warnings.warn( "Input dataset has both a nodata value and internal alpha/mask band. Nodata value will be prioritized.", NodataAlphaMaskWarning, ) if colormap and len(indexes) > 1: raise IncompatibleOptions( "Cannot add a colormap for multiple bands data." ) # we need to remove the `alpha band` index from the output data # when we translate the band to an internal mask if alpha and add_mask: indexes = tuple( x for x in indexes if x in utils.non_alpha_indexes(src_dst) ) if not add_mask and ( (nodata is not None or alpha) and dst_kwargs.get("compress", "").lower() == "jpeg" ): warnings.warn( "Nodata/Alpha band will be translated to an internal mask band.", ) add_mask = True indexes = ( utils.non_alpha_indexes(src_dst) if len(indexes) not in [1, 3] else indexes ) src_indexes = indexes tilesize = min(int(dst_kwargs["blockxsize"]), int(dst_kwargs["blockysize"])) vrt_params = { "dtype": dtype, "width": src_dst.width, "height": src_dst.height, "resampling": ResamplingEnums[resampling], } if src_dst.gcps[1]: vrt_params.update( { "add_alpha": True, "src_crs": src_dst.gcps[1], "src_transform": transform_from_gcps(src_dst.gcps[0]), } ) if nodata is not None: vrt_params.update( {"nodata": nodata, "add_alpha": False, "src_nodata": nodata} ) elif alpha: vrt_params.update({"add_alpha": False}) elif mask: vrt_params.update({"add_alpha": True}) if tms: wo_params = utils.get_web_optimized_params( src_dst, zoom_level_strategy=zoom_level_strategy, zoom_level=zoom_level, aligned_levels=aligned_levels, tms=tms, ) vrt_params.update(**wo_params) if vrt_params.get("add_alpha", False) and not (add_mask or mask): indexes = tuple(indexes) + (src_dst.count + 1,) with WarpedVRT(src_dst, **vrt_params) as vrt_dst: meta = vrt_dst.meta meta["count"] = len(indexes) if add_mask: meta.pop("nodata", None) meta.pop("alpha", None) if ( dst_kwargs.get("photometric", "").upper() == "YCBCR" and meta["count"] == 1 ): warnings.warn( "PHOTOMETRIC=YCBCR not supported on a 1-band raster" " and has been set to 'MINISBLACK'" ) dst_kwargs["photometric"] = "MINISBLACK" meta.update(**dst_kwargs) meta.pop("compress", None) meta.pop("photometric", None) if allow_intermediate_compression: meta["compress"] = temporary_compression if in_memory is None: in_memory = vrt_dst.width * vrt_dst.height < IN_MEMORY_THRESHOLD if in_memory: tmpfile = ctx.enter_context(MemoryFile()) tmp_dst = ctx.enter_context(tmpfile.open(**meta)) else: tmpfile = ctx.enter_context(TemporaryRasterFile(dst_path)) tmp_dst = ctx.enter_context( rasterio.open(tmpfile.name, "w", **meta) ) # Transfer color interpolation if len(indexes) == 1 and ( vrt_dst.colorinterp[indexes[0] - 1] is not ColorInterp.palette ): tmp_dst.colorinterp = [ColorInterp.gray] else: tmp_dst.colorinterp = [vrt_dst.colorinterp[b - 1] for b in indexes] if colormap: if tmp_dst.colorinterp[0] is not ColorInterp.palette: tmp_dst.colorinterp = [ColorInterp.palette] warnings.warn( "Dataset color interpretation was set to `Palette`" ) tmp_dst.write_colormap(1, colormap) elif tmp_dst.colorinterp[0] is ColorInterp.palette: try: tmp_dst.write_colormap(1, vrt_dst.colormap(1)) except ValueError: warnings.warn( "Dataset has `Palette` color interpretation" " but is missing colormap information" ) wind = list(tmp_dst.block_windows(1)) if not quiet: click.echo("Reading input: {}".format(source), err=True) fout = ctx.enter_context(open(os.devnull, "w")) if quiet else sys.stderr if quiet is False and progress_out: fout = progress_out with click.progressbar(wind, file=fout, show_percent=True) as windows: # type: ignore for _, w in windows: matrix = vrt_dst.read(window=w, indexes=indexes) tmp_dst.write(matrix, window=w) if add_mask or mask: # Cast mask to uint8 to fix rasterio 1.1.2 error (ref #115) mask_value = vrt_dst.dataset_mask(window=w).astype("uint8") tmp_dst.write_mask(mask_value, window=w) if overview_level is None: overview_level = get_maximum_overview_level( vrt_dst.width, vrt_dst.height, minsize=tilesize ) if not quiet and overview_level: click.echo("Adding overviews...", err=True) overviews = [decimation_base**j for j in range(1, overview_level + 1)] tmp_dst.build_overviews(overviews, ResamplingEnums[overview_resampling]) if not quiet: click.echo("Updating dataset tags...", err=True) for i, b in enumerate(src_indexes): tmp_dst.set_band_description(i + 1, src_dst.descriptions[b - 1]) if forward_band_tags: tmp_dst.update_tags(bidx=i + 1, **src_dst.tags(b)) tags = src_dst.tags() tags.update( { "OVERVIEW_RESAMPLING": ResamplingEnums[ overview_resampling ].name.upper() } ) if tms: default_zoom = tms.zoom_for_res( max(tmp_dst.res), max_z=30, zoom_level_strategy=zoom_level_strategy, ) tags.update( { "TILING_SCHEME_NAME": tms.id or "CUSTOM", "TILING_SCHEME_ZOOM_LEVEL": ( zoom_level if zoom_level is not None else default_zoom ), } ) if aligned_levels: tags["TILING_SCHEME_ALIGNED_LEVELS"] = aligned_levels if additional_cog_metadata: tags.update(**additional_cog_metadata) if forward_ns_tags: namespaces = src_dst.tag_namespaces() for ns in namespaces: if ns in ["DERIVED_SUBDATASETS", "IMAGE_STRUCTURE"]: continue tmp_dst.update_tags(ns=ns, **src_dst.tags(ns=ns)) tmp_dst.update_tags(**tags) tmp_dst._set_all_scales([vrt_dst.scales[b - 1] for b in indexes]) tmp_dst._set_all_offsets([vrt_dst.offsets[b - 1] for b in indexes]) if not quiet: click.echo("Writing output to: {}".format(dst_path), err=True) if use_cog_driver: if not GDALVersion.runtime().at_least("3.1"): raise Exception( "GDAL 3.1 or above required to use the COG driver." ) dst_kwargs["driver"] = "COG" if add_mask and dst_kwargs.get("compress", "") != "JPEG": warnings.warn( "With GDAL COG driver, mask band will be translated to an alpha band." ) if overview_level == 0: dst_kwargs["overviews"] = "NONE" dst_kwargs["overview_resampling"] = overview_resampling dst_kwargs["warp_resampling"] = resampling dst_kwargs["blocksize"] = tilesize dst_kwargs.pop("blockxsize", None) dst_kwargs.pop("blockysize", None) dst_kwargs.pop("tiled", None) dst_kwargs.pop("photometric", None) copy(tmp_dst, dst_path, **dst_kwargs) else: copy(tmp_dst, dst_path, copy_src_overviews=True, **dst_kwargs) def cog_validate( # noqa: C901 src_path: Union[str, pathlib.PurePath], strict: bool = False, config: Optional[Dict] = None, quiet: bool = False, ) -> Tuple[bool, List[str], List[str]]: """ Validate Cloud Optimized Geotiff. This script is the rasterio equivalent of https://svn.osgeo.org/gdal/trunk/gdal/swig/python/samples/validate_cloud_optimized_geotiff.py Parameters ---------- src_path: str or PathLike object A dataset path or URL. Will be opened in "r" mode. strict: bool Treat warnings as errors quiet: bool Remove standard outputs Returns ------- is_valid: bool True is src_path is a valid COG. errors: list List of validation errors. warnings: list List of validation warnings. """ errors: List[str] = [] warnings: List[str] = [] details: Dict[str, Any] = {} config = config or {} if not GDALVersion.runtime().at_least("2.2"): raise Exception("GDAL 2.2 or above required") with rasterio.Env(**config): with rasterio.open(src_path) as src: if not src.driver == "GTiff": errors.append("The file is not a GeoTIFF") if not quiet: click.secho("The following errors were found:", fg="red", err=True) for e in errors: click.echo("- " + e, err=True) return False, errors, warnings if any(pathlib.Path(x).suffix.lower() == ".ovr" for x in src.files): errors.append( "Overviews found in external .ovr file. They should be internal" ) overviews = src.overviews(1) if src.width > 512 and src.height > 512: if src.block_shapes and src.block_shapes[0][1] == src.width: errors.append( "The file is greater than 512xH or 512xW, but is not tiled" ) if not overviews: warnings.append( "The file is greater than 512xH or 512xW, it is recommended " "to include internal overviews" ) ifd_offset = int(src.get_tag_item("IFD_OFFSET", "TIFF", bidx=1)) # Starting from GDAL 3.1, GeoTIFF and COG have ghost headers # e.g: # """ # GDAL_STRUCTURAL_METADATA_SIZE=000140 bytes # LAYOUT=IFDS_BEFORE_DATA # BLOCK_ORDER=ROW_MAJOR # BLOCK_LEADER=SIZE_AS_UINT4 # BLOCK_TRAILER=LAST_4_BYTES_REPEATED # KNOWN_INCOMPATIBLE_EDITION=NO # """ # # This header should be < 200bytes if ifd_offset > 300: errors.append( f"The offset of the main IFD should be < 300. It is {ifd_offset} instead" ) ifd_offsets = [ifd_offset] details["ifd_offsets"] = {} details["ifd_offsets"]["main"] = ifd_offset # Optimizations are usually invalidated when a COG is modified. # When this happens, GDAL >= 3.1 flags the modifications by updating the ghost headers. gdal_ghost_headers = src.get_tag_item("GDAL_STRUCTURAL_METADATA", "TIFF") if ( gdal_ghost_headers is not None and "KNOWN_INCOMPATIBLE_EDITION=YES" in gdal_ghost_headers ): errors.append( "This file used to have optimizations in its layout, " "but those have been, at least partly, invalidated by later changes" ) if overviews and overviews != sorted(overviews): errors.append("Overviews should be sorted") for ix, dec in enumerate(overviews): # NOTE: Size check is handled in rasterio `src.overviews` methods # https://github.com/mapbox/rasterio/blob/4ebdaa08cdcc65b141ed3fe95cf8bbdd9117bc0b/rasterio/_base.pyx # We just need to make sure the decimation level is > 1 if not dec > 1: errors.append( "Invalid Decimation {} for overview level {}".format(dec, ix) ) # Check that the IFD of descending overviews are sorted by increasing # offsets ifd_offset = int(src.get_tag_item("IFD_OFFSET", "TIFF", bidx=1, ovr=ix)) ifd_offsets.append(ifd_offset) details["ifd_offsets"]["overview_{}".format(ix)] = ifd_offset if ifd_offsets[-1] < ifd_offsets[-2]: if ix == 0: errors.append( "The offset of the IFD for overview of index {} is {}, " "whereas it should be greater than the one of the main " "image, which is at byte {}".format( ix, ifd_offsets[-1], ifd_offsets[-2] ) ) else: errors.append( "The offset of the IFD for overview of index {} is {}, " "whereas it should be greater than the one of index {}, " "which is at byte {}".format( ix, ifd_offsets[-1], ix - 1, ifd_offsets[-2] ) ) # Get blocks size block_size = src.block_shapes[0] # Extract number of blocks per row and column yblocks = (src.height + block_size[1] - 1) // block_size[1] xblocks = (src.width + block_size[0] - 1) // block_size[0] data_offsets = [] details["data_offsets"] = {} # Find the first block with a valid block_offset for y in range(yblocks): for x in range(xblocks): data_offset = get_data_offset(src, x, y) if data_offset > 0: data_offsets = [data_offset] details["data_offsets"] = {} details["data_offsets"]["main"] = data_offset break else: continue break for ix, _dec in enumerate(overviews): # Get the width and height of the overview overview_width = src.width // (_dec) overview_height = src.height // (_dec) # Extract number of blocks per row and column yblocks = (overview_height + block_size[1] - 1) // block_size[1] xblocks = (overview_width + block_size[0] - 1) // block_size[0] for y in range(yblocks): for x in range(xblocks): data_offset = get_data_offset(src, x, y, ix) if data_offset > 0: data_offset = int(data_offset) if data_offset else 0 data_offsets.append(data_offset) details["data_offsets"][ "overview_{}".format(ix) ] = data_offset break else: continue break if data_offsets[-1] != 0 and data_offsets[-1] < ifd_offsets[-1]: if len(overviews) > 0: errors.append( "The offset of the first block of the smallest overview " "should be after its IFD" ) else: errors.append( "The offset of the first block of the image should " "be after its IFD" ) for i in range(len(data_offsets) - 2, 0, -1): if data_offsets[i] < data_offsets[i + 1]: errors.append( "The offset of the first block of overview of index {} should " "be after the one of the overview of index {}".format(i - 1, i) ) if len(data_offsets) >= 2 and data_offsets[0] < data_offsets[1]: errors.append( "The offset of the first block of the main resolution image " "should be after the one of the overview of index {}".format( len(overviews) - 1 ) ) for ix, _dec in enumerate(overviews): with rasterio.open(src_path, OVERVIEW_LEVEL=ix) as ovr_dst: if ovr_dst.width > 512 and ovr_dst.height > 512: if ( ovr_dst.block_shapes and ovr_dst.block_shapes[0][1] == ovr_dst.width ): errors.append("Overview of index {} is not tiled".format(ix)) if warnings and not quiet: click.secho("The following warnings were found:", fg="yellow", err=True) for w in warnings: click.echo("- " + w, err=True) click.echo(err=True) if errors and not quiet: click.secho("The following errors were found:", fg="red", err=True) for e in errors: click.echo("- " + e, err=True) is_valid = False if errors or (warnings and strict) else True return is_valid, errors, warnings def get_data_offset(src, x, y, ix=None): """ Retrieves the data offset from the source based on the given block coordinates. Parameters: src : object The source from which to retrieve the data offset. x : int The x-coordinate of the block. y : int The y-coordinate of the block. ix : int, optional The index of the overview. Default is None. Returns: int The data offset retrieved from the source. Returns 0 if the data offset is None. """ data_offset = src.get_tag_item( "BLOCK_OFFSET_%d_%d" % (x, y), "TIFF", bidx=1, ovr=ix ) return int(data_offset) if data_offset is not None else 0 def cog_info( src_path: Union[str, pathlib.PurePath], strict: bool = False, config: Optional[Dict] = None, quiet: bool = True, ) -> models.Info: """Get general info and validate Cloud Optimized Geotiff.""" config = config or {} is_valid, validation_errors, validation_warnings = cog_validate( src_path, strict=strict, config=config, quiet=quiet ) with rasterio.Env(**config): with rasterio.open(src_path) as src_dst: driver = src_dst.driver compression = getattr(src_dst.compression, "value", src_dst.compression) colorspace = src_dst.photometric.value if src_dst.photometric else None overviews = src_dst.overviews(1) tags = {"Image Metadata": src_dst.tags()} namespaces = src_dst.tag_namespaces() for ns in namespaces: if ns in ["DERIVED_SUBDATASETS"]: continue tags.update({str.title(ns).replace("_", " "): src_dst.tags(ns=ns)}) band_metadata = { f"Band {ix}": models.BandMetadata( **{ "Description": src_dst.descriptions[ix - 1], "ColorInterp": src_dst.colorinterp[ix - 1].name, "Offset": src_dst.offsets[ix - 1], "Scale": src_dst.scales[ix - 1], "Metadata": src_dst.tags(ix), } ) for ix in src_dst.indexes } try: colormap = src_dst.colormap(1) except ValueError: colormap = None profile = models.Profile( Bands=src_dst.count, Width=src_dst.width, Height=src_dst.height, Tiled=(src_dst.block_shapes[0][1] != src_dst.width), Dtype=src_dst.dtypes[0], Interleave=( src_dst.interleaving.value if src_dst.interleaving else "UNKNOWN" ), AlphaBand=utils.has_alpha_band(src_dst), InternalMask=utils.has_mask_band(src_dst), Nodata=src_dst.nodata, ColorInterp=tuple([color.name for color in src_dst.colorinterp]), ColorMap=colormap is not None, Scales=src_dst.scales, Offsets=src_dst.offsets, ) try: crs = ( f"EPSG:{src_dst.crs.to_epsg()}" if src_dst.crs.to_epsg() else src_dst.crs.to_wkt() ) except AttributeError: crs = None minzoom: Optional[int] = None maxzoom: Optional[int] = None try: minzoom, maxzoom = utils.get_zooms(src_dst) except Exception: pass geo = models.Geo( CRS=crs, BoundingBox=tuple(src_dst.bounds), Origin=(src_dst.transform.c, src_dst.transform.f), Resolution=(src_dst.transform.a, src_dst.transform.e), MinZoom=minzoom, MaxZoom=maxzoom, ) ifds = [ models.IFD( Level=0, Width=src_dst.width, Height=src_dst.height, Blocksize=src_dst.block_shapes[0], Decimation=0, ) ] for ix, decim in enumerate(overviews): with rasterio.open(src_path, OVERVIEW_LEVEL=ix) as ovr_dst: ifds.append( models.IFD( Level=ix + 1, Width=ovr_dst.width, Height=ovr_dst.height, Blocksize=ovr_dst.block_shapes[0], Decimation=decim, ) ) return models.Info( Path=str(src_path), Driver=driver, COG=is_valid, Compression=compression, ColorSpace=colorspace, COG_errors=validation_errors or None, COG_warnings=validation_warnings or None, Profile=profile, GEO=geo, Tags=tags, Band_Metadata=band_metadata, # type: ignore [call-arg] IFD=ifds, )