第01章 - 影像云原生全景与架构定位

一景 30 米分辨率的多光谱影像,10000 × 10000 像素、3 个波段、uint16,未压缩约 570 MB,压成 COG 后仍有 300 MB 上下。业务方只想看某条河谷 2 km × 2 km 的假彩色合成。如果影像还躺在传统分发体系里,这个需求的处理方式是:把整景下载下来,用 QGIS 打开,裁切,导出 PNG。10 MB/s 的线路要半分钟,磁盘上多了一份临时文件,第二天同样的需求再来一次。

云原生影像要解决的就是这件事:让“看一小块”只传输一小块的字节。做到这一点需要四个构件同时成立——对象存储提供按字节寻址的底座,COG 让文件内部可以被随机定位,STAC 让“哪一景、哪一块、什么波段”变成可检索的元数据,动态瓦片服务把这三者拼成一次 HTTP 请求。缺任何一环,链路都退化成“下载整景”。

这一章先把四个构件各自的职责边界、当前版本基线和彼此之间的接口讲清楚,后面十章都是在这个骨架上填细节。

1.1 传统分发为什么贵

把影像发布成 Web 服务,行业里长期有两条路,两条路都有结构性成本。

第一条是 WMS/WMTS 切片农场。为了让任意缩放级别都能秒开,需要预先渲染每一级瓦片。一景 30 米分辨率的影像铺到 z18,瓦片数量是百万级;全国范围、多时相就是十亿级对象和对应的存储与渲染机队。数据一旦更新,切片要重跑;用户只访问过 3% 的范围,剩下 97% 的瓦片照样占着存储。

第二条是 整景下载 + 本地处理。省了切片成本,但每次分析都要搬运全量数据。一景 1.1 GB,一个县级的年度分析可能要几百景,网络和临时存储都成了瓶颈。

两条路的共同问题是:数据被复制成了服务形态。原始影像一份、切片一份、有时候还有 COG 一份,三份数据要同步、要校验、要计费。

云原生路线把这件事反过来:只保留一份原始 COG,服务在读的时候现算。代价从“预计算 + 存储”转移到了“每次请求多几次 HTTP 往返 + 一点 CPU”。当请求量不大、或者请求高度集中在少数区域时,这个交换非常划算;当请求量极大且区域固定时,缓存层会把成本再压回去。第 17、20 章会算这笔账,这里先记住结论:云原生不是无条件更便宜,它把固定成本换成了可变成本。

1.2 四个构件与它们的边界

1.2.1 对象存储:唯一的数据底座

对象存储(S3、OSS、COS、MinIO)提供三件影像服务真正需要的东西:HTTP GET 可寻址、字节范围读取、以及几乎无限的容量与按量计费。它不提供目录树语义(前缀不是目录)、不提供文件锁、不支持原地修改(对象只能整体覆盖)。

这条“不支持原地修改”的约束会一路传导到架构上:COG 一旦上传就不再改写,需要修正就重写一个新对象;STAC Item 的 href 指向的对象因此是稳定的,可以放心做 CDN 缓存和长 TTL。第 6 章会展开 Range 语义与 CDN 行为。

1.2.2 COG:让文件内部可随机定位

COG(Cloud Optimized GeoTIFF)不是新格式,它是 GeoTIFF 的一种内部组织约定,2023 年被 OGC 发布为标准:OGC 21-026,版本 1.0,批准日期 2023-05-08,发布日期 2023-07-14,类别是 OGC Implementation Standard。

标准要求里最关键的几条,直接决定了“能不能只读一小块”:

  • 所有 IFD(Image File Directory)必须按瓦片组织,不能用条带(strip);
  • 至少有一个全分辨率 IFD,其后跟着逐级缩小的降分辨率子文件(overview),每一级的缩小倍数在 2 到 10 之间;
  • 最后一级 overview 必须小到只剩一个瓦片(TilesAcross 或 TilesDown 等于 1);
  • 地理参考只写在全分辨率 IFD 里,overview 不重复写;
  • 文件超过 4 GB 必须用 BigTIFF。

再加上一条推荐性条款(Recommendation 3):IFD 与瓦片数据要按“全分辨率 IFD → 各级 overview IFD → 最小 overview 瓦片 → … → 全分辨率瓦片”的顺序排列。这条不是强制,但它决定了打开文件需要几次 HTTP 请求——按推荐顺序排,一次 16 KB 的请求就能把所有 IFD 读完;排得乱,可能要三次往返。OGC 文档给的参照数字是:S3 单次往返 20–30 ms,叠加传输后常见 150 ms,三次就是额外约 540 ms 的感知延迟。

第 3 章会逐条拆这些要求,并用 rio-cogeo 的校验代码对照说明。

1.2.3 STAC:让文件可被检索

