Displaying COGs on a leafmap Web Map
Create a map and add the COG as a tile layer with explicit rendering parameters:
import leafmap
m = leafmap.Map(center=(0.35, 34.75), zoom=11, basemap="CartoDB.Positron")
m.add_cog_layer(
"https://example-bucket.s3.amazonaws.com/products/ndvi_20260614.tif",
name="NDVI 14 June", rescale="-0.2,0.9", colormap_name="rdylgn",
)
m
The rescale and colormap are the parts that turn a black square into a readable layer. This page belongs to interactive raster exploration in Jupyter in Visualization, Tiling & Web Delivery.
How the Layer Reaches the Map
add_cog_layer does not load the raster into the notebook. It builds a tile URL template pointing at a tile service and hands that to the map widget, which then requests only the tiles in view.
This is the reason a tile layer beats loading an array for anything larger than a chip. An array has to be decimated to fit in the browser, and the decimation hides exactly the pixel-level detail — a shifted mask, a single-pixel artefact — that exploration exists to find.
Environment & Setup
| Package | Version pin | Used for |
|---|---|---|
leafmap |
>=0.32 |
Map widget and COG helpers |
ipyleaflet |
>=0.18 |
The underlying Leaflet widget |
localtileserver |
>=0.8 |
Optional: serving local files without a remote endpoint |
pip install "leafmap>=0.32" "ipyleaflet>=0.18" "localtileserver>=0.8"
Complete Working Example
import leafmap
from ipywidgets import HTML
def product_map(layers: list[dict], *, center: tuple[float, float],
zoom: int = 11, tiler: str = "https://tiles.example.org") -> leafmap.Map:
"""Build a map from a list of layer specs, bottom to top."""
m = leafmap.Map(center=center, zoom=zoom, basemap="CartoDB.Positron",
draw_control=False, measure_control=False)
legend_rows = []
for spec in layers: # order matters: first is lowest
params = f"url={spec['url']}"
if "rescale" in spec:
params += f"&rescale={spec['rescale'][0]},{spec['rescale'][1]}"
if "colormap" in spec:
params += f"&colormap_name={spec['colormap']}"
for b in spec.get("bidx", []):
params += f"&bidx={b}"
template = f"{tiler}/cog/tiles/WebMercatorQuad///.png?{params}"
m.add_tile_layer(template, name=spec["name"],
opacity=spec.get("opacity", 1.0),
attribution=spec.get("attribution", ""))
if "rescale" in spec:
legend_rows.append(f"<b>{spec['name']}</b>: {spec['rescale'][0]} to "
f"{spec['rescale'][1]} ({spec.get('colormap', 'grey')})")
m.add_layer_control()
if legend_rows:
m.add_widget(HTML("<br>".join(legend_rows)), position="bottomright")
return m
m = product_map(
[
{"name": "True colour", "url": "s3://example-bucket/scenes/S2A_36NYF.tif",
"bidx": [4, 3, 2], "rescale": (0, 3000)},
{"name": "NDVI", "url": "s3://example-bucket/products/ndvi_36NYF.tif",
"rescale": (-0.2, 0.9), "colormap": "rdylgn", "opacity": 0.65},
],
center=(0.35, 34.75),
)
m
Building the legend from the same specs that built the layers is a small discipline with a large payoff: the legend cannot disagree with the layer, because both came from one dictionary. Hand-typing the legend into an HTML string is how a map ends up labelled “0 to 1” while rendering “−0.2 to 0.9”.
Local Files for Quick Checks
import leafmap
m = leafmap.Map()
m.add_raster("outputs/ndvi_candidate.tif", colormap="RdYlGn",
vmin=-0.2, vmax=0.9, layer_name="candidate NDVI")
m
The local route starts a small tile server inside the kernel, which is ideal for inspecting an output the pipeline has just written. It depends on the file being a reasonable COG too — a striped GeoTIFF displays, but every pan reads full-width rows and the map feels sluggish for the reasons explained in understanding Cloud-Optimized GeoTIFF structure.
Verification
import httpx
layer_templates = [l.url for l in m.layers if hasattr(l, "url") and "{z}" in l.url]
for t in layer_templates:
probe = t.replace("{z}", "11").replace("{x}", "1222").replace("{y}", "1022")
r = httpx.get(probe, timeout=30)
print(r.status_code, f"{len(r.content)/1024:.1f} KB", t[:80])
Fetching one tile of each layer directly separates “the map is blank” into its two causes. A 200 response with a sensible size means the endpoint works and the problem is the view — wrong centre, wrong zoom, or the layer hidden under an opaque one. An error response means the endpoint or the parameters are wrong, and the widget was never going to show anything.
Check alignment against the basemap at native zoom too. A correctly georeferenced COG lines up with roads and coastlines to within a pixel; a consistent offset of hundreds of metres means the file’s CRS is wrong, which is covered in fixing EPSG mismatches in rasterio.open.
Common Errors
The layer renders black
No rescale was given, so reflectance is stretched across the full integer range. Pass explicit limits taken from the product’s recorded stretch.
The map shows the basemap only
The layer is outside the current view, or the endpoint returned errors silently. Fetch one tile directly as above.
The layer is offset from the basemap
The file’s CRS is missing or wrong. Fix the metadata rather than nudging the layer.
The map is slow to pan
The source is not a proper COG, or the tile service is far from the storage. Validate the file and check the service’s placement.
The layer control lists layers in the wrong order
The control reflects insertion order. Rebuild the map with layers added bottom-first rather than trying to reorder them afterwards, which some widget versions handle inconsistently.
The exported notebook shows an empty map to colleagues
The layer pointed at a local tile server or a private endpoint. Publish the file behind a reachable service before exporting, and check the template opens in a browser that is not logged in to anything.
Frequently Asked Questions
Q: Can leafmap display a local GeoTIFF without a tile server? Yes — it can start a small local tile server behind the scenes for a local file. That is convenient for a quick look, but the layer disappears when the kernel stops and cannot be shared, so publish the file and point at a real endpoint for anything other people will see.
Q: Why is my layer offset from the basemap? Almost always a wrong or missing CRS in the file, so the tiler places it with the wrong transform. Check the file’s CRS with rio info; a correct COG with a correct CRS lines up with any basemap automatically.
Q: How do I control which layer is on top? Layers draw in the order they are added, so add the basemap first, the imagery second and overlays such as masks or classifications last, with opacity below one so the evidence underneath stays visible.
Q: Which basemap works best under raster data? A light, low-saturation one such as a greyscale street map. A satellite basemap under satellite imagery is confusing, and a colourful street map competes with the data for attention. The basemap should provide place names and orientation, and nothing else.
Related
- Interactive Raster Exploration in Jupyter — the parent topic on notebook exploration.
- Adding Dynamic Rescaling and Colormaps to Tile URLs — the parameters these layers pass.
- Drawing an AOI and Reading Pixels Interactively — the next step once the layer is visible.
- Reading a COG over S3 without Downloading — what the tile service does for every tile.