第15章 - MosaicJSON 与影像镶嵌
单片 COG 的瓦片服务,/cog 一个端点就够了。但真实业务几乎没有”只看一景”的场景:一个省域的正射影像有上千个分幅,一个时间序列的关键帧有几十期。把上千个 URL 塞进一次请求,路由和参数长度都不允许;逐景出图再在前端拼,接缝和色彩都不一致。
这中间需要一层”哪些文件覆盖了这张瓦片”的索引,这就是 MosaicJSON。它不存像素,只存”瓦片坐标 → 文件列表”的映射,让一次请求知道该打开哪几个 COG、按什么顺序叠加。
15.1 MosaicJSON 是什么
MosaicJSON 是一份 JSON 规范,当前版本 0.0.3(developmentseed/mosaicjson-spec 仓库的 0.0.3/Readme.md 与 schema.json)。它的定位一句话就能说清:面向 XYZ 瓦片的 COG 清单。
和 STAC 的区别值得先厘清,两者经常被混用:
| 维度 | STAC Item | MosaicJSON |
|---|---|---|
| 描述单位 | 一个资产(一景、一个文件) | 一组资产的瓦片级索引 |
| 空间表达 | 几何 + bbox | quadkey → 文件列表 |
| 时间表达 | datetime / start_datetime |
无(要靠文件列表里的顺序或 backend 侧处理) |
| 用途 | 目录、检索、溯源 | 直接驱动瓦片渲染 |
| 生成方式 | 采集时元数据抽取 | 从文件列表烧制(burn) |
| 标准 | STAC 1.1.0 | MosaicJSON 0.0.3 |
简单说:STAC 回答”有什么”,MosaicJSON 回答”这张瓦片用哪几个文件”。前者可以很重(带完整元数据、扩展、链接),后者必须很轻,因为它会在每次瓦片请求时被读取。
15.2 字段与约束
规范里 17 个字段,真正影响渲染的是下面 8 个:
| 字段 | 必填 | 类型 | 说明 |
|---|---|---|---|
mosaicjson |
是 | string | 版本号,形如 0.0.3 |
tiles |
是 | object | quadkey → 文件 URL 数组 |
minzoom |
是 | int | 最小层级,键所在层级 |
maxzoom |
是 | int | 最大层级,必须 ≥ minzoom |
bounds |
是(schema) | array | [left, bottom, right, top],WGS84 |
quadkey_zoom |
否 | int | 索引层级,缺省等于 minzoom |
center |
否 | array | [lon, lat, zoom] |
asset_prefix |
否 | string | 拼在每个资产前的公共前缀 |
注意 bounds 这里有个规范内部的不一致:Readme 3.9 节写的是 OPTIONAL、默认全球,但 schema.json 的 required 数组里明确包含 bounds。校验器按 schema 走,也就是没有 bounds 会被判无效。生成工具都会写这个字段,手写 JSON 时容易漏。
tiles 的键是 quadkey,不是 z/x/y。quadkey 是把瓦片的二进制行列号按位交错编码成的一串 0~3 数字,长度等于层级。规范对键的约束写在 schema 的 patternProperties 里:[0123]{1,30},也就是 1 到 30 位。
一个 quadkey 与 XYZ 的换算:
def tile_to_quadkey(x: int, y: int, z: int) -> str:
"""XYZ 瓦片号转 quadkey。"""
key = []
for i in range(z, 0, -1):
digit = 0
mask = 1 << (i - 1)
if x & mask:
digit += 1
if y & mask:
digit += 2
key.append(str(digit))
return "".join(key)
def quadkey_to_tile(qk: str) -> tuple[int, int, int]:
"""quadkey 转 XYZ 瓦片号。"""
x = y = 0
z = len(qk)
for i, ch in enumerate(qk):
mask = 1 << (z - i - 1)
d = int(ch)
if d & 1:
x |= mask
if d & 2:
y |= mask
return x, y, z
print(tile_to_quadkey(3372, 1552, 12)) # 形如 "131023013012"
print(quadkey_to_tile("131023013012")) # (3372, 1552, 12)
quadkey_zoom 是性能开关。当它小于 maxzoom 时,低层级的瓦片请求会把多个 quadkey 的文件列表合并,也就是”一个 z=8 的瓦片对应 z=12 的 16 个 quadkey”。这带来两个后果:低层级请求要读更多文件列表(内存换 IO),但避免了为每个层级都建索引。规范里明确写了”当 quadkey_zoom 大于 minzoom 时,tiler 会合并 quadkey 的资产列表”。
选值经验:数据量大(十万级以上文件)、覆盖范围广时用 quadkey_zoom = minzoom + 4 左右;文件数在千级以下,直接用 minzoom,简单且索引小。
15.3 生成:cogeo-mosaic create
生成 MosaicJSON 的工具是 cogeo-mosaic,CLI 有 create、create-from-features、footprint、info、to-geojson、update、upload 七个子命令。最常用的是前两个。
从文件列表生成:
# list.txt 每行一个 COG 的 URL
cat > list.txt <<'EOF'
https://bucket.s3.amazonaws.com/ortho/tile_001.tif
https://bucket.s3.amazonaws.com/ortho/tile_002.tif
https://bucket.s3.amazonaws.com/ortho/tile_003.tif
EOF
cogeo-mosaic create list.txt -o mosaic.json
# 直接写到 S3,支持 gzip(后缀 .json.gz 自动压缩)
cogeo-mosaic create list.txt -o s3://bucket/mosaic/ortho.json.gz
# 指定层级与覆盖阈值
cogeo-mosaic create list.txt -o mosaic.json \
--minzoom 8 --maxzoom 16 --quadkey-zoom 10 --min-tile-cover 10
--min-tile-cover 是”一个文件至少要覆盖瓦片的百分之几才写进列表”,默认 0.0。设成 10 能砍掉大量边缘碎片,显著减小 MosaicJSON 体积,代价是瓦片边缘可能有缺口。
从 GeoJSON / STAC 生成:
# 从 STAC API 拿 ItemCollection,用某个属性作为资产标识
curl -s "https://earth-search.aws.element84.com/v1/collections/sentinel-2-l2a/items?bbox=116,39,117,40&limit=100" \
| cogeo-mosaic create-from-features \
--minzoom 9 --maxzoom 15 \
--property "assets.red.href" \
-o mosaic.json
--property 是访问器路径,支持点号深入。上面这种写法直接取 Item 的 assets.red.href,得到的 MosaicJSON 里每个值是 COG 的直链而不是 Item ID。如果用默认的 id,MosaicJSON 里存的就是 Item ID,TiTiler 侧还需要一个能把它翻译成文件路径的 reader,这在后端是常见错误——瓦片请求会报 RasterioIOError,因为 GDAL 把 Item ID 当成了相对路径。
代码里生成(适合在 ETL 里嵌):
from cogeo_mosaic.mosaic import MosaicJSON
urls = [
"https://bucket.s3.amazonaws.com/ortho/tile_001.tif",
"https://bucket.s3.amazonaws.com/ortho/tile_002.tif",
]
mosaic = MosaicJSON.from_urls(
urls,
minzoom=8,
maxzoom=16,
quadkey_zoom=None, # 缺省等于 minzoom
minimum_tile_cover=0.05,
tile_cover_sort=False,
)
print(mosaic.minzoom, mosaic.maxzoom, len(mosaic.tiles))
with open("mosaic.json", "w", encoding="utf-8") as f:
f.write(mosaic.model_dump_json(exclude_none=True))
from_urls 会逐个打开 COG 读边界,N 个文件就是 N 次 HEAD/GET。一千个文件在本地磁盘上几十秒,在 S3 上可能要几分钟。批量场景下并发读元数据比顺序读快一个数量级,MosaicJSON.from_urls 内部已经用了线程池,但受 IO 限制,实际加速比在 4~8 倍之间。
tile_cover_sort=True 会把每个 quadkey 的文件列表按覆盖率排序,覆盖率高的排前面。配合 PixelSelectionMethod.first(默认)时,这相当于”优先用主体覆盖的影像”,能减少边缘碎片的拼接痕迹。
15.4 backend:文件放哪里
cogeo-mosaic 3.0 起把”MosaicJSON 存哪”抽象成 backend,URL 的 scheme 决定实现类:
| Backend | URL 形式 | 读写 | 适用场景 |
|---|---|---|---|
| FileBackend | file:///path/m.json 或裸路径 |
读写 | 单机、容器内挂载卷 |
| S3Backend | s3://bucket/m.json.gz |
读写 | AWS 上最常用 |
| GCSBackend | gs://bucket/m.json.gz |
读写 | GCP |
| ABSBackend | az://account.blob.core.windows.net/c/m.json |
读写 | Azure |
| DynamoDBBackend | dynamodb://region/table |
读写 | 超大 mosaic,按 quadkey 分项存 |
| SQLiteBackend | sqlite:///m.db:name |
读写 | 单机多 mosaic,可事务更新 |
| HttpBackend | https://host/m.json.gz |
只读 | 静态托管,配合 CDN |
| STACBackend | stac+https://api/search |
只读 | 动态从 STAC 检索生成 |
| MemoryBackend | :memory: 或 None |
内存 | 临时计算、测试 |
选择的核心变量是单次瓦片请求要读多少 MosaicJSON 数据。FileBackend 和 HttpBackend 每次请求都要读整个文件(带 gzip 解压),适合 10 MB 以内;DynamoDB 按 quadkey 只读一个分项,适合几十万键的大 mosaic;SQLite 介于两者之间,本地有 B 树索引但没有网络层的按需读取。
一个 100 万 quadkey、平均每键 4 个 URL 的 MosaicJSON,gzip 后约 40~80 MB。这种规模下 HttpBackend 每次瓦片请求解压 60 MB 是不可接受的,必须换 DynamoDB,或者把 quadkey_zoom 抬高、让键数降下来。
MosaicBackend 是个工厂函数,按 URL 前缀自动选类,代码里通常不用关心具体类型:
from cogeo_mosaic.backends import MosaicBackend
# 自动识别为 S3Backend
with MosaicBackend("s3://bucket/mosaic/ortho.json.gz") as mosaic:
print(type(mosaic).__name__) # S3Backend
print(mosaic.mosaic_def.minzoom)
img = mosaic.tile(12, 3372, 1552)
print(img.array.shape)
tile() 返回的是 rio_tiler.models.ImageData,和单文件 reader 返回的类型一致,所以下游渲染代码可以复用。
15.5 接入 TiTiler
MosaicTilerFactory 和 TilerFactory 的结构几乎一样,区别只有两处:必须传 backend,以及多一组 /assets 端点用来查”这张瓦片用了哪些文件”。
from fastapi import FastAPI
from cogeo_mosaic.backends import MosaicBackend
from titiler.core.errors import DEFAULT_STATUS_CODES, add_exception_handlers
from titiler.mosaic.errors import MOSAIC_STATUS_CODES
from titiler.mosaic.factory import MosaicTilerFactory
app = FastAPI(title="Mosaic Tiler")
mosaic = MosaicTilerFactory(
backend=MosaicBackend,
router_prefix="/mosaicjson",
add_statistics=True, # 默认 False
add_part=True, # 默认 False
add_ogc_maps=True, # 默认 False
)
app.include_router(mosaic.router, prefix="/mosaicjson", tags=["Mosaic"])
add_exception_handlers(app, DEFAULT_STATUS_CODES)
add_exception_handlers(app, MOSAIC_STATUS_CODES)
add_* 的默认值和 TilerFactory 不同:镶嵌的 /statistics、/bbox、/map 都是关的。原因是这三个端点对一个 mosaic 做全量计算的成本远高于单文件,官方选择默认不挂。
MOSAIC_STATUS_CODES 把 cogeo-mosaic 的异常映射成 HTTP 状态码:MosaicAuthError → 401,MosaicError → 424,MosaicNotFoundError → 404。424(Failed Dependency)这个选择很讲究——它表达的是”你的请求没问题,但我依赖的 mosaic 出错了”,比笼统的 500 更准确。
调用示例:
MOSAIC="s3://bucket/mosaic/ortho.json.gz"
BASE="http://127.0.0.1:8000/mosaicjson"
# 基本信息
curl -s "$BASE/info?url=$MOSAIC" | jq '{bounds, minzoom, maxzoom}'
# 这张瓦片用了哪些文件(排错神器)
curl -s "$BASE/tiles/WebMercatorQuad/12/3372/1552/assets?url=$MOSAIC" | jq
# 出图
curl -s -o mosaic.png \
"$BASE/tiles/WebMercatorQuad/12/3372/1552.png?url=$MOSAIC&rescale=0,3000"
# 校验一份 MosaicJSON(注意是 POST)
curl -s -X POST "$BASE/validate" \
-H "Content-Type: application/json" \
--data-binary @mosaic.json
/tiles/{tms}/{z}/{x}/{y}/assets 是排查接缝问题的第一站。它返回该瓦片相交的文件列表,能立刻看出”这块为什么有缝”——列表里少了应该覆盖的文件,或者顺序不对。
/validate 是 POST 不是 GET。官方 docs/src/endpoints/mosaic.md 的表格里写的还是 GET,那是文档滞后;extensions/mosaicjson.py 里注册的是 @factory.router.post("/validate"),请求体是完整的 MosaicJSON 对象。用 GET 调用会得到 405。
15.6 像素选择:叠加顺序即语义
同一张瓦片上有多个文件时,怎么合并由 pixel_selection 决定,默认 first。可取值来自 rio-tiler 的 PixelSelectionMethod 枚举:
| 值 | 行为 | 适用 |
|---|---|---|
first |
先到先得,填满即停 | 优先级明确的分幅拼接 |
highest |
逐像元取最大值 | 云污染下的反射率、热异常 |
lowest |
逐像元取最小值 | 合成最少云覆盖 |
mean |
堆叠求均值 | 多时相平滑 |
median |
堆叠求中位数 | 去异常值,效果比 mean 稳 |
stdev |
堆叠求标准差 | 变化检测 |
sum |
堆叠求和 | 累积量(如降雨栅格) |
lastbandlow / lastbandhigh |
用最后一个波段做决策 | 按质量波段择优 |
count |
统计有效像元数 | 覆盖率分析 |
first 的性能最好,因为它有 exit_when_filled=True:一旦所有像元都被填满就停止,不再打开后续文件。这意味着一张贴满的瓦片可能只读了列表里的第一个文件,而不是全部。反过来说,mean、median 必须读完列表里所有文件,成本是 first 的数倍到数十倍。
# 用中位数合成去云
curl -s -o median.png \
"$BASE/tiles/WebMercatorQuad/12/3372/1552.png?url=$MOSAIC\
&pixel_selection=median&rescale=0,3000"
MOSAIC_CONCURRENCY 控制一次瓦片请求内并发读文件的线程数,默认取 RIO_TILER_MAX_THREADS(CPU×5)。设成 1 会把并行读退化成串行,延迟随文件数线性增长;设得比 CPU 数大太多只增加上下文切换。我的经验值是 min(文件数, CPU×4)。
MOSAIC_STRICT_ZOOM 是另一个容易被忽略的环境变量:默认 false,也就是请求的 z 超出 [minzoom, maxzoom] 时仍然返回(向上采样或向下采样)。设成 true 后,超出范围直接返回空瓦片。对成本敏感的场景我建议开启,因为 z 超出 maxzoom 的请求是最典型的”无效放大”,只会消耗 CPU。
15.7 排错
MosaicNotFoundError → 404。 URL 拼错,或者 backend 类型判错。s3:// 前缀但实际是 HTTP 可读的公开桶时,被判成 S3Backend 后还需要 AWS 凭证,就会 401 而不是 404。公网可读的 mosaic 一律用 https:// 前缀。
tile outside bounds → 404,但数据明明覆盖这里。 先看 bounds 字段。手写的 MosaicJSON 里 bounds 写得比实际小,超出部分全被判为越界。规范要求 bounds 覆盖所有层级的全部数据,宁大勿小。
瓦片有缝。 三种原因:--min-tile-cover 设得太大,边缘文件被过滤;tile_cover_sort 没开,first 选到了覆盖不全的文件;源 COG 之间有 1~2 像素的空隙。第三种最常见,是分幅生产时的问题,只能在生成 COG 阶段重叠一点。
低层级请求特别慢。 大概率是 quadkey_zoom 等于 minzoom 且数据量大。z=6 的瓦片要遍历它下面所有 quadkey 的键,键数越多越慢。把 quadkey_zoom 抬高 3~4 级,让低层级只查少量父键。
from_urls 卡住不动。 它在逐个读 COG 头。远端文件加 AWS_NO_SIGN_REQUEST=YES 或正确的凭证,并确认 GDAL_DISABLE_READDIR_ON_OPEN=EMPTY_DIR 已设——不设的话每个文件都多一次 LIST。
更新后瓦片没变化。 backend 类自带缓存(S3Backend 会缓存已解析的 mosaic),进程不重启就看不到新版本。生产环境更新 MosaicJSON 后要滚动重启,或者改用 DynamoDB 这类实时读的 backend。
15.8 生产建议
- MosaicJSON 要版本化。文件名带上内容哈希或日期,例如
ortho-20261101.json.gz,让 CDN 可以长缓存。原地覆盖会让缓存永远命中旧版本。 - 单文件别超过 50 MB(gzip 后)。超过就该考虑 DynamoDB 或拆分 mosaic。这个阈值来自”每次瓦片请求都要读一遍”的成本模型。
maxzoom不要超过源数据实际分辨率对应的层级。多出来的层级是纯上采样,白烧 CPU。用第 13 章的lonlat_to_tile配合源数据分辨率算出这个上限。first+tile_cover_sort是默认组合里性价比最高的。它让最”主体”的文件先被读,exit_when_filled能提前终止,实际 IO 量比无序列表少 30%~50%。- 对
/assets端点限流。它会返回完整文件列表,被爬虫盯上等于泄漏数据组织方式。内网用,或者加 token。
15.9 本章要点
| 结论 | 依据 |
|---|---|
MosaicJSON 0.0.3 的 tiles 是 quadkey → URL 数组 |
schema.json 的 patternProperties |
schema 里 bounds 是必填 |
0.0.3/schema.json 的 required 数组 |
quadkey_zoom 缺省等于 minzoom |
Readme 3.8 节 |
| 9 种 backend,Http 与 STAC 只读 | cogeo-mosaic backends 文档 |
| MosaicTilerFactory 必须传 backend | mosaic/factory.py 的 backend: type[BaseBackend] |
first 有 exit_when_filled,能提前终止 |
FirstMethod.exit_when_filled = True |
| 424 表示 mosaic 依赖出错 | MOSAIC_STATUS_CODES 的映射 |
/validate 是 POST 不是 GET |
extensions/mosaicjson.py 的路由装饰器 |
下一章把 STAC 和 TiTiler 接起来:不用预生成 MosaicJSON,直接拿 STAC 检索结果驱动出图,以及这条路在什么规模下会失效。