STAC(SpatioTemporal Asset Catalog)是一套 JSON 元数据规范,把“一景影像”描述成一个 GeoJSON Feature,附带时间、空间范围、资产列表。当前核心规范版本是 1.1.0(2024-09-10 发布),由四部分组成:Catalog(目录)、Collection(集合)、Item(条目),以及公共部分(Link、Asset、Common Metadata)。

STAC 解决的是“找”的问题,不是“读”的问题。它规定 Item 的 assets 里放什么 href,但不规定这个 href 指向的文件内部怎么组织——那个职责在 COG 那边。这条分工很重要:STAC 可以描述任何格式的资产(COG、NetCDF、Zarr、GeoParquet),COG 只负责让其中一种格式变得可流式读取。

与之配套的 STAC API 1.0.0(2023-04-24 发布)定义了 /search 等端点。注意版本号错位:数据规范是 1.1.0,API 规范仍是 1.0.0,两者的 conformance URI 里写的都是 v1.0.0。这个错位在写配置和做兼容性判断时经常踩到,第 10 章会专门说。

1.2.4 动态瓦片:让像素按需渲染

TiTiler 是一个基于 FastAPI + Rasterio/GDAL 的动态瓦片服务。它接收 z/x/y 与一个 COG 的 URL,在服务端读出对应窗口、做重采样与拉伸、返回 PNG/JPEG/WebP。当前核心包 titiler-core 的版本是 2.4.0,生态里还有 titiler-mosaic(MosaicJSON 镶嵌)与 titiler-extensions(算法、缓存等)。

它不存数据、不建索引,是一个无状态计算层。无状态意味着可以随便水平扩容,也意味着每次请求都要重新走一遍“读 COG 头 → 定位瓦片 → 解码 → 重采样”,所以缓存(第 17 章)和 COG 布局(第 3、5 章)对它的性能影响极大。

1.3 全链路架构

把四个构件串起来,一条完整的链路是这样:

   ┌──────────┐   ┌───────────┐   ┌──────────────┐   ┌─────────────┐
   │ 原始影像  │──▶│ COG 生产   │──▶│ 对象存储      │◀──│ CDN / 缓存   │
   │ GeoTIFF  │   │ rio-cogeo │   │ S3 / OSS     │   │ CloudFront  │
   └──────────┘   └─────┬─────┘   └──────┬───────┘   └──────┬──────┘
                        │                │  Range GET       │
                        │ 元数据          │                  │
                        ▼                ▼                  ▼
                  ┌───────────┐   ┌──────────────┐   ┌─────────────┐
                  │ STAC 编目  │──▶│ STAC API     │   │  TiTiler    │
                  │ pystac    │   │ stac-fastapi │   │ 动态瓦片     │
                  └───────────┘   │ + pgstac     │   └──────┬──────┘
                                  └──────┬───────┘          │
                                         │ 检索              │ PNG/JPEG
                                         ▼                   ▼
                                  ┌──────────────────────────────┐
                                  │ 前端地图 / STAC Browser       │
                                  └──────────────────────────────┘

链路上有三条独立的数据流,容易混淆,值得单独指出:

  1. 元数据流:影像 → STAC Item(JSON)→ 索引库(PostgreSQL/pgstac)→ 检索接口。数据量小,更新频繁。
  2. 像素流:客户端 → TiTiler → 对象存储的 Range 请求 → 瓦片字节。数据量大,每次请求只碰几 KB 到几百 KB。
  3. 浏览流:STAC Browser 读 STAC API 或静态目录,展示缩略图与元数据,点开某个资产时才触发像素流。

三条流的故障表现完全不同:元数据流挂了是“搜不到”,像素流挂了是“搜到了但图裂”,浏览流挂了是“页面打不开”。排障时先分清是哪条流,比盲目看日志有效得多。

1.3.1 一次瓦片请求到底传了多少字节

用一个具体例子算清楚。沿用开头那景:10000 × 10000 像素、3 波段 uint16、512 × 512 瓦片、DEFLATE 压缩、5 级 overview,COG 约 300 MB。

客户端请求 z14 的某块瓦片,TiTiler 需要:

  • 读文件头与 IFD:按 OGC 推荐布局约 2–6 KB(标准给的参照是 4096×4096、4 级 overview 时 5 个 IFD 及 TileOffsets 落在前 6 KB);
  • 定位到对应 overview 层(WebMercator 在 z14 赤道处约 9.55 米/像素,30 米源数据约需 3 倍降采样,即第 2 级;纬度越高每像素地面范围越小,所需倍数越大,60°N 约 6 倍),读 1–4 个 512×512 瓦片块,uint16 压缩后每块约 60–150 KB;
  • 解码、重采样到 256×256、拉伸、编码成 PNG,约 40–120 KB 返回。

总计从对象存储读 60 KB–600 KB(1 个块到 4 个块,加上几 KB 头部),返回给浏览器 40–120 KB。相比下载 300 MB,是两到三个数量级的差别。

这个估算里最容易被忽略的是“读几个块”:如果请求的瓦片刚好跨在 COG 块边界上,就要读 4 个块而不是 1 个。块大小与瓦片网格对齐能把这件事的概率降到最低,第 5 章讲 –blocksize 与 –tms 时会回到这里。

1.4 版本基线

下表是本教程写作时核实过的版本,代码示例按这些版本验证。版本差异导致的行为变化,会在对应章节单独标注。

组件 版本 核实来源 影响范围
STAC 核心规范 1.1.0(2024-09-10) stac-spec 仓库 CHANGELOG Item/Collection/Catalog 字段、bands 进入 core
STAC API 规范 1.0.0(2023-04-24) stac-api-spec CHANGELOG /search 参数、conformance URI
COG 标准 OGC 21-026 v1.0(2023-07-14) docs.ogc.org/is/21-026 文件布局硬约束
GDAL 3.13.3 PyPI gdal COG driver、/vsicurl/ 行为
rio-cogeo 7.0.4 PyPI 与仓库 init.py rio cogeo 子命令与校验规则
pystac 1.16.0 PyPI 目录构建 API
stac-fastapi 7.1.1 仓库 VERSION 分层结构、扩展命名空间
stac-fastapi-pgstac 7.2.1 PyPI 后端装配、环境变量
pgstac 0.10.1 GitHub release 表结构、SQL 函数
titiler-core 2.4.0 PyPI 瓦片端点
STAC Browser 5.2.0 GitHub release 部署模式、配置项

两个 Python 版本约束值得提前记住:rio-cogeo 7.x 要求 Python ≥ 3.11,stac-fastapi 7.x 也要求 ≥ 3.11。想用 3.10 跑这套工具链会在安装阶段就失败。

1.5 最小可跑示例

四个构件各给一段能立刻验证的代码。这些示例不需要先读完后面十章。

1.5.1 只读文件头,判断是不是 COG

对象存储上的 COG 可以用一次 Range 请求检查布局。前 1 KB 里能看到 TIFF 魔数、第一个 IFD 的偏移量,以及 GDAL 3.1 之后写入的 ghost 区(GDAL_STRUCTURAL_METADATA)。

# 只取前 1 KB,不下载整个文件
curl -s -r 0-1023 "https://example-bucket.s3.amazonaws.com/landsat/scene.tif" -o head.bin

# TIFF 魔数:小端 II (0x4949) 或大端 MM (0x4d4d),第三、四字节是 42
xxd -l 16 head.bin
# 00000000: 4949 2a00 0800 0000 2d00 0000 0a00 0000  II*.........

# 看 ghost 区(GDAL >= 3.1 写入,用于标记布局是否被破坏)
strings head.bin | head -20
# GDAL_STRUCTURAL_METADATA_SIZE=000140 bytes
# LAYOUT=IFDS_BEFORE_DATA
# BLOCK_ORDER=ROW_MAJOR
# BLOCK_LEADER=SIZE_AS_UINT4
# BLOCK_TRAILER=LAST_4_BYTES_REPEATED
# KNOWN_INCOMPATIBLE_EDITION=NO

LAYOUT=IFDS_BEFORE_DATA 表示 IFD 在数据之前,这是 COG 的基本特征;KNOWN_INCOMPATIBLE_EDITION=YES 表示这个文件曾经被原地修改过、优化已失效——rio-cogeo 的校验会把它判成错误(见第 5 章)。

如果没有 ghost 区(GDAL < 3.1 或非 GDAL 工具生成),仍然可以用 IFD 偏移量判断:第一个 IFD 的偏移写在文件第 4–8 字节,值小于 300 说明头部紧凑,符合 COG 期望。

1.5.2 用 rasterio 只读一个窗口

import rasterio
from rasterio.windows import from_bounds

# 远程 COG,rasterio 内部走 GDAL /vsicurl/
url = "https://example-bucket.s3.amazonaws.com/landsat/scene.tif"

with rasterio.open(url) as src:
    print(src.profile)
    # {'driver': 'GTiff', 'dtype': 'uint16', 'width': 10000, 'height': 10000,
    #  'count': 3, 'crs': CRS.from_epsg(32650), 'transform': Affine(...),
    #  'blockxsize': 512, 'blockysize': 512, 'tiled': True}

    # 只读 2 km x 2 km 的范围(UTM 50N,单位米)
    window = from_bounds(500000, 4400000, 502000, 4402000, src.transform)
    data = src.read([1, 2, 3], window=window, out_shape=(3, 256, 256))
    print(data.shape, data.dtype)   # (3, 256, 256) uint16

注意 out_shape 参数:它让 GDAL 在读取时直接做降采样,而不是先读全分辨率再缩。对 10000 像素的影像读 2 km 窗口,不指定 out_shape 会拿到约 67 × 67 像素的原始数据(30 米分辨率下 2 km 约 67 像素),指定后由 GDAL 选择合适层级——这个选择依赖 overview 是否存在。

1.5.3 用 pystac 读目录

import pystac

# 静态目录:读根 catalog,再取第一个 collection 的第一个 item
cat = pystac.Catalog.from_file("https://example.com/stac/catalog.json")
print(cat.id, cat.description)

for child in cat.get_children():
    print("collection:", child.id)
    for item in child.get_items():
        print("  item:", item.id, item.datetime, item.bbox)
        for key, asset in item.assets.items():
            print("    asset:", key, asset.media_type, asset.href)
        break
    break

pystac 会自动解析相对链接——这也是第 7 章推荐用 pystac 而不是手写 JSON 的主要原因:链接的绝对化、self/root/parent 的补全,手工维护极易出错。

1.5.4 用 TiTiler 出一张瓦片

# TiTiler 默认把 COG 工厂挂在 /cog,瓦片路由带 TileMatrixSetId
curl -s -o tile.png \
  "https://titiler.example.com/cog/tiles/WebMercatorQuad/12/3410/1612?url=https%3A%2F%2Fexample-bucket.s3.amazonaws.com%2Flandsat%2Fscene.tif&rescale=0%2C8000&colormap_name=viridis"

file tile.png
# tile.png: PNG image data, 256 x 256, 8-bit/color RGB, non-interlaced

url 参数必须整体 URL 编码,否则查询串里的 & 会把参数截断——这是接入 TiTiler 时最高频的错误,第 13 章会给出编码前后的对照。

1.6 常见误区

现象 真实原因 定位方法
瓦片服务很慢,但对象存储监控显示带宽不高 COG 未分块(strip),每次读都要拉整行 rio info 看 blockxsize 是否等于 width
首屏要 2–3 秒才出图 IFD 分散在文件各处,打开时多次往返 rio cogeo validate 看 IFD offset 顺序
浏览器报 CORS 错误,curl 却正常 服务端未返回 Access-Control-Allow-Headers: range 浏览器 Network 面板看预检请求
STAC 里搜得到,点开图裂 Item 的 href 是相对路径,前端解析基准错了 直接 curl 该 href
同一份数据在 QGIS 正常、TiTiler 报错 数据不是 COG,TiTiler 仍能读但性能极差 rio cogeo validate
上传后校验通过,第二天变成 invalid 有人用 GDAL 原地 update 过文件 ghost 区 KNOWN_INCOMPATIBLE_EDITION=YES

第三条值得展开:OGC 21-026 明确要求 HTTPS 服务在响应头里加 Access-Control-Allow-Headers: range,否则浏览器跨域发起 Range 请求时预检会失败。很多对象存储的默认 CORS 配置只允许 Content-Type,需要显式加 range。

1.7 生产建议

先定 COG 的块大小,再定瓦片服务的网格。 两者不匹配时,每次请求都要多读 1–3 个块。经验值:块 512 × 512,瓦片网格用 WebMercatorQuad,overview 因子取 2,级数算到“最小一级只剩一个块”为止。第 5 章会给完整计算。

把 STAC 元数据和像素分开部署。 元数据小、变化快,适合放数据库加 API;像素大、只读,适合放对象存储加 CDN。混在一起的典型后果是:为了更新一条元数据去重写整个 COG,或者为了加速像素访问去给数据库加缓存。

不要为“以防万一”预生成瓦片。 动态瓦片加 CDN 的组合在访问集中时命中率很高;只有在访问极度分散、且 QPS 很高时,预切片才更划算。判断依据是缓存命中率,不是主观感觉。

接受最终一致性。 对象存储的覆盖写、CDN 的 TTL、pgstac 的 search 缓存(默认有 TTL),三者叠加意味着“刚上传的影像立刻能搜到并看到图”需要额外设计。第 18 章会给一条可用的流水线顺序。

1.8 本章小结

要点 结论
云原生的本质 只保留一份 COG,服务在读时现算,把固定成本换成可变成本
COG 的作用 让文件内部可按块随机定位,标准是 OGC 21-026 v1.0
STAC 的作用 描述“有什么”,不描述“怎么读”;核心规范 1.1.0,API 规范 1.0.0
TiTiler 的作用 无状态计算层,性能取决于 COG 布局与缓存
关键数字 /vsicurl/ 默认 16 KB 块;IFD offset < 300;块 512;瓦片 256
版本底线 Python ≥ 3.11;GDAL ≥ 3.1(COG driver 与 ghost 区)

下一章进入 STAC 的数据模型,把 Catalog、Collection、Item、Asset、Link 五个对象和 1.1.0 相对 1.0.0 的变化逐字段拆开。