Visualization & tiles

Every render decision — which bands, what colormap, how to rescale — lives in the backend, so the same item looks identical whether it is drawn by the portal, an SDK, an agent, or an embedded map client.

Geopera serves imagery as standard XYZ map tiles ({z}/{x}/{y}) produced on demand from Cloud-Optimized GeoTIFFs (COGs). You never compute pixels client-side: you tell the platform what to show (a visualization, or explicit band-math), and it resolves the how and returns the tile. Three layers cooperate:

  • Tile rendering — turn a source COG (or a catalog item) into a PNG/WebP/JPEG tile.
  • Visualizations — the named, backend-owned catalog of how an item can be shown (natural colour, colour-infrared, spectral indices, elevation), fully resolved.
  • Map-client integration — TileJSON and OGC WMTS so OpenLayers, Leaflet, deck.gl, or QGIS can consume a layer with a plain URL template.

All operations here are side_effect=read and scoped tiles:read (or items:read for the catalog-item routes). See scopes for what each grants.

The tile contract is mid-migration. An in-flight kernel change is collapsing render decisions onto a single governed operation: the client will send source + a visualization id (+ optional AOI) and the backend resolves colormap, band-math, and rescale server-side (auto or AOI-scoped) rather than the client baking a fixed rescale= into the URL. Because of this, the exact request fields for cog.tile.render and visualization.list_for may differ from what you see below. Treat this page as the concepts and workflow; always confirm the current parameter shapes against the operations reference before you hardcode a request. Do not bake a global rescale value into tile URLs — let the backend rescale.

How tile rendering works

A tile request names an XYZ coordinate and a source, and the backend reads only the overviews and windows it needs from the COG, applies the render config, and returns the encoded tile. There are two source families:

  • Arbitrary COG (cog.*) — render any SSRF-allowlisted COG URL. Authentication is the only gate; there is no per-resource access check, so these are ideal for public data and your own COG outputs.
  • Catalog item (items.tile.*) — render one of your catalog items by item_id, with item-level access control (project or org membership) and per-tile usage attribution applied automatically.

Both produce byte-identical tiles for the same render config and are heavily cached (a hot in-process cache plus Redis, with ETag and long Cache-Control headers).

Operations at a glance

OperationSourceScopeReturns
cog.tile.renderCOG URLtiles:readBinary tile (png/webp/jpg)
cog.tile.terrainCOG URLtiles:readTerrain-RGB tile (webp)
cog.thumbnailCOG URLtiles:readJPEG thumbnail
cog.statisticsCOG URLtiles:readAOI percentile stats + rescale
cog.formulastiles:readSpectral-index formula templates
cog.colormapstiles:readCurated colormap groups
visualization.list_foritem bandstiles:readAvailable visualizations
items.tile.renderitem iditems:readBinary tile
items.tile.tilejsonitem iditems:readTileJSON metadata
items.tile.statisticsitem iditems:readExpression stats
items.tile.wmts_capabilitiesitem iditems:readWMTS GetCapabilities XML
items.tile.wmts_get_tileitem iditems:readWMTS GetTile (binary)

Every one is invoked the same way: POST /v1/op/{operation_id} with a JSON body and a Bearer token. See authentication and concepts for the RPC-over-HTTP model.

Rendering a tile from a COG

cog.tile.render is the workhorse. You give it a COG url, an XYZ coordinate (z/x/y), and an output format, plus how to colour it. The colouring is expressed one of two ways, and they are mutually exclusive:

  • bands — a comma-separated list of 1-based band indices for an RGB composite, e.g. "1,2,3" for true colour.
  • expression — a band-math formula using b1, b2, … references (for a spectral index like NDVI). Pair with a colormap to colour the single-band result.

Supported format values are png, webp, jpg, and jpeg. quality (default 85) applies to lossy formats.

bash
curl -s -X POST https://api.geopera.com/v1/op/cog.tile.render \
  -H "Authorization: Bearer $GEOPERA_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://data.geopera.com/cogs/scene-abc.tif",
    "z": 12, "x": 1205, "y": 2654,
    "format": "webp",
    "bands": "1,2,3"
  }' --output tile.webp

A band-math render uses expression plus a colormap instead of bands:

bash
curl -s -X POST https://api.geopera.com/v1/op/cog.tile.render \
  -H "Authorization: Bearer $GEOPERA_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://data.geopera.com/cogs/scene-abc.tif",
    "z": 12, "x": 1205, "y": 2654,
    "format": "png",
    "expression": "(b4-b3)/(b4+b3)",
    "colormap": "rdylgn"
  }' --output ndvi.png

The response is the raw tile bytes (this op is raw_response), with Content-Type set from the format, a long-lived Cache-Control, and an ETag you can use for conditional requests.

Rescale: let the backend decide

rescale (a "min,max" string) maps data values to the colour ramp. The platform direction is to stop baking a fixed global rescale into tile URLs — a global elevation range flattens a 0–50 m harbour DEM to one colour, for example. Two strategies the backend applies for you:

  • Auto rescale — derived from the dataset’s own statistics (e.g. a 2-σ stretch from embedded/overview stats) when no AOI is supplied.
  • AOI rescale — scaled to the data inside an area of interest, so the colour ramp is consistent at every zoom. Compute it with cog.statistics (below), or pass an aoi so the render scales and clips to it.

If you do pass an explicit rescale, treat it as an override and confirm the current field shape in the operations reference — this is one of the parameters in flux.

AOI-scoped statistics

cog.statistics reads the COG masked to a GeoJSON geometry and returns percentile statistics plus a ready-to-use rescale string, so colormaps scale to the data inside your AOI rather than a global range. It works for elevation, RGB band combos, and band-math indices.

bash
curl -s -X POST https://api.geopera.com/v1/op/cog.statistics \
  -H "Authorization: Bearer $GEOPERA_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://data.geopera.com/cogs/dem-harbour.tif",
    "geometry": {
      "type": "Polygon",
      "coordinates": [[[151.20,-33.86],[151.23,-33.86],[151.23,-33.84],[151.20,-33.84],[151.20,-33.86]]]
    },
    "percentiles": [2.0, 98.0]
  }'

Use the returned rescale when you render tiles for that AOI so the harbour DEM spans its real ~0–42 m, not −100…3000 m.

Terrain and thumbnails

  • cog.tile.terrain renders a Mapbox Terrain-RGB tile from a single-band elevation/depth COG — the encoding deck.gl and other 3D clients expect for a height mesh. It accepts the same z/x/y, an optional invert (for depth), and an optional aoi that clips the mesh so it does not extend beyond the AOI on zoom-out.
  • cog.thumbnail produces a quick JPEG preview (default 256×256) from the smallest COG overview — cheap, and handy for catalog cards. It auto-stretches non-uint8 data with a 2–98 percentile clip.
bash
curl -s -X POST https://api.geopera.com/v1/op/cog.thumbnail \
  -H "Authorization: Bearer $GEOPERA_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "url": "https://data.geopera.com/cogs/scene-abc.tif", "width": 320, "height": 320 }' \
  --output thumb.jpg

Visualizations: the backend-owned catalog

Rather than each client deciding what “natural colour” or “NDVI” means, Geopera owns the catalog of visualizations for an item. visualization.list_for takes the bands available on an item (mapped from its eo:bands) and returns the standard, fully resolved VisualizationSpecs — RGB composites, colour-infrared, the applicable spectral indices from the authoritative index registry, and elevation for DEMs. Band-math is resolved to b1/b2 references and labels are correct by construction (a DEM is never mislabeled “Natural Colour”).

You give it band_roles (role name → 1-based band index), a data_type (raster, dem, …), and optional registry render_params:

bash
curl -s -X POST https://api.geopera.com/v1/op/visualization.list_for \
  -H "Authorization: Bearer $GEOPERA_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "band_roles": { "RED": 1, "GREEN": 2, "BLUE": 3, "NIR": 4 },
    "data_type": "raster"
  }'

A representative response (fields shown are the resolved spec shape; the exact request contract for this op is part of the in-flight migration — verify it in operations):

json
{
	"visualizations": [
		{
			"id": "natural-colour",
			"label": "Natural Colour",
			"kind": "rgb",
			"bands": [1, 2, 3],
			"is_default": true,
			"description": "True-colour RGB"
		},
		{
			"id": "color-infrared",
			"label": "Colour Infrared",
			"kind": "rgb",
			"bands": [4, 1, 2],
			"description": "NIR-Red-Green — vegetation in red"
		},
		{
			"id": "ndvi",
			"label": "NDVI",
			"kind": "index",
			"expression": "(b4-b1)/(b4+b1)",
			"colormap": "rdylgn",
			"rescale": "-1,1",
			"category": "vegetation"
		}
	],
	"default_id": "natural-colour"
}

Each spec carries everything a render needs: bands (for RGB), or expression + colormap + rescale (for an index), or a colormap + rescale for elevation. The intended workflow is to read the catalog, let the user pick a visualization id, and have the backend resolve that id into the render config when it draws the tile — so the client never re-derives band-math, colormaps, or rescale.

For a DEM, visualization.list_for returns a single elevation spec with a terrain colormap; render it with cog.tile.render (colormapped) for the 2D map and cog.tile.terrain for the 3D mesh, sharing one rescale.

Per-item saved profiles

The derived catalog above is the standard menu. Users can also save their own visualization profiles on an item (a named band/expression/colormap/rescale config); clients merge those with the derived catalog.

OperationSide-effectScopeUse
visualization.profiles.listreaditems:readList an item’s saved profiles
visualization.profile.getreaditems:readGet one profile
visualization.profile.createcomputeitems:writeSave a new profile (editor/admin)
visualization.profile.updatecomputeitems:writeUpdate a profile (editor/admin)
visualization.profile.set_defaultcomputeitems:writeMake a profile the item default
visualization.profile.deletedestructiveitems:writeRemove a profile (editor/admin)

Reads require item member access (project or org membership); writes require editor or admin on the item’s project. See scopes.

Catalog-item tiles

For your own catalog items, items.tile.render renders by item_id with item-level access control and automatic per-tile usage attribution. You can drive the render with a saved profile, or override directly with bands / expression (+ rescale, colormap); asset_key selects a named asset on multi-asset items.

bash
curl -s -X POST https://api.geopera.com/v1/op/items.tile.render \
  -H "Authorization: Bearer $GEOPERA_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "item_id": "itm_8f2c...",
    "z": 12, "x": 1205, "y": 2654,
    "format": "webp",
    "profile": "natural-colour"
  }' --output item-tile.webp

items.tile.tilejson returns TileJSON 3.0.0 metadata (name, tile URL template, zoom range, the resolved profile, and the renderable asset) for clients that bootstrap a layer from a TileJSON document. items.tile.statistics computes global p2/p98 expression statistics from the smallest overview for a quick rescale.

Serving tiles to map clients

Map libraries want a plain URL template, not a POST body. Geopera preserves the classic XYZ / TileJSON / WMTS wire contracts through thin GET projections over these ops, so you can wire a layer into OpenLayers, Leaflet, deck.gl, or QGIS directly.

XYZ template

Point any XYZ layer at the catalog-item tile route. The template uses {z}/{x}/{y} and a format extension:

javascript
// OpenLayers / Leaflet style XYZ template for a catalog item
const tileUrl =
	'https://api.geopera.com/api/v1/items/itm_8f2c.../tiles/{z}/{x}/{y}.webp?profile=natural-colour';

The matching items.tile.tilejson document gives you that template plus zoom bounds if you prefer to configure the layer from TileJSON.

WMTS

For OGC clients (QGIS, ArcGIS, MapServer-based stacks), items.tile.wmts_capabilities emits a WMTS 1.0.0 GetCapabilities document, and items.tile.wmts_get_tile serves individual tiles (KVP). Capabilities advertises the GoogleMapsCompatible (EPSG:3857) tile matrix set across zoom 0–18, image/webp and image/png formats, and a ResourceURL template. You supply base_url (and an optional key) so the emitted absolute URLs resolve back to your deployment.

bash
curl -s -X POST https://api.geopera.com/v1/op/items.tile.wmts_capabilities \
  -H "Authorization: Bearer $GEOPERA_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "item_id": "itm_8f2c...",
    "base_url": "https://api.geopera.com",
    "profile": "natural-colour"
  }'

Point a WMTS client at the WMTSCapabilities.xml projection of that op and it discovers the layer, tile matrix set, and tile URL template automatically. items.tile.wmts_get_tile serves a transparent tile rather than erroring when a coordinate has no data, which keeps map clients happy at the edges of coverage.

Worked example: NDVI over an AOI

End to end — discover the visualization, scope the rescale to an AOI, and render a tile — in Python with the geopera package.

python
from geopera import Client

client = Client(api_key="gpra_...")  # or a session token

cog = "https://data.geopera.com/cogs/scene-abc.tif"
aoi = {
    "type": "Polygon",
    "coordinates": [[
        [151.20, -33.86], [151.23, -33.86],
        [151.23, -33.84], [151.20, -33.84], [151.20, -33.86],
    ]],
}

# 1. Discover the visualizations available for these bands.
viz = client.op("visualization.list_for", {
    "band_roles": {"RED": 1, "GREEN": 2, "BLUE": 3, "NIR": 4},
    "data_type": "raster",
})
ndvi = next(v for v in viz["visualizations"] if v["id"] == "ndvi")

# 2. Scope the colormap to the data inside the AOI.
stats = client.op("cog.statistics", {
    "url": cog,
    "geometry": aoi,
    "expression": ndvi["expression"],
})

# 3. Render a tile using the resolved expression, colormap, and AOI-scoped rescale.
tile = client.op("cog.tile.render", {
    "url": cog,
    "z": 12, "x": 1205, "y": 2654,
    "format": "png",
    "expression": ndvi["expression"],
    "colormap": ndvi["colormap"],
    "rescale": stats["rescale"],
})
with open("ndvi-aoi.png", "wb") as f:
    f.write(tile)

The same three calls work identically from @geopera/sdk, the geopera CLI, or the MCP gateway — there is one render decision point, so every client produces the same pixels.

Gotchas

  • bands and expression are mutually exclusive. Sending both is a 400. Use bands for an RGB composite, expression (+ colormap) for a single-band index.
  • Band indices are 1-based, comma-separated as a string ("1,2,3"), matching the band numbering in the visualization catalog.
  • Don’t bake a global rescale into URLs. A fixed range flattens data outside it; prefer auto or AOI rescale via cog.statistics. The exact rescale plumbing is part of the in-flight migration.
  • cog.* renders have no per-resource access check — authentication alone is the gate. Use items.tile.* for catalog items so item-level entitlement and usage attribution apply.
  • Reuse the cache. Tiles carry an ETag and a long Cache-Control; send If-None-Match to avoid re-rendering unchanged tiles. AOI-scoped tiles are cached separately from global ones.
  • Renders can time out. A tile or stats computation that exceeds the server budget returns 504; a malformed expression returns 400. Errors are problem+json.
  • Confirm the contract. cog.tile.render and visualization.list_for are mid-strangle — check the operations reference for current fields rather than relying on the examples here verbatim.

Related