Skip to content

python-remote-sensing/cog-inspector

Repository files navigation

cog-inspector

Validate whether a GeoTIFF is a proper Cloud-Optimized GeoTIFF, and explain exactly why it is not.

The problem

"Is this a COG?" is a yes/no question with an unhelpful answer. When a raster is slow to read from S3, what you actually need to know is which structural property is missing and what it costs you: a stripped file forces a full-width read for every small window; a file with no overviews forces full-resolution decoding for every zoomed-out tile request; a file whose overviews were added in place has its IFDs appended after the pixel data, so a client must seek to the end of a multi-gigabyte object before it can read a single pixel.

That last case is the nasty one. GDAL will happily open the file, rio cogeo validate may or may not mention it, and everything works fine on local disk. Over HTTP it doubles your request count on every single open.

cog-inspector reports every structural property as PASS / FAIL / WARN with the reason, separates hard COG-layout violations from performance advice, estimates the HTTP range-request cost of opening the file, and prints a gdal_translate command whose flags are derived from what actually failed.

Install

Python 3.12 or newer. Clone the repository and install the dependencies:

git clone https://github.com/python-remote-sensing/cog-inspector.git
cd cog-inspector
pip install -r requirements.txt

There is no install step — the package sits at the repository root and runs in place:

python -m cog_inspector scene.tif

If you want the shorter cog-inspector spelling used throughout this README, add an alias:

alias cog-inspector='python -m cog_inspector'

Usage

A file that is a genuine COG:

$ cog-inspector s2_b04_cog.tif
s2_b04_cog.tif
  2048x2048 px, 1 band(s), uint16, EPSG:32633
  TIFF (little-endian), 512x512 tiles, DEFLATE/HORIZONTAL, PIXEL-interleaved, 60.1 KiB
  Header: 1.2 KiB (1196 bytes), ~1 range request(s) to open

  PASS  GDAL readable
        GDAL opens the file as a raster dataset.
  PASS  Internal tiling
        Image data is tiled at 512x512.
  PASS  Block size  [advisory]
        Tile size 512x512 is a good choice for cloud reads.
  PASS  Overviews present
        2 internal overview level(s) at decimation 2, 4.
  PASS  Overview layout
        Overviews decimate by powers of two (2, 4) in decreasing size order.
  PASS  Overview depth  [advisory]
        The pyramid bottoms out at 512x512, small enough to render the whole scene from
        one level.
  PASS  Overviews tiled
        All 2 overview level(s) are tiled.
  PASS  Overviews internal
        Overviews are stored inside the file, with no sidecar .ovr.
  PASS  Header before image data
        All metadata ends at byte 1196, before the first data block at byte 1200; one
        leading range request covers the whole header.
  PASS  IFD ordering
        3 IFD(s) appear in ascending file order.
  PASS  Data block ordering  [advisory]
        Tile offsets increase monotonically and overviews precede full-resolution data.
  PASS  Header read cost  [advisory]
        The 1196 byte header fits in GDAL's initial 16384 byte read: 1 range request to
        open the file.
  PASS  Compression  [advisory]
        Compressed with DEFLATE, a good choice for cloud reads. ZSTD decompresses 2-3x
        faster at a comparable ratio if all readers support it.
  PASS  Predictor  [advisory]
        Predictor HORIZONTAL is applied before DEFLATE.
  PASS  Georeference
        Georeferenced with EPSG:32633 and an affine geotransform.

  VALID COG  --  15 passed, 0 failed, 0 warning(s)
$ echo $?
0

A file straight out of a processing script, which is not:

$ cog-inspector s2_b04_20240612.tif
s2_b04_20240612.tif
  2048x2048 px, 1 band(s), uint16, EPSG:32633
  TIFF (little-endian), stripped, DEFLATE, PIXEL-interleaved, 462.4 KiB
  Header: 8.4 KiB (8564 bytes), ~1 range request(s) to open

  PASS  GDAL readable
        GDAL opens the file as a raster dataset.
  FAIL  Internal tiling
        Image data is stored in strips of 2 row(s), not tiles. A client cannot read a
        spatial window without transferring every strip it intersects, so a small area-
        of-interest read pulls full-width rows.
  FAIL  Overviews present
        No internal overviews for a 2048x2048 image. Every zoomed-out request has to
        decode full-resolution tiles, which is the single most expensive mistake in a
        cloud raster.
  PASS  Header before image data
        All metadata ends at byte 8564, before the first data block at byte 8564; one
        leading range request covers the whole header.
  PASS  IFD ordering
        1 IFD(s) appear in ascending file order.
  PASS  Data block ordering  [advisory]
        Tile offsets increase monotonically and overviews precede full-resolution data.
  PASS  Header read cost  [advisory]
        The 8564 byte header fits in GDAL's initial 16384 byte read: 1 range request to
        open the file.
  PASS  Compression  [advisory]
        Compressed with DEFLATE, a good choice for cloud reads. ZSTD decompresses 2-3x
        faster at a comparable ratio if all readers support it.
  WARN  Predictor  [advisory]
        No predictor on uint16 data. Horizontal differencing (PREDICTOR=2) usually
        shrinks 16-bit and wider integer rasters noticeably.
  PASS  Georeference
        Georeferenced with EPSG:32633 and an affine geotransform.

  NOT A VALID COG  --  7 passed, 2 failed, 1 warning(s)

  Suggested rewrite
    gdal_translate s2_b04_20240612.tif s2_b04_20240612_cog.tif -of COG -co BLOCKSIZE=512 -co COMPRESS=DEFLATE -co PREDICTOR=YES -co OVERVIEW_RESAMPLING=AVERAGE
    rio cogeo create s2_b04_20240612.tif s2_b04_20240612_cog.tif --blocksize 512 --cog-profile deflate --overview-resampling average
    - AVERAGE resampling suits continuous data. For categorical rasters (land cover,
    cloud masks) use OVERVIEW_RESAMPLING=NEAREST or MODE instead.
    - ZSTD decompresses roughly 2-3x faster than DEFLATE at similar ratios; use -co
    COMPRESS=ZSTD if every consumer of the file has GDAL 2.3 or newer.
