第13章 - TiTiler API 与瓦片服务
上一章把 TiTiler 拆成了工厂和中间件。这一章落到具体接口:一次瓦片请求吃哪些参数、返回什么结构、哪些参数会真的改变像素。很多人调 TiTiler 的挫败感来自“参数写进去了但画面没变”,根因通常不是参数写错,而是参数没走到对应环节——nodata 影响的是读取,rescale 影响的是渲染,两者错配就会得到一张全白或全黑的图。
先把一条硬规则放前面:TiTiler 的 query 参数不是自由文本,每一个都绑定到一个 Pydantic 模型,非法值在依赖注入阶段就被拒绝。好处是坏参数不会带进 GDAL;代价是错误信息是 422 加一段 JSON Schema 描述,不熟悉的人容易以为服务挂了。
13.1 一次瓦片请求的几何
/cog/tiles/{tileMatrixSetId}/{z}/{x}/{y} 里的 z/x/y 不是“第几级第几列”,而是“在指定 TileMatrixSet 的第 z 个矩阵里的列行号”。同一个 z/x/y 在 WebMercatorQuad 和 WorldCRS84Quad 下指向完全不同的地理范围,这是两个 TMS 混用时最常见的错位来源。
WebMercatorQuad 的定义(EPSG:3857,原点在左上,pointOfOrigin = [-20037508.3427892, 20037508.3427892]):
第 z 级矩阵宽高 = 2^z 个瓦片
单瓦片地理跨度 = 40075016.6855785 / 2^z 米
z=0 单瓦片约 40075 km(全球一张)
z=10 单瓦片约 39.1 km
z=14 单瓦片约 2.45 km
z=18 单瓦片约 153 m
一张瓦片在像元层面是 256×256(TMS 的 tileWidth × tileHeight),所以 z=14 时地面分辨率约 2.45 km / 256 ≈ 9.6 m。当源数据是 10 m 分辨率的 Sentinel-2 时,z=14 已经接近 1:1,往下的 z=13、z=12 会读 overview,往上的 z=15 及以上要做上采样。这个“哪一级开始读 overview”的判断在 rio-tiler 内部由 minzoom/maxzoom 和源数据的 internal overviews 决定,/info 能看到结果。
反查:给一个经纬度,怎么算它落在哪张瓦片?
import math
def lonlat_to_tile(lon: float, lat: float, z: int) -> tuple[int, int]:
n = 2 ** z
x = int((lon + 180.0) / 360.0 * n)
lat_rad = math.radians(lat)
y = int((1.0 - math.asinh(math.tan(lat_rad)) / math.pi) / 2.0 * n)
return min(max(x, 0), n - 1), min(max(y, 0), n - 1)
print(lonlat_to_tile(116.391, 39.907, 12)) # 北京,z=12
# 预期输出形如 (3372, 1552),具体值取决于取整规则
asinh(tan(lat)) 等价于 Web 墨卡托的 y 反算,比手写对数公式数值更稳。纬度超过 ±85.0511° 时没有对应瓦片,clamp 会把结果压到边缘,真实请求则得到 404。
13.2 端点全景
TilerFactory 注册的端点按功能分四组。下表是 2.x 的实际路径(前缀由 include_router 决定,这里省略):
| 分组 | 方法 | 路径 | 返回 |
|---|---|---|---|
| 元数据 | GET | /info |
Info JSON:bounds、band_descriptions、dtype、nodata_type 等 |
| 元数据 | GET | /info.geojson |
同上,包成 GeoJSON Feature,带 bbox 与 geometry |
| 统计 | GET | /statistics |
波段名为键的 BandStatistics 字典 |
| 统计 | POST | /statistics |
对 GeoJSON 范围内做统计 |
| OGC Tiles | GET | /tiles |
TileSetList,每个 TMS 一个 tileset |
| OGC Tiles | GET | /tiles/{tileMatrixSetId} |
单个 tileset 元数据 |
| 瓦片 | GET | /tiles/{tileMatrixSetId}/{z}/{x}/{y} |
图片或二进制 |
| 瓦片 | GET | /tiles/{tileMatrixSetId}/{z}/{x}/{y}.{format} |
指定格式 |
| 前端 | GET | /{tileMatrixSetId}/tilejson.json |
TileJSON 3.0.0 |
| 前端 | GET | /{tileMatrixSetId}/map.html |
内置查看器 HTML |
| 采样 | GET | /point/{lon},{lat} |
像元值 |
| 出图 | GET | /preview[/{width}x{height}][.{format}] |
整幅预览 |
| 出图 | GET | /bbox/{minx},{miny},{maxx},{maxy}[/{w}x{h}].{format} |
指定范围 |
| 出图 | POST | /feature[/{w}x{h}][.{format}] |
按 GeoJSON 裁切 |
| OGC Maps | GET | /map |
WMS 风格的 bbox + width + height + crs |
/tiles 与 /tiles/{tileMatrixSetId} 是 OGC API - Tiles 的元数据端点,结构来自 models/OGC.py 的 TileSetList。每个 tileset 里最有用的是 rel 为 tile 的那条链接:
{
"tilesets": [{
"title": "tileset tiled using WebMercatorQuad TileMatrixSet",
"dataType": "map",
"crs": "http://www.opengis.net/def/crs/EPSG/0/3857",
"boundingBox": {
"lowerLeft": [-180.0, -85.0511287798066],
"upperRight": [180.0, 85.0511287798066],
"crs": "http://www.opengis.net/def/crs/OGC/1.3/CRS84"
},
"links": [
{"href": ".../tiles/WebMercatorQuad/{z}/{x}/{y}?url=...", "rel": "tile"}
]
}]
}
boundingBox.crs 用的是 CRS84(经纬度、经度在前),而 tileset 的 crs 是 EPSG/0/3857。一个是数据范围所在的 CRS,一个是瓦片网格的 CRS,两者不一致是规范要求,不是 bug。写客户端时把 bbox 当成 3857 米制坐标去算会得到完全错误的结果。
13.3 瓦片端点的参数
路径参数只有五个,查询参数按依赖分组。下表按“影响哪一段”归类,这个归类比按字母排序有用得多:
| 参数 | 类型/默认 | 影响阶段 | 说明 |
|---|---|---|---|
tileMatrixSetId |
路径,必填 | 几何 | TMS 标识,非法值 422 |
z x y |
路径,整数 | 几何 | 矩阵行列号,越界 404 |
format |
路径,可选 | 编码 | png/jpg/jpeg/webp/tif/tiff/jp2/npy/pngraw |
url |
query,必填 | 读取 | 数据集地址,支持 s3://、/vsis3/、http(s):// |
bidx |
可重复整数 | 读取 | 波段序号,从 1 开始 |
expression |
str | 读取 | 波段运算,如 b4/b3,分号分隔多表达式 |
nodata |
int/float/nan |
读取 | 覆盖内部 nodata |
unscale |
bool,默认 False | 读取 | 应用数据集内部 scale/offset |
resampling |
枚举,默认 nearest | 读取 | RasterIO 重采样,9 个合法值 |
reproject |
枚举,默认 nearest | 读取 | 重投影时的 WarpKernel,14 个合法值 |
buffer |
float,默认 0 | 读取 | 瓦片外扩,单位是像元数的一半,必须 0.5 的倍数 |
padding |
int,默认 0 | 读取 | 边缘补边,减少重采样接缝 |
tilesize |
int,默认取 TMS 矩阵尺寸 | 读取 | 覆盖输出尺寸,如 512 |
rescale |
可重复 min,max |
渲染 | 逐波段拉伸 |
color_formula |
str | 渲染 | rio-color 公式 |
colormap_name |
枚举 | 渲染 | rio-tiler 内置色带名 |
colormap |
str(JSON) | 渲染 | 自定义色带 |
return_mask |
bool,默认 True | 渲染 | 是否把掩膜写进输出 |
algorithm |
枚举 | 后处理 | 内置算法名,如 hillshade |
algorithm_params |
str(JSON) | 后处理 | 算法参数 |
两个容易误解的默认值:
resampling 只管同一 CRS 内的缩放,reproject 只在重投影时生效。源数据是 UTM、TMS 是 WebMercator 时两步都会发生,只改前者而图像仍模糊,通常是卡在了后者。
buffer=0.5 得到的不是 256×256,而是 257×257。官方参数说明写得很清楚:输出尺寸会扩展为 tilesize + 2 * buffer。做瓦片拼接时如果忘了裁掉这圈 buffer,相邻瓦片会出现一像素错位。
13.4 输出格式与 dtype
格式决定 dtype 的合法范围,完整对照表来自 render_image() 里的 format_dtypes:
| 格式 | 合法 dtype | 媒体类型 | 典型体积(256×256) |
|---|---|---|---|
| png | uint8, uint16 | image/png | 8~40 KB |
| pngraw | uint8, uint16 | image/png | 同上,但保留原始掩膜行为 |
| jpeg / jpg | uint8 | image/jpeg | 5~20 KB |
| webp | uint8 | image/webp | 3~15 KB |
| jp2 | uint8, int16, uint16 | image/jp2 | 10~50 KB |
| tif / tiff | 任意 | image/tiff; application=geotiff | 100 KB~1 MB |
| npy | 任意 | application/x-binary | 原始字节,3 波段 256×256 的 uint16 约 512 KB |
体积是经验值:同样 256×256,海面几乎纯色的 JPEG 只要 3 KB,城市纹理能到 25 KB。容量规划按 15 KB 估 P50、40 KB 估 P95。
npy 是最容易被低估的格式。它返回 NumPy 的 .npy,形状是 (bands + 1, height, width),最后一片是掩膜。用它做像素级校验比解 PNG 可靠得多:
import io
import numpy
import requests
BASE = "http://127.0.0.1:8000/cog"
URL = "https://example.com/data/scene.tif"
r = requests.get(
f"{BASE}/tiles/WebMercatorQuad/13/6707/3113.npy",
params={"url": URL, "bidx": [1, 2, 3]}, # 可重复参数用 list
timeout=30,
)
r.raise_for_status()
arr = numpy.load(io.BytesIO(r.content))
print(arr.shape, arr.dtype) # 例:(4, 256, 256) uint16
data, mask = arr[:-1], arr[-1]
print(data[0].min(), data[0].max(), mask.mean())
bidx=[1,2,3] 被编码成 bidx=1&bidx=2&bidx=3,正是 FastAPI 解析整数列表期望的形式。写成 bidx=1,2,3 会 422,因为 BidxParams.indexes 不会拆分逗号串。
另一个坑是 return_mask。默认 True 时 PNG 会带 alpha 通道;JPEG 不支持 alpha,会被静默丢弃。如果下游做拼接,用 return_mask=false 加 PNG 更省体积,但边缘会变成黑色而不是透明。
13.5 tilejson:前端的接入契约
/{tileMatrixSetId}/tilejson.json 不只是便利端点,它决定了前端怎么拼 URL。返回体在标准 TileJSON 3.0.0 字段之外,额外塞了四个 TiTiler 专有字段:
| 字段 | 来源 | 用途 |
|---|---|---|
tiles |
由当前请求的 query 重写生成 | 瓦片模板,已带全部渲染参数 |
bounds |
get_geographic_bounds() |
数据范围(经纬度) |
minzoom / maxzoom |
源数据推导,可被 query 覆盖 | 前端跳级 |
attribution |
环境变量 TITILER_DEFAULT_ATTRIBUTION |
版权署名 |
raster_layers |
get_renders(src_dst) |
STAC Render 定义的图层 |
band_descriptions |
info() |
波段含义 |
data_type |
info() |
数据类型,用来决定 rescale |
minmax |
info() |
各波段最小最大值,可直接用作 rescale |
tiles 字段的生成逻辑值得单独说:它把请求里除 tilematrixsetid、tile_format、minzoom、maxzoom 之外的所有 query 原样带到瓦片 URL 上,并在缺省时补一个 tilesize。这意味着三件事:
- 调
tilejson.json?url=..&rescale=0,3000&colormap_name=viridis,前端拿到的瓦片模板会自动带上这两个参数,前端不用重复配置; - 反过来,任何不想泄漏的参数(比如内部 token)也会被带进
tiles,出现在浏览器 Network 面板里; - 参数过多时 URL 会很长,某些代理对 2 KB 以上的 URL 有截断风险。
实际的返回样例(字段顺序按模型定义):
{
"tilejson": "3.0.0",
"scheme": "xyz",
"tiles": ["https://t.example.com/cog/tiles/WebMercatorQuad/{z}/{x}/{y}?url=...&tilesize=512"],
"bounds": [116.28, 39.83, 116.51, 40.02],
"minzoom": 6,
"maxzoom": 20,
"data_type": "uint16",
"minmax": [[0, 65535]]
}
scheme 默认是 xyz(y 轴向下),不是 tms(y 轴向上)。MapLibre 和 Leaflet 默认都按 xyz 处理,所以不用管;自己写瓦片解码器时如果按 TMS 处理,整幅图会上下颠倒。
tilesize 的默认值在这里是 512,而瓦片端点默认取 TMS 的 256。这个不一致是有意的:2.0 把 tilejson 默认改成 512,因为高 DPI 屏上 512 更清晰且请求数减半,且 MapLibre 的 raster source 默认也是 512,两端刚好对上。
13.6 info、statistics、point 的返回
/info 是最该先调的端点,返回 rio_tiler.models.Info:
| 字段 | 含义 | 排错用途 |
|---|---|---|
bounds |
数据集范围(WGS84) | 判断瓦片是否落在数据外 |
band_metadata |
每波段的元数据元组 | 看 nodata、单位 |
band_descriptions |
波段描述 | 判断波段顺序 |
dtype |
数据类型 | 决定 rescale 与输出格式 |
nodata_type |
Alpha/Mask/Internal/Nodata/None |
掩膜从哪来 |
colorinterp |
色彩解释 | 判断是否已经是 RGB |
scales / offsets |
内部缩放 | 决定 unscale 要不要开 |
colormap |
内嵌色带 | 判断是否需要外部色带 |
nodata_type 能解释绝大多数“黑边”:Alpha 有 alpha 波段,Mask 有独立掩膜波段,Internal 是 TIFF 内部 mask,Nodata 是 nodata 值,None 没有任何掩膜。最后一种情况下透明区按 0 渲染,出图一片黑。
/statistics 默认返回每个波段的统计,percentiles 默认 [2, 98],histogram_bins 默认 10。返回结构里 histogram 是 [counts, bin_edges] 的两行数组——计数在前、边界在后,这一点与 numpy 的返回值顺序相反,直接画图时别弄反。做自动拉伸时用 p2 和 p98 比用 min/max 稳,因为后者会被单个异常像元拉到极端值。
/point/{lon},{lat} 返回:
{
"coordinates": [116.391, 39.907],
"values": [1234.0],
"band_names": ["1"],
"band_descriptions": ["B04"]
}
values 里可能是 null——当该点落在掩膜区或数据外时。前端把 null 当 0 处理是最常见的数值错误来源。另外 TiTiler 用的 JSONResponse 会把 NaN、Infinity 转成 null,因为标准 JSON 不支持这三个字面量。
13.7 完整调用示例
用 curl 走一遍从元数据到瓦片的完整链路,每一步都有明确的预期输出:
BASE="http://127.0.0.1:8000/cog"
SRC="https://example.com/data/scene.tif"
# 1. 数据集信息
curl -s "$BASE/info?url=$SRC" | jq '{bounds, dtype, nodata_type, band_descriptions}'
# 2. 统计(用 2/98 分位做拉伸区间)
curl -s "$BASE/statistics?url=$SRC&bidx=1&p=2&p=98" | jq '.1 | {min, max}'
# 3. 可选 TMS 与瓦片模板
curl -s "$BASE/tiles?url=$SRC" | jq '.tilesets[].links[] | select(.rel=="tile") | .href'
# 4. 一张瓦片,存盘后看大小与类型
curl -s -o tile.png -w '%{http_code} %{content_type} %{size_download}\n' \
"$BASE/tiles/WebMercatorQuad/12/3372/1552.png?url=$SRC&rescale=0,3000"
# 5. 检查缓存头
curl -sI "$BASE/tiles/WebMercatorQuad/12/3372/1552.png?url=$SRC" | grep -i cache-control
第 5 步的预期输出是 cache-control: public, max-age=3600,来自 ApiSettings.cachecontrol 的默认值。这个头是 CDN 能否生效的前提,第 17 章展开。
再用 Python 做一次批量校验:给定一组瓦片坐标,统计哪些返回 200、哪些 404。验收新数据集时用它能快速画出实际覆盖范围。
import requests
BASE = "http://127.0.0.1:8000/cog"
SRC = "https://example.com/data/scene.tif"
def probe(x: int, y: int) -> int:
return requests.get(
f"{BASE}/tiles/WebMercatorQuad/10/{x}/{y}.png",
params={"url": SRC, "rescale": "0,3000"},
timeout=20,
).status_code
codes = [probe(x, y) for x in range(840, 860) for y in range(380, 400)]
print(f"命中 {codes.count(200)}/{len(codes)}")
这段是串行的,40 个请求大约 2~4 秒。要压测并发应当用独立脚本,别把验收脚本当成压测工具——它的目的是”覆盖范围对不对”,不是”能扛多少 QPS”。
13.8 状态码与错误语义
错误处理集中在 titiler/core/errors.py 的 DEFAULT_STATUS_CODES,映射关系是固定的:
| 异常 | HTTP | 触发条件 |
|---|---|---|
MissingBands |
400 | 没给 bidx 也没给 expression,而数据集是多波段 |
MissingAssets |
400 | 多资产端点没给 assets(2.x 起必填) |
InvalidExpression |
400 | 表达式语法错,或引用了不存在的波段 |
InvalidFormat |
400 | 输出格式不在枚举内 |
InvalidColorFormat |
400 | color_formula 无法解析 |
InvalidAssetName |
404 | assets= 指定的资产不存在 |
InvalidBandName |
404 | 表达式里的 bN 超出波段数 |
TileOutsideBounds |
404 | 瓦片与数据范围不相交 |
RasterioIOError |
500 | 文件打不开:路径错、权限、网络 |
RioTilerError |
500 | rio-tiler 内部错误 |
这张表要记住的是 404 的两种含义:TileOutsideBounds 是“这片本来没数据”,InvalidAssetName/InvalidBandName 是“资源名错”。前者正常,后者是配置错误。日志里两者都表现为 404,生产环境要按 detail 里的异常名分桶,否则告警会被正常空瓦片淹没。
502/504 不在 TiTiler 的错误表里,它们来自上游:代理超时、对象存储 5xx。RasterioIOError 被映射成 500 而不是 502,这是可以商榷的设计——监控会把“上游存储故障”算成“服务自身故障”。
13.9 踩坑清单
expression 与 bidx 同时给。 两个依赖都在 layer_dependency 里,BidxExprParams 的继承顺序是 ExpressionParams 在前、BidxParams 在后,expression 优先。同时给不会报错,bidx 被忽略。
rescale 的个数少于波段数。 rio-tiler 的 rescale_image() 里有一句 if len(in_range) != nbands: in_range = ((in_range[0]),) * nbands,也就是所有波段都套用第一组 rescale。三波段影像只给 rescale=0,1000 时,绿蓝波段也会用 0~1000 拉伸,大概率过曝。
URL 里带 & 的签名地址。 预签名 URL 自带 ?X-Amz-Signature=...&X-Amz-Date=...,直接塞进 url= 会把后面的参数当成本服务的 query。两种解法:整段 base64 编码,或者只把 query 部分编码后单独传。官方示例给了完整的自定义 path_dependency 写法(见 docs/src/examples/code/working_with_signed_urls.md)。
tilesize=512 但前端没设 tileSize。 MapLibre 的 raster source 默认 512,Leaflet 的 TileLayer 默认 256。前者不用改,后者必须写 tileSize: 512,否则图像被切成四块各显示一角。
unscale=true 之后 rescale 区间没跟着变。 unscale 会把 DN 值按 scale/offset 转成物理量,数值范围可能从 0~10000 变成 0~1。原来写的 rescale=0,3000 就完全失效了。先 /info 看 scales/offsets,再定 rescale。
13.10 生产建议
- 把
/info与/statistics的结果缓存到配置里。这两个端点要打开文件读元数据,在列表页会成倍放大。 maxzoom要显式给。让 rio-tiler 自己推可能得到 22 甚至更高,而源数据只到 18,多出来的层级全是上采样,白白消耗 CPU 和带宽。- 错误日志只记
url尾部 64 个字符。完整 URL 可能含签名,写进日志就是泄漏。 - 对
/tiles与/{tms}/tilejson.json做短 TTL 缓存。它们随数据集变化,但一秒内不会变,max-age=60就能挡掉大部分重复请求。 /statistics限制max_size。默认预览是 1024,对超大栅格会读很多块;做自动拉伸建议固定max_size=256,精度足够且成本降一个数量级。
13.11 本章要点
| 结论 | 依据 |
|---|---|
z/x/y 只在指定 TMS 下有地理含义 |
WebMercatorQuad 与 WorldCRS84Quad 的原点和分辨率不同 |
buffer=0.5 使输出变成 tilesize+1 |
TileParams.buffer 的官方描述 |
resampling 与 reproject 是两段插值 |
DatasetParams 的两个独立字段 |
| jpeg/webp 只接受 uint8,png 接受 uint8/uint16 | render_image() 的 format_dtypes |
npy 形状是 (bands+1, h, w),末片为掩膜 |
官方 output_format 文档 |
tilejson 的 tiles 会带上全部 query 参数 |
tilejson 端点的 query 透传逻辑 |
| tilejson 默认 tilesize 512,瓦片端点默认取 TMS 尺寸 | 2.0.0a1 变更记录 |
| 404 同时表示“无数据”与“资源名错” | TileOutsideBounds 与 InvalidAssetName 都映射 404 |
下一章进入渲染层:rescale、colormap、color_formula、algorithm 分别在哪一步改像素,以及为什么同一份数据在不同重采样下会差出一个像素的地物边界。