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

Anatomy of a fully specified tile URL The path carries the tile matrix set and the z, x and y indices. The query string carries the source url, the rescale range that maps data values onto the display range, the colormap that turns those values into colours, and optionally a band expression and a nodata override. Everything the render depends on lives in the URL /cog/tiles/WebMercatorQuad/{z}/{x}/{y}.png which tile, in which grid url=s3://.../ndvi.tif which file — restrict this server-side rescale=-0.2,0.9 data range mapped onto 0-255 colormap_name=rdylgn values to colours expression, nodata, bidx optional: derive, mask, select bands

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

Colour table in the file, or colormap in the URL When the raster carries its own colour table the tile URL needs no colour parameters at all and every consumer renders identically. When it does not, a custom colormap must be passed as encoded JSON on every request, which works but leaves the definition in each client rather than in one place. Two ways to colour a class raster colour table in the file URL carries no colour parameters every consumer agrees automatically one definition, in the data prefer this whenever you control the file colormap in the URL JSON mapping values to RGBA must be repeated in every client definitions drift apart over time for files you cannot rewrite A file whose colours travel with it needs no coordination between teams.
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 as a rendering smoke test A rescale range far above the data renders a nearly uniform dark tile that compresses to about one kilobyte. A range matching the data renders real structure at twenty kilobytes. A range far too narrow renders near-random noise that compresses badly at eighty kilobytes. Only the middle case is a correct image. PNG size tells you whether the rescale is sane range too wide 1 KB — flat, almost all one colour range matches 20 KB — real structure range too narrow 80 KB Assert a size band in the smoke test and a bad rescale fails the deploy, not the viewer.

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.