Adding Dynamic Rescaling and Colormaps to Tile URLs
A tile URL should carry everything needed to reproduce the image, so the same string always renders the same map:
/cog/tiles/WebMercatorQuad/{z}/{x}/{y}.png
?url=s3://example-bucket/products/ndvi.tif
&rescale=-0.2,0.9
&colormap_name=rdylgn
Anything left to server defaults is a rendering decision nobody wrote down. This page belongs to serving raster tiles with TiTiler in Visualization, Tiling & Web Delivery.
The Parameters That Matter
Of these, rescale is the one whose absence is most visible: without it the tiler stretches across the dataset’s dtype range, and satellite reflectance renders almost black for the reasons described in rendering rasters with matplotlib.
Environment & Setup
| Package | Version pin | Used for |
|---|---|---|
titiler.core |
>=0.18 |
The endpoints that accept these parameters |
httpx |
>=0.27 |
Testing the URLs from Python |
matplotlib |
>=3.8 |
Generating a custom colormap to pass through |
pip install "titiler.core>=0.18" "httpx>=0.27" "matplotlib>=3.8"
Complete Working Example
from urllib.parse import urlencode
def tile_template(base: str, url: str, *, rescale: tuple[float, float] | None = None,
colormap_name: str | None = None, expression: str | None = None,
bidx: list[int] | None = None, nodata: str | None = None,
tilesize: int = 256) -> str:
"""Build an XYZ template with every rendering parameter encoded safely."""
params: list[tuple[str, str]] = [("url", url)]
if rescale:
params.append(("rescale", f"{rescale[0]},{rescale[1]}"))
if colormap_name:
params.append(("colormap_name", colormap_name))
if expression:
params.append(("expression", expression)) # urlencode escapes the +
for b in bidx or []:
params.append(("bidx", str(b)))
if nodata is not None:
params.append(("nodata", nodata))
if tilesize != 256:
params.append(("tilesize", str(tilesize)))
query = urlencode(params, safe="")
return f"{base}/cog/tiles/WebMercatorQuad///.png?{query}"
if __name__ == "__main__":
base = "https://tiles.example.org"
ndvi = tile_template(base, "s3://example-bucket/products/ndvi.tif",
rescale=(-0.2, 0.9), colormap_name="rdylgn")
# NDVI computed on the fly from a band stack — note the raw + in the expression
live = tile_template(base, "s3://example-bucket/scenes/S2A_36NYF.tif",
expression="(b8-b4)/(b8+b4)",
rescale=(-0.2, 0.9), colormap_name="rdylgn")
truecolour = tile_template(base, "s3://example-bucket/scenes/S2A_36NYF.tif",
bidx=[4, 3, 2], rescale=(0, 3000))
print(live)
Using urlencode rather than an f-string is the whole reason the expression works. The + in (b8+b4) becomes %2B automatically; written by hand it arrives as a space and the server returns a parse error that gives no hint about the cause.
Custom Colormaps and Categorical Data
import json
from urllib.parse import urlencode
CLASS_COLORS = {
"1": [38, 115, 0, 255],
"2": [168, 204, 84, 255],
"3": [196, 40, 27, 255],
"4": [28, 92, 168, 255],
}
query = urlencode([
("url", "s3://example-bucket/products/landcover.tif"),
("colormap", json.dumps(CLASS_COLORS)),
], safe="")
print(f"https://tiles.example.org/cog/tiles/WebMercatorQuad///.png?{query}")
For a continuous product with a ramp that is not among the built-in names, the same mechanism accepts a list of value-range and colour pairs. Generating it from a matplotlib colormap keeps the figure and the tile service on the same colours:
import json
import numpy as np
from matplotlib import colormaps
def encode_colormap(name: str, n: int = 32) -> str:
cmap = colormaps[name]
stops = {}
for i in range(n):
lo, hi = int(i * 255 / n), int((i + 1) * 255 / n) - 1
r, g, b, a = cmap(i / (n - 1))
stops[f"[{lo}, {hi}]"] = [int(r * 255), int(g * 255), int(b * 255), 255]
return json.dumps(stops)
Verification
import httpx
def check_template(template: str) -> None:
url = template.replace("{z}", "10").replace("{x}", "604").replace("{y}", "512")
r = httpx.get(url, timeout=30)
r.raise_for_status()
assert r.headers["content-type"] == "image/png"
# A tile of pure nodata is tiny; a correctly rendered one is not
assert len(r.content) > 1500, "tile looks empty — check rescale and nodata"
print(f"{len(r.content)/1024:.1f} KB")
Tile size is a surprisingly good proxy for whether the rendering parameters are right. A tile rendered with a wrong rescale is nearly uniform and compresses to almost nothing; one rendered with noise-level limits is close to random and compresses badly. Anything between roughly 5 and 40 KB is a normal image.
The second check is visual and belongs in a review rather than a test: open the same extent as a matplotlib figure with the identical limits and ramp, and compare. Any difference means the two paths disagree about nodata or scaling, which is exactly the drift this whole parameterisation exists to prevent.
Common Errors
The template works by hand but not from a client
The client re-encoded the already-encoded query string, double-escaping the percent signs. Hand clients the raw template and let them substitute only the z, x and y placeholders.
The tile is uniformly one colour
rescale is missing or far from the data range. Query the /cog/statistics endpoint for the file and use its percentiles as a starting point.
Nodata renders as a valid colour
The file has no nodata set, so the tiler has nothing to mask. Pass nodata= explicitly, and fix the file’s metadata so future consumers do not need to.
A custom colormap is rejected
The JSON was not URL-encoded, or the keys are integers rather than strings. Encode with urlencode and use string keys.
The layer looks different in two clients
One of them is supplying its own defaults because a parameter was omitted from the template it was given. Generate the template from one function, as above, and hand out the string it returns rather than letting each client assemble its own.
Band indices select the wrong bands
bidx is one-based and refers to file order, not to wavelength. Confirm the order from the band descriptions before publishing a composite template, since the same triple gives different colours on a different sensor.
Frequently Asked Questions
Q: Why does an expression with a plus sign fail? A plus sign means a space in a query string, so the expression arrives mangled. Encode it as %2B, or build the URL with urlencode so every parameter is escaped correctly.
Q: How do I serve a class raster with its own colours? Write a colour table into the file and the tiler will honour it with no parameters at all. Where that is not possible, pass a custom colormap as JSON mapping class values to RGBA tuples.
Q: Should rescale values come from the data or be fixed? Fixed, and recorded next to the product. Per-scene values make each tile look good in isolation and make a mosaic of scenes visibly patchy, because neighbouring files are rendered on different scales.
Q: Is an expression as fast as a pre-computed product? No. Every tile recomputes the expression over the pixels it reads, and for a band ratio that is a measurable share of the render time. Expressions are excellent for exploration and for one-off views; anything looked at regularly deserves a materialised product.
Related
- Serving Raster Tiles with TiTiler — the parent topic on deployment, mosaics and caching.
- Choosing Colour Ramps for Continuous Raster Data — picking the ramp these parameters name.
- Writing Prediction Rasters as COGs — embedding a colour table so no colormap parameter is needed.
- Displaying COGs on a leafmap Web Map — consuming these templates in a notebook.