Skip to content

feat(unstable-cog): Change tile path semantics for COG sources to match other sources, expose COG bounds, center and tileSize in TileJSON#2510

Merged
CommanderStorm merged 44 commits intomaplibre:mainfrom
Auspicus:cog_web_5
Feb 10, 2026

Conversation

@Auspicus
Copy link
Copy Markdown
Contributor

@Auspicus Auspicus commented Jan 17, 2026

This change tightens COG support and introduces breaking validation behavior: Martin now only accepts Cloud Optimized GeoTIFFs that are fully compatible with WebMercatorQuad. COGs must be tiled (no strips), use Chunky planar configuration, RGB/RGBA 8-bit imagery or YCbCr, and one of None/LZW/Deflate/ModernJPEG/WEBP compression methods, and must declare ProjectedCRSGeoKey = EPSG:3857. Zoom levels are derived from image resolution and tile size rather than IFD order, tile coordinates are computed in absolute WebMercator space (including non-zero tile origins), and all images must use a consistent tile size. TileJSON generation is updated to include accurate bounds, center, zoom range, and tileSize. As a result, previously accepted but ambiguous or non-WebMercator COGs may now fail to load.

@Auspicus Auspicus changed the title Allow requesting tiles from COG sources using WebMercator convention behind auto_webmercator config option feat(martin): Allow requesting tiles from COG sources using WebMercator convention behind auto_webmercator config option Jan 17, 2026
@Auspicus Auspicus marked this pull request as draft January 17, 2026 07:37
@Auspicus Auspicus changed the title feat(martin): Allow requesting tiles from COG sources using WebMercator convention behind auto_webmercator config option feat(martin): Allow requesting tiles from COG sources using standard WebMercator tile ZXY indexing Jan 17, 2026
@Auspicus Auspicus changed the title feat(martin): Allow requesting tiles from COG sources using standard WebMercator tile ZXY indexing feat(martin): Allow requesting tiles from COG sources using standard WebMercator tile ZXY indexing, expose COG extent and tileSize in TileJSON Jan 17, 2026
@Auspicus Auspicus changed the title feat(martin): Allow requesting tiles from COG sources using standard WebMercator tile ZXY indexing, expose COG extent and tileSize in TileJSON feat(martin): Allow requesting tiles from COG sources using standard WebMercator tile ZXY indexing, expose COG bounds and tileSize in TileJSON Jan 17, 2026
@Auspicus Auspicus changed the title feat(martin): Allow requesting tiles from COG sources using standard WebMercator tile ZXY indexing, expose COG bounds and tileSize in TileJSON feat(martin): Change tile path semantics for COG sources to match other sources, expose COG bounds and tileSize in TileJSON Jan 17, 2026
@sharkAndshark
Copy link
Copy Markdown
Collaborator

Great work! Some thoughts here:

reading a tile could be a lot more efficient as we can skip re-projecting pixels from file model space to the grid.

yes and not.. Actually only works for the last zoom, eg, if the zoom range is 8 -20, we could only skip re-projecting at zoom 20 with that gdal option.

a requirement for the COG to be projected in EPSG:3857

Yeah. For web-frinendly eanbled, it should be enforced.

removes the previous ability to read a COG source using arbitrary model space indexing.

It's a breaking change. Unfortunally our server rely on this feature with a local CRS(without a epsg code). It's pretty fast as no clipping and merging.

Option1

Remove it. As Most user only use 3857. My company could keep our martin branch and someday contributing back upstream once martin figure out how to tell user the inner tilegrid of COG. I think /catalog is the best endpoint to do this

Option 2

Keep it. And make web-friendly default. But We should keep the code as it's the most efficient way to serve tiles, no clipping and merging. And can serve any CRS.

I perfer option1. It's the most easy way. Once #343 is resolved, we could add it back.

it appears that the full COG file is loaded into memory

Is it true?

@Auspicus
Copy link
Copy Markdown
Contributor Author

Auspicus commented Jan 21, 2026

From original PR description