$ echo $?
1

Remote files

Only the header is read, using HTTP range requests — the pixel data is never downloaded:

cog-inspector https://example.com/scenes/s2_b04.tif
cog-inspector s3://sentinel-cogs/sentinel-s2-l2a-cogs/32/T/NM/2024/6/scene_B04.tif
cog-inspector /vsicurl/https://example.com/scenes/s2_b04.tif

Structured output

$ cog-inspector s2_b04_cog.tif --json | jq '.results[0].header'
{
  "size_bytes": 1196,
  "first_data_offset": 1200,
  "contiguous": true,
  "range_requests": 1,
  "chunk_bytes": 16384
}

$ cog-inspector s2_b04_cog.tif --json | jq '.results[0].overviews'
{
  "count": 2,
  "factors": [2.0, 4.0],
  "internal": true,
  "external_ovr": false,
  "all_tiled": true,
  "smallest_dimension": 512
}

Sweep a directory and list only what is broken:

cog-inspector *.tif --json | jq -r '.results[] | select(.summary.is_cog | not) | .target'

Gate a pipeline on it — --strict turns performance warnings into a non-zero exit:

cog-inspector --strict --quiet output/*.tif || { echo "not cloud-ready"; exit 1; }

As a library

from cog_inspector import inspect_target

result = inspect_target("s3://bucket/scene.tif")

if not result.is_cog:
    for check in result.failures:
        print(check.id, "-", check.message)
    print("fix:", result.fix.gdal_translate)

print(result.header.range_requests, "range request(s) to open")

How it works

rasterio and GDAL expose the raster view of a file — bands, CRS, block size, overview count. They do not expose the byte layout, and the byte layout is the entire point of the COG format. So cog-inspector does both: rasterio supplies the CRS, dtype and sidecar information, while a self-contained TIFF reader in cog_inspector/tiff.py walks the file's IFD chain directly and records where every structure physically sits.

What the TIFF parser reads

A TIFF is a header pointing at a linked list of Image File Directories. Each IFD is a list of tags; a tag either stores its value inline (4 bytes in classic TIFF, 8 in BigTIFF) or stores a file offset to it. The parser handles both, in both byte orders, for both classic TIFF (magic 42, 32-bit offsets) and BigTIFF (magic 43, 64-bit offsets), and extracts:

  • IFD offsets and their physical order. IFD 0 is the full-resolution image; subsequent IFDs are overviews (NewSubfileType bit 0) or transparency masks (bit 2).
  • TileOffsets / TileByteCounts (tags 324/325), or StripOffsets / StripByteCounts (273/279) when the file is stripped. The presence of tile tags is the tiling check.
  • The metadata extent — the highest byte offset touched by any IFD structure or any out-of-line tag value. This is the number of bytes a client must read before it knows the file's layout.
  • The first data offset — the lowest non-zero tile/strip offset. Zero offsets are skipped rather than treated as data at byte 0, because sparse COGs legitimately store 0 for empty tiles.

Why each check matters

Check What it means if it fails
Internal tiling Strips span the full image width. Reading a 512x512 window means transferring every strip it intersects — for a 10980 px Sentinel-2 band, that is ~21x more bytes than necessary.
Block size TIFF requires tile dimensions to be multiples of 16. Beyond that, tiny tiles bloat the offset arrays in the header, and huge tiles waste bandwidth per read.
Overviews present Without a pyramid, every zoomed-out request decodes full-resolution tiles. This is the single most expensive mistake in a cloud raster.
Overview layout Readers pick a level by walking the IFD chain in order. Overviews stored out of size order, or at non-power-of-two decimation, cause over-fetching and wrong-level selection.
Overviews internal A sidecar .ovr is a second object. A client fetching only the .tif gets no pyramid, and the .ovr is routinely lost during upload or copy.
Header before image data If any IFD or tag value sits after the pixel data, the client must issue an extra range request — often a seek to the end of a multi-GB object — just to learn the layout. gdal_addo on an existing file produces exactly this.
Data block ordering Tiles scattered through the file prevent GDAL from merging adjacent tile reads into one range request. Overviews written after full-resolution data mean a zoomed-out client cannot just read a short prefix.
Header read cost Modelled as ceil(header_bytes / 16384), matching GDAL's GDAL_INGESTED_BYTES_AT_OPEN and its /vsicurl/ chunk size. Two requests instead of one doubles your open latency on every read.

The range-request estimate

This is the number that actually matters for cloud reads. GDAL pulls 16 KiB when it opens a remote file. If the whole header fits in that, opening costs exactly one request. If it does not — usually because small tiles produced a very long TileOffsets array — every open pays extra round trips before touching a pixel. cog-inspector reports the header size in bytes and the resulting request count, so you can see the effect of changing block size directly.

How the fix command is derived

The suggested command is not a template. Flags are added only for the checks that failed:

  • BLOCKSIZE — keeps a square 256/512/1024 tile size if the file already has one, otherwise 512.
  • COMPRESS — keeps the existing scheme if it is cloud-friendly (DEFLATE, ZSTD, LZW, WEBP, JPEG, LERC, JXL), otherwise DEFLATE.
  • PREDICTOR=YES — added only for DEFLATE/ZSTD/LZW on float or 16-bit-and-wider integer data, where differencing actually pays off. It is deliberately omitted for 8-bit data.
  • INTERLEAVE=PIXEL — added only when the source is band-interleaved with more than one band.
  • OVERVIEW_RESAMPLING=AVERAGE — added only when the pyramid is missing, shallow, stripped, external or misordered.
  • BIGTIFF=YES — added when the source is already BigTIFF.

Options

Flag Effect
TARGET... One or more GeoTIFFs: local paths, http(s):// URLs, s3:///gs:///az:// URLs, or ready-made /vsi*/ paths.
--json Emit {"strict": ..., "results": [...], "summary": {...}} instead of the text report.
--strict Treat performance warnings as failures when deciding the exit code.
--show-skipped Include checks that did not apply to this file.
--quiet, -q Print nothing; communicate the verdict through the exit code only.
--no-color Disable ANSI colour. NO_COLOR in the environment does the same.
--timeout SECONDS Socket timeout for remote reads (default 30).
--version Print the version and exit.

Exit codes: 0 every target is a valid COG, 1 at least one target failed a check or could not be read as a TIFF, 2 usage error.

Python API

Function Purpose
inspect_target(target, *, timeout=30.0) -> Inspection Inspect one file. Parse failures are reported through Inspection.error, never raised.
read_tiff_structure(source, max_ifds=256) -> TiffStructure Parse the IFD chain of any TIFF/BigTIFF from a ByteSource.
to_vsi_path(target) -> str Translate a path or URL into a GDAL virtual filesystem path.
estimate_range_requests(header_bytes, chunk=16384) -> int The range-request cost model.

Inspection exposes checks, failures, warnings, passes, raster, header, overviews, fix, is_cog, ok(strict=False), check(id) and to_dict().

Limitations

  • Missing overviews are reported as FAIL, not WARN, for images larger than 512 px. GDAL's own validate_cloud_optimized_geotiff.py treats this as a warning. We disagree: a large COG without a pyramid forces full-resolution reads for every zoomed-out request, which is the failure mode the format exists to prevent. Use --json and filter by category if you want GDAL's grouping.
  • Non-HTTP remote schemes need the GDAL Python bindings. Local files and http(s):// URLs are read natively. s3://, gs:// and az:// structural parsing goes through osgeo.gdal.VSIFOpenL, which is not bundled with rasterio wheels. Without it you get a clear error; use a presigned or public HTTPS URL instead.
  • Compression is identified, not verified. The tool reads the Compression tag; it does not decode tiles, so it cannot detect corrupt compressed streams.
  • Lossy compression is not judged. WEBP and JPEG pass the compression check. Whether lossy compression is acceptable for your data is a decision the tool cannot make.
  • The range-request model assumes GDAL's defaults. Clients that set GDAL_INGESTED_BYTES_AT_OPEN or CPL_VSIL_CURL_CHUNK_SIZE differently, or non-GDAL readers, will see different numbers.
  • Only the TIFF container is inspected. STAC metadata, band descriptions and colour interpretation are out of scope.

Further reading

Background on the format and the access patterns this tool measures:

Contributing

Issues and pull requests are welcome. Please keep the test suite hermetic — no network calls; generate synthetic GeoTIFFs with rasterio in tests/conftest.py, or hand-build TIFF bytes with the helper in tests/tiffbuild.py for layouts GDAL will not write.

pip install -r requirements-dev.txt
ruff check . && ruff format --check .
python -m pytest

License

MIT — see LICENSE.

Built and maintained alongside Python Remote Sensing & Raster Processing Pipelines.

About

Validate whether a GeoTIFF is a proper Cloud-Optimized GeoTIFF and explain exactly why it is not: structural checks, HTTP range-request cost, and a derived gdal_translate fix.

Topics

Resources

License

Stars

0 stars

Watchers

0 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages