<!-- Source: https://docs.geopera.com/api-reference/guides/visualization-and-tiles · Markdown for LLMs -->

# 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](/api-reference/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](/api-reference/operations) 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

| Operation                      | Source     | Scope        | Returns                          |
| ------------------------------ | ---------- | ------------ | -------------------------------- |
| `cog.tile.render`              | COG URL    | `tiles:read` | Binary tile (png/webp/jpg)       |
| `cog.tile.terrain`             | COG URL    | `tiles:read` | Terrain-RGB tile (webp)          |
| `cog.thumbnail`                | COG URL    | `tiles:read` | JPEG thumbnail                   |
| `cog.statistics`               | COG URL    | `tiles:read` | AOI percentile stats + rescale   |
| `cog.formulas`                 | —          | `tiles:read` | Spectral-index formula templates |
| `cog.colormaps`                | —          | `tiles:read` | Curated colormap groups          |
| `visualization.list_for`       | item bands | `tiles:read` | Available visualizations         |
| `items.tile.render`            | item id    | `items:read` | Binary tile                      |
| `items.tile.tilejson`          | item id    | `items:read` | TileJSON metadata                |
| `items.tile.statistics`        | item id    | `items:read` | Expression stats                 |
| `items.tile.wmts_capabilities` | item id    | `items:read` | WMTS GetCapabilities XML         |
| `items.tile.wmts_get_tile`     | item id    | `items:read` | WMTS GetTile (binary)            |

Every one is invoked the same way: `POST /v1/op/{operation_id}` with a JSON body and a
Bearer token. See [authentication](/api-reference/authentication) and
[concepts](/api-reference/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](/api-reference/operations) — 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
`VisualizationSpec`s — 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](/api-reference/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.

| Operation                           | Side-effect | Scope         | Use                               |
| ----------------------------------- | ----------- | ------------- | --------------------------------- |
| `visualization.profiles.list`       | read        | `items:read`  | List an item's saved profiles     |
| `visualization.profile.get`         | read        | `items:read`  | Get one profile                   |
| `visualization.profile.create`      | compute     | `items:write` | Save a new profile (editor/admin) |
| `visualization.profile.update`      | compute     | `items:write` | Update a profile (editor/admin)   |
| `visualization.profile.set_default` | compute     | `items:write` | Make a profile the item default   |
| `visualization.profile.delete`      | destructive | `items:write` | Remove 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](/api-reference/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](/api-reference/errors).
- **Confirm the contract.** `cog.tile.render` and `visualization.list_for` are mid-strangle
  — check [the operations reference](/api-reference/operations) for current fields rather
  than relying on the examples here verbatim.

## Related

- [Operations reference](/api-reference/operations) — the authoritative, current
  parameter list for every tile and visualization operation.
- [Processing & analytics](/api-reference/guides/processing) — produce the COGs and
  derived rasters you then visualize.
- [Authentication](/api-reference/authentication) · [Scopes](/api-reference/scopes) ·
  [Errors](/api-reference/errors) — the cross-cutting contracts every op shares.