notes

  • if the COG is aligned to the mercator grid aka. "Web Friendly" (each chunk in the file starts and ends at the boundaries of a martin tile), for instance by using gdal_translate cog.tif cog_aligned.tif -of COG -co TILING_SCHEME=GoogleMapsCompatible, reading a tile could be a lot more efficient as we can skip re-projecting pixels from file model space to the grid
  • this PR adds a requirement for the COG to be projected in EPSG:3857 which seemed to have been required before but was not enforced, the OGC COG spec makes no such assertion but we probably don't want to have to support re-projecting COGs written in arbitrary CRS. downloading a sample COG from AWS USDA NAIP mirror which was projected in NAD83 (EPSG:26910) doesn't work with the current assumptions without prior reprojection to EPSG:3857
  • this PR removes the previous ability to read a COG source using arbitrary model space indexing (where /cog_source/0/0/0 does not align with expected mercator tiles) which is not backwards compatible but since this feature is unstable, perhaps this is OK?
  • it appears that the full COG file is loaded into memory (which can be > 4GB with the BIGTIFF extension) and might be avoidable as COGs are assumed to be tiled in chunks of 256x256 or 512x512 so theoretically each tile request only needs to load up to 4 tiles at worst and 1 tile at best (depending on boundaries) I was looking crudely at the total memory usage and because it was near the size of the COG I was loading I assumed this was the cause. After further digging it appears disabling the fonts feature drops the memory usage significantly. I believe due to the precomputed masks

yes and not.. Actually only works for the last zoom, eg, if the zoom range is 8 -20, we could only skip re-projecting at zoom 20 with that gdal option.

I'm not sure why that would be the case if the internal tile blocks were aligned 1:1 with the WebMercator grid. Since a request for /cog_source/10/10/10 would correspond 1:1 with an entire tile block in the COG in that instance. Can you elaborate?

Yeah. For web-frinendly eanbled, it should be enforced.

I think to get this over the line and "stable-ish" we could aim for MVP that's fast but maybe doesn't support all the variations of COGs just yet. I will work on a full list of requirements for the COG file to work within the current system but it sounds like we're in alignment on:

  • rgb/rgba 8 bit color (3 or 4 bands)
  • projected in epsg:3857
  • tiled not striped, a COG requirement (maybe requiring 256, 512, 1024 blocks)?
  • compression?

It's a breaking change. Unfortunally our server rely on this feature with a local CRS(without a epsg code). It's pretty fast as no clipping and merging.

Option1

Remove it. As Most user only use 3857. My company could keep our martin branch and someday contributing back upstream once martin figure out how to tell user the inner tilegrid of COG. #343 (comment)

Option 2

Keep it. And make web-friendly default. But We should keep the code as it's the most efficient way to serve tiles, no clipping and merging. And can serve any CRS.

I perfer option1. It's the most easy way. Once #343 is resolved, we could add it back.

I've gone with option 1 here. Keeping support will require extra complexity to the feature and it seems likely it will confuse users who want COGs to "just work".

it appears that the full COG file is loaded into memory

Is it true?

It seems as though there was a change between v1.0.0 and v1.2.0 that made the fonts feature take a lot more memory on boot. It took about as much memory as my COG file so I thought it was that. Even when no fonts are loaded. Removing the pre-computed masks drops this significantly. I haven't dug in far enough to get to the bottom of it. I run martin without the fonts feature. TLDR; not sure how the tiff crate handles buffering, etc. yet but it doesn't seem to be the immediate culprit for this spike.

@CommanderStorm
Copy link
Copy Markdown
Member

It seems as though there was a change between v1.0.0 and v1.2.0 that made the fonts feature take a lot more memory on boot. It took about as much memory as my COG file so I thought it was that. Even when no fonts are loaded

We noticed an increase in this in the folliwing PR, but we apparently should have looke deeper into this, rahter than just accepting it.

@sharkAndshark
Copy link
Copy Markdown
Collaborator

we could only skip re-projecting at zoom 20 with that gdal option.

If we have a COG file whose extent is this red square:

image

we could make it 1:1 to webmercator quad at its nearest zoom level, let's suppose it's 8:

this red bbox would be aligned and padded to four tiles big in web mercator quad:

z x y
8 213 111
8 214 111
8 213 110
8 214 110

then let's make it 1:1 to webmercator quad at zoom level 7:

zoom 8 zoom 7
8/213/111 7/106/55
8/214/111 7/107/55
8/213/110 7/106/55
8/214/110 7/107/55

The combination of /7/106/55 and /7/107/55 is much bigger than the combination of (/8/213/1111 && /8/214/111 && /8/213/11 && /8/214/110).

It's not allowed there are different extents in one single COG. I believe in gdal didn't align it at all zoom-levels and I remember I made a concept prove about this that I found I could only skip reprojection at the lagest zoom-level.

@Auspicus

@Auspicus
Copy link
Copy Markdown
Contributor Author

Auspicus commented Jan 22, 2026

@CommanderStorm I have a proposal to help with the memory usage here: #2519 but I'm not sure if it gets it all the way across the line. I think there's more that could be done.

@Auspicus
Copy link
Copy Markdown
Contributor Author

It's not allowed there are different extents in one single COG. I believe in gdal didn't align it at all zoom-levels and I remember I made a concept prove about this that I found I could only skip reprojection at the lagest zoom-level.

@Auspicus

@sharkAndshark I think I see what you mean. Let me try a few things out and report back. Ultimately it'd be a premature optimization to even try this without seeing how much the pixel by pixel copy costs. FWIW, where you've gotten it to feels pretty fast on my machine. Might not be necessary to go further :)

@Auspicus Auspicus changed the title feat(martin): Change tile path semantics for COG sources to match other sources, expose COG bounds and tileSize in TileJSON feat(cog): Change tile path semantics for COG sources to match other sources, expose COG bounds and tileSize in TileJSON Jan 24, 2026
@Auspicus Auspicus changed the title feat(cog): Change tile path semantics for COG sources to match other sources, expose COG bounds and tileSize in TileJSON feat(unstable-cog): Change tile path semantics for COG sources to match other sources, expose COG bounds and tileSize in TileJSON Jan 24, 2026
@Auspicus Auspicus changed the title feat(unstable-cog): Change tile path semantics for COG sources to match other sources, expose COG bounds and tileSize in TileJSON feat(unstable-cog): Change tile path semantics for COG sources to match other sources, expose COG bounds, center and tileSize in TileJSON Jan 26, 2026
@Auspicus
Copy link
Copy Markdown
Contributor Author

Auspicus commented Jan 26, 2026

@sharkAndshark I've found that using the following options with GDAL produces a COG that aligns at all generated zoom levels so that all tile requests have exactly 1 intersecting tile in the source file:

gdal_translate original.tif compatible.tif \
-b 1 -b 2 -b 3 -of COG -co BIGTIFF=YES \
-co TILING_SCHEME=GoogleMapsCompatible \
-co ALIGNED_LEVELS=5 -co OVERVIEW_COUNT=4 \
-co OVERVIEWS=IGNORE_EXISTING -co COMPRESS=LZW \
-co ADD_ALPHA=YES -co NUM_THREADS=ALL_CPUS \
-co ZOOM_LEVEL_STRATEGY=LOWER -co BLOCKSIZE=512

The key options are ALIGNED_LEVELS and OVERVIEW_COUNT, you must request ALIGNED_LEVELS=OVERVIEW_COUNT+1 so that every overview and the full resolution image are aligned. GDAL imposes a conservative maximum value for ALIGNED_LEVELS to 10 but it should be possible to push further.

I'm working on a commit to adjust the clipping and zoom level approximation to account for this. I will push that soon. It also appears that currently the only supported compression codecs are LZW, DEFLATE and NONE.

Also, the /z/x/y values are dependent on tileSize so if we correctly publish the internal tile size of the COG in the TileJSON then the requests are properly aligned by the client.

image

@sharkAndshark
Copy link
Copy Markdown
Collaborator

sharkAndshark commented Jan 27, 2026

The key options are ALIGNED_LEVELS and OVERVIEW_COUNT

Good job! I would try this and inspect the inner layout of result file. If the aligning is from top down then there would be no different extents. But if the aligning is from full resolution to the last overview, there would be. So maybe gdal work from top to down when aligned levels count bigger than 1.

@CommanderStorm
Copy link
Copy Markdown
Member

One thing which you can decide (I am fine with both ways) is if we want to stabilise this feature and take it into the default compilation.
Basically, renaming unstable-cog -> cog.

The effect of this is that it makes breaking changes slower (since they require a SemVer-major bump), so if you think this needs some in the short-term future, we should not do that.

@Auspicus
Copy link
Copy Markdown
Contributor Author

Auspicus commented Jan 28, 2026

@sharkAndshark

Good job! I would try this and inspect the inner layout of result file. If the aligning is from top down then there would be no different extents. But if the aligning is from full resolution to the last overview, there would be. So maybe gdal work from top to down when aligned levels count bigger than 1.

From the GDAL docs it says:

ALIGNED_LEVELS=: Number of resolution levels for which GeoTIFF tile and tiles defined in the tiling scheme match. When specifying this option, padding tiles will be added to the left and top sides of the target raster, when needed, so that a GeoTIFF tile matches with a tile of the tiling scheme. Only taken into account if TILING_SCHEME is different from CUSTOM. Effect of this option is only visible when setting it at 2 or more, since the full resolution level is by default aligned with the tiling scheme. For a tiling scheme whose consecutive zoom level resolutions differ by a factor of 2, care must be taken in setting this value to a high number of levels, as up to 2^(ALIGNED_LEVELS-1) tiles can be added in each dimension. The driver enforces a hard limit of 10.

So this will work but requires padding of the higher zooms to align them with the lower zooms, and you're right to be hesitant about that. This will generate a larger file than necessary for sure. I wonder if SPARSE_OK=TRUE would help in this case:

SPARSE_OK=[TRUE​/​FALSE]: (GDAL >= 3.2) Defaults to FALSE. Should empty blocks be omitted on disk? When this option is set, any attempt of writing a block whose all pixels are 0 or the nodata value will cause it not to be written at all (unless there is a corresponding block already allocated in the file). Sparse files have 0 tile/strip offsets for blocks never written and save space; however, most non-GDAL packages cannot read such files. On the reading side, the presence of a omitted tile after a non-empty one may cause optimized readers to have to issue an extra GET request to the TileByteCounts array.

Testing with this option on a small area, with SPARSE_OK=TRUE the file is ~4MB with 10 zoom levels. Without SPARSE_OK=TRUE the file is closer to 80MB. Both were using WEBP compression which likely makes this difference smaller than it would be with PNG, or JPEG.

@Auspicus
Copy link
Copy Markdown
Contributor Author

Auspicus commented Jan 28, 2026

Amazingly, SPARSE_OK=TRUE actually kinda works in this MR. But you get an error logged for all the missing tiles. I'll see if we can rectify that. I generated an LZW compression COG and the difference was much greater between SPARSE_OK=TRUE/FALSE. Still around ~4MB with SPARSE_OK=TRUE but ~300MB with SPARSE_OK=FALSE.

image

@Auspicus
Copy link
Copy Markdown
Contributor Author

@sharkAndshark do you mind casting another eye over this when you have some time?

Copy link
Copy Markdown
Member

@CommanderStorm CommanderStorm left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice work, looks good to me.
I have a few nits, nothing major

Obviously, I would like a review from @sharkAndshark if possible

Auspicus and others added 3 commits January 31, 2026 17:14
Co-authored-by: Frank Elsinga <frank@elsinga.de>
Co-authored-by: Frank Elsinga <frank@elsinga.de>
Co-authored-by: Frank Elsinga <frank@elsinga.de>
@sharkAndshark
Copy link
Copy Markdown
Collaborator

sharkAndshark commented Jan 31, 2026

Yeah I will review it these days @Auspicus would you mind update the description of this PR either?

@Auspicus
Copy link
Copy Markdown
Contributor Author

Auspicus commented Feb 1, 2026

I managed to add in support for WEBP and JPEG without much fuss. It might be worth waiting to merge until upstream image-tiff library change lands in a release which adds CompressionMethod::WEBP so we don't need to hardcode it. Even with this I don't think this is quite ready for stable but it should cover a lot more use cases now.

@CommanderStorm
Copy link
Copy Markdown
Member

That is cool. Both ways are fine with me.

@Auspicus
Copy link
Copy Markdown
Contributor Author

Auspicus commented Feb 5, 2026

I've created this discussion: #2540 to cover support for other projections than EPSG:3857. I think this will require a bit of consideration.

@CommanderStorm CommanderStorm enabled auto-merge (squash) February 9, 2026 19:30
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

@CommanderStorm CommanderStorm merged commit 937dc98 into maplibre:main Feb 10, 2026
40 checks passed
@CommanderStorm CommanderStorm mentioned this pull request Feb 10, 2026
CommanderStorm added a commit that referenced this pull request Feb 11, 2026
## 🤖 New release

* `martin-tile-utils`: 0.6.9 -> 0.6.10 (✓ API compatible changes)
* `mbtiles`: 0.15.1 -> 0.15.2 (✓ API compatible changes)
* `martin-core`: 0.2.6 -> 0.3.0 (⚠ API breaking changes)
* `martin`: 1.3.0 -> 2.0.0

### ⚠ `martin-core` breaking changes

```text
--- failure enum_missing: pub enum removed or renamed ---

Description:
A publicly-visible enum cannot be imported by its prior path. A `pub use` may have been removed, or the enum itself may have been renamed or removed entirely.
        ref: https://doc.rust-lang.org/cargo/reference/semver.html#item-remove
       impl: https://github.com/obi1kenobi/cargo-semver-checks/tree/v0.46.0/src/lints/enum_missing.ron

Failed in:
  enum martin_core::config::OptOneMany, previously in file /tmp/.tmpiF3o7Z/martin-core/src/config/cfg_containers.rs:29
  enum martin_core::config::OptBoolObj, previously in file /tmp/.tmpiF3o7Z/martin-core/src/config/cfg_containers.rs:8

--- failure module_missing: pub module removed or renamed ---

Description:
A publicly-visible module cannot be imported by its prior path. A `pub use` may have been removed, or the module may have been renamed, removed, or made non-public.
        ref: https://doc.rust-lang.org/cargo/reference/semver.html#item-remove
       impl: https://github.com/obi1kenobi/cargo-semver-checks/tree/v0.46.0/src/lints/module_missing.ron

Failed in:
  mod martin_core::config, previously in file /tmp/.tmpiF3o7Z/martin-core/src/config/mod.rs:1
  mod martin_core::config::env, previously in file /tmp/.tmpiF3o7Z/martin-core/src/config/env.rs:1

--- failure struct_missing: pub struct removed or renamed ---

Description:
A publicly-visible struct cannot be imported by its prior path. A `pub use` may have been removed, or the struct itself may have been renamed or removed entirely.
        ref: https://doc.rust-lang.org/cargo/reference/semver.html#item-remove
       impl: https://github.com/obi1kenobi/cargo-semver-checks/tree/v0.46.0/src/lints/struct_missing.ron

Failed in:
  struct martin_core::config::env::OsEnv, previously in file /tmp/.tmpiF3o7Z/martin-core/src/config/env.rs:53
  struct martin_core::config::IdResolver, previously in file /tmp/.tmpiF3o7Z/martin-core/src/config/id_resolver.rs:10
  struct martin_core::config::env::FauxEnv, previously in file /tmp/.tmpiF3o7Z/martin-core/src/config/env.rs:76

--- failure trait_missing: pub trait removed or renamed ---

Description:
A publicly-visible trait cannot be imported by its prior path. A `pub use` may have been removed, or the trait itself may have been renamed or removed entirely.
        ref: https://doc.rust-lang.org/cargo/reference/semver.html#item-remove
       impl: https://github.com/obi1kenobi/cargo-semver-checks/tree/v0.46.0/src/lints/trait_missing.ron

Failed in:
  trait martin_core::config::env::Env, previously in file /tmp/.tmpiF3o7Z/martin-core/src/config/env.rs:20
```

<details><summary><i><b>Changelog</b></i></summary><p>


## `mbtiles`

<blockquote>

##
[0.15.2](mbtiles-v0.15.1...mbtiles-v0.15.2)
- 2026-02-11

### Other

- restrict `unused_trait_names` for trait imports
([#2542](#2542))
</blockquote>

## `martin-core`

<blockquote>

##
[0.3.0](martin-core-v0.2.6...martin-core-v0.3.0)
- 2026-02-11

### Added

- *(unstable-cog)* Change tile path semantics for COG sources to match
other sources, expose COG bounds, center and tileSize in TileJSON
([#2510](#2510))

### Other

- *(martin-core)* [**breaking**] remove the configration from the
martin-core crate
([#2521](#2521))
- restrict `unused_trait_names` for trait imports
([#2542](#2542))
</blockquote>

## `martin`

<blockquote>

##
[2.0.0](martin-v1.3.0...martin-v2.0.0)
- 2026-02-11

### Added

- *(srv)* Add HTTP 301 redirects for common URL mistakes
([#2528](#2528))
- *(unstable-cog)* Change tile path semantics for COG sources to match
other sources, expose COG bounds, center and tileSize in TileJSON
([#2510](#2510))

### Fixed

- logs not being integrated with the `path-prefix` correctly
([#2549](#2549))
- Make sure that `route-prefix` does not break the UI when using
trailing slash urls
([#2541](#2541))

### Other

- *(deps)* Bump the all-npm-version-updates group across 2 directories
with 7 updates ([#2553](#2553))
- Add test coverage for header handling in tilejson requests
([#2529](#2529))
- *(martin-core)* [**breaking**] remove the configration from the
martin-core crate
([#2521](#2521))
- *(deps)* autoupdate pre-commit
([#2545](#2545))
- restrict `unused_trait_names` for trait imports
([#2542](#2542))
- *(deps)* Bump the all-npm-version-updates group across 2 directories
with 12 updates ([#2533](#2533))
</blockquote>


</p></details>

---
This PR was generated with
[release-plz](https://github.com/release-plz/release-plz/).

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
@Auspicus Auspicus deleted the cog_web_5 branch March 18, 2026 20:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants