第03章 - COG 云优化 GeoTIFF 原理
一个 10000 × 10000 的 GeoTIFF,未压缩约 300 MB。客户端要第 14 级的一块 256 × 256 瓦片,理论上只需要几百 KB 的像素。但“需要”和“能取到”是两件事:如果文件按条带(strip)存储,一行条带横跨整个 10000 像素宽,取 256 × 256 就得读 256 条横条,每条 10000 像素,加起来约 7.6 MB 的解压前数据;如果按瓦片(tile)存储,读 1 到 4 个 512 × 512 块就够了。
COG 就是把“能取到”变成现实的那层组织约定。它没有发明新格式,只是把 TIFF 已有的三个机制——分块、降分辨率子文件、元数据前置——按固定顺序摆好,让一次 HTTP Range 请求能定位到目标字节。
2023 年 OGC 把这件事标准化为 OGC 21-026 Cloud Optimized GeoTIFF Standard v1.0(批准 2023-05-08,发布 2023-07-14)。这一章按标准的条款顺序拆解,每条给出对应的字节级现象和验证命令。
3.1 TIFF 的骨架
3.1.1 文件头与 IFD
TIFF 文件由三部分组成:8 字节文件头、一个或多个 IFD(Image File Directory)、以及 IFD 指向的数据区。
8 字节头(经典 TIFF):
| 偏移 | 长度 | 内容 |
|---|---|---|
| 0–1 | 2 | 字节序:II(0x4949,小端)或 MM(0x4d4d,大端) |
| 2–3 | 2 | 魔数 42(0x002a) |
| 4–7 | 4 | 第一个 IFD 的字节偏移 |
BigTIFF 把魔数改成 43,偏移宽度从 4 字节改成 8 字节,头部变成 16 字节。
IFD 本身是一张标签表:
┌─────────────────────────────┐
│ 2 字节:标签数量 N │
├─────────────────────────────┤
│ N × 12 字节:标签条目 │
│ tag(2) type(2) count(4) │
│ value_or_offset(4) │
├─────────────────────────────┤
│ 4 字节:下一个 IFD 的偏移(0 表示结束)│
└─────────────────────────────┘
当标签的值超过 4 字节(比如 TileOffsets 数组),IFD 里存的是指向值区的偏移,真正的数组放在别处。这一点是 COG 布局的关键:TileOffsets 与 TileByteCounts 这两个数组可能很大(10000 像素、512 块 → 每方向 20 块 → 400 个偏移 × 4 字节 = 1.6 KB),它们的位置决定了“打开文件”要读几次。
3.1.2 条带与瓦片
TIFF 6.0 的 15 节定义了瓦片存储:图像被切成固定大小的矩形块,每块独立压缩。相关标签:
| 标签 | 编号 | 说明 |
|---|---|---|
| TileWidth | 322 | 瓦片宽,必须是 16 的倍数 |
| TileLength | 323 | 瓦片高,必须是 16 的倍数 |
| TileOffsets | 324 | 每块数据在文件中的偏移数组 |
| TileByteCounts | 325 | 每块压缩后的字节数 |
不用瓦片时是条带:RowsPerStrip(278)、StripOffsets(273)、StripByteCounts(279)。条带的宽度等于图像宽度,所以“按行读”友好、“按块读”灾难。
OGC 21-026 的要求很直接:所有 IFD 都必须按瓦片组织。标准原文是 “All Image File Directories (IFD) in a COG file SHALL be organized into tiles (instead of strips)”。这一条没有例外,包括 overview 与掩膜。
还有两条补充:瓦片必须是正方形(TileWidth = TileLength),且边长要“小于或等于常见屏幕视口”。后半句没有给具体数字,实践中 256 与 512 是主流;GDAL 的 COG driver 默认 512。
3.1.3 用命令看这些标签
# GDAL 的视角
gdalinfo scene.tif | head -40
# Driver: GTiff/GeoTIFF
# Size is 10000, 10000
# Coordinate System is: PROJCRS["WGS 84 / UTM zone 50N", ...]
# Band 1 Block=512x512 Type=UInt16, ColorInterp=Gray
# Overviews: 5000x5000, 2500x2500, 1250x1250, 625x625, 313x313
# rasterio 的视角,看块大小与压缩
rio info scene.tif
# {"blockxsize": 512, "blockysize": 512, "compress": "deflate",
# "count": 3, "dtype": "uint16", "interleave": "pixel",
# "tiled": true, "width": 10000, "height": 10000, ...}
gdalinfo 输出里的 Block=512x512 就是 TileWidth/TileLength;如果显示 Block=10000x1 之类的形状,说明是按条带存的,不是 COG。
3.2 Overview:为什么要金字塔
3.2.1 没有 overview 会怎样
客户端要 z10 的瓦片,对应地面范围可能覆盖整景。没有 overview 时,GDAL 必须读全分辨率数据再降采样:10000 × 10000 × 3 波段 × 2 字节 = 600 MB 的解压后数据。即使只读需要的窗口,缩放到 256 × 256 也要读几百万像素。
有 overview 时,z10 可能直接命中 313 × 313 那一级,读 1 个块就够。
3.2.2 标准的硬约束
OGC 21-026 对 overview 有四条要求:
- 至少一个全分辨率 IFD(NewSubfileType 的第 0 位未置位,或没有该标签)。
- IFD 链的顺序:全分辨率 IFD 的 next 指针指向第一个 overview 的 IFD;每个 overview 的 next 指向下一个更小的 overview;最后一个 overview 的 next 指向下一个全分辨率图像(比如另一个波段或掩膜),没有就指向 0。
- 每一级必须比上一级小(ImageWidth 与 ImageHeight 都更小)。
- 缩小倍数在 2 到 10 之间,最后一级必须小到 TilesAcross 或 TilesDown 等于 1。
第 4 条的后半句常被忽略:如果你的 overview 只做到 1/64,而图像还有 20 × 20 块,校验就会失败——因为最小一级仍然跨多个瓦片。GDAL 的 COG driver 与 rio-cogeo 都会自动算到“最小一级只剩一个块”为止。
def overview_levels(width: int, height: int, tilesize: int = 512) -> int:
"""算需要几级 overview,使最小一级只剩一个瓦片。"""
level = 0
while min(width, height) > tilesize:
width //= 2
height //= 2
level += 1
return level
print(overview_levels(10000, 10000)) # 5
print(overview_levels(4096, 4096)) # 3
这个算法与 rio-cogeo 内部的 get_maximum_overview_level 等价:它用 tilesize 作为 minsize,反复除以 2 直到小于块边长。10000 像素、512 块 → 需要 5 级(5000、2500、1250、625、312)。
3.2.3 地理参考只在全分辨率层
标准要求:全分辨率 IFD 必须用 ModelTiepointTag、ModelPixelScaleTag、GeoKeyDirectoryTag 写地理参考;所有 overview 不得包含地理参考,读取时继承全分辨率层的原点。
这条的经济意义很直接:每级 overview 少写几十到几百字节的 GeoKeys。更重要的是语义——如果 overview 自己写了略有偏差的原点,不同层级的瓦片会错位,而客户端通常不会检查。
3.3 文件布局:一次请求读完头部
3.3.1 推荐顺序
标准的 Recommendation 3 给了 12 项顺序,归纳成四段:
[1] TIFF 头 + ghost 区
[2] 全分辨率 IFD + 其标签值(不含 TileOffsets/TileByteCounts 数组)
[3] 掩膜 IFD(若有)
[4] 各级 overview 的 IFD(从大到小)
[5] 各级掩膜 overview 的 IFD
[6] 上述所有 IFD 的 TileOffsets 与 TileByteCounts 数组
[7] 最小 overview 的瓦片数据
[8] …中间各级…
[9] 最大 overview 的瓦片数据
[10] 全分辨率瓦片数据
关键在 [2]–[6]:所有 IFD 与偏移数组连续排布,一次 Range 请求就能全部取回。数据区从最小 overview 开始往前排,是因为浏览器加载时先要低分辨率——这样连续读的时候是顺序 I/O。
3.3.2 乱序的代价
标准里给了对照:TIFF 规范本身对 IFD 位置毫无约束,只要 next 指针指对就行。一个“合法但很糟”的布局可以是:全分辨率 IFD 在文件头、第一级 overview IFD 在文件尾、第二级在文件中间。打开它需要三次请求。
数字上:S3 单次请求 20–30 ms,加上数据 locality 与传输,常见 150 ms。三次请求 = 450 ms 加 90 ms,约 540 ms。在浏览器里,这半秒就是“点了没反应”和“立刻出图”的区别。
3.3.3 ghost 区
GDAL 3.1 起,写 COG 时会在头部插入一段 ASCII 的 ghost 区:
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
这段不是 TIFF 标准的一部分,是 GDAL 的私有约定。它的价值在于可检测性:任何人用 GDAL 以 update 模式打开 COG 并写入,GDAL 会把 KNOWN_INCOMPATIBLE_EDITION 改成 YES,因为它知道布局已经被破坏。
rio-cogeo 的校验正是靠这个字段判断“曾经是 COG、后来被改坏”:
gdal_ghost_headers = src.get_tag_item("GDAL_STRUCTURAL_METADATA", "TIFF")
if (
gdal_ghost_headers is not None
and "KNOWN_INCOMPATIBLE_EDITION=YES" in gdal_ghost_headers
):
errors.append(
"This file used to have optimizations in its layout, "
"but those have been, at least partly, invalidated by later changes"
)
这段代码来自 rio-cogeo 7.0.4 的 cog_validate。它解释了一个常见现象:文件昨天校验通过,今天变 invalid,中间只被人用 QGIS 打开编辑过。
3.4 校验规则逐条对照
rio-cogeo 的 cog_validate 是 GDAL 官方 validate_cloud_optimized_geotiff.py 的 rasterio 移植版,逻辑基本一一对应。它的检查项可以当作 COG 定义的“可执行版本”:
| 检查 | 判级 | 代码依据 |
|---|---|---|
| 驱动不是 GTiff | error | “The file is not a GeoTIFF” |
| 存在外部 .ovr | error | “Overviews found in external .ovr file. They should be internal” |
| 大于 512 但不是 tiled | error | “The file is greater than 512xH or 512xW, but is not tiled” |
| 大于 512 但没有 overview | warning | “it is recommended to include internal overviews” |
| 主 IFD 偏移 > 300 | error | “The offset of the main IFD should be < 300” |
| ghost 区标记已损坏 | error | “used to have optimizations … invalidated by later changes” |
| overview 未排序 | error | “Overviews should be sorted” |
| overview 抽取倍数 ≤ 1 | error | “Invalid Decimation … for overview level” |
| IFD 偏移未递增 | error | “The offset of the IFD for overview of index …” |
| 最小 overview 首块在 IFD 之前 | error | “The offset of the first block of the smallest overview should be after its IFD” |
| 主图首块在 overview 之前 | error | “The offset of the first block of the main resolution image should be after …” |
| overview 未分块 | error | “Overview of index {} is not tiled” |
几个数字值得记住:
- 512:小于等于 512 的文件不要求分块(小文件整体读也就几 KB)。
- 300:主 IFD 的偏移上限。正常 COG 的主 IFD 在 8 字节头之后,加上 ghost 区约 140 字节,偏移通常在 150–250 之间;超过 300 说明头部塞了太多东西或者布局异常。
- strict 模式:把 warning 也当 error。CI 里建议开 strict,否则“没有 overview”这种警告会被忽略。
# 普通校验
rio cogeo validate scene.tif
# scene.tif is a valid cloud optimized GeoTIFF
# strict:warning 也算失败
rio cogeo validate --strict scene.tif
# 看详细结构(IFD 表、块大小、抽取倍数)
rio cogeo info scene.tif
rio cogeo info 的输出里有一段 IFD 表,形如:
IFD
Id Size BlockSize Decimation
0 10000x10000 512x512 0
1 5000x5000 512x512 2
2 2500x2500 512x512 4
3 1250x1250 512x512 8
4 625x625 512x512 16
5 313x313 512x512 32
这张表是判断 overview 是否正确的最快方式:Decimation 列应该是 2 的幂次递增,最后一行的 Size 应该小于等于 BlockSize。
3.5 压缩与数值编码
3.5.1 压缩算法选择
标准不限定压缩方法,只说“常见的是无压缩、DEFLATE、LZW,或者有损的 JPEG”。实际选择要看数据类型:
| 压缩 | 有损 | 适用 | 注意 |
|---|---|---|---|
| DEFLATE | 否 | 通用,尤其浮点 | 比 LZW 压得好,但部分老软件兼容性差 |
| LZW | 否 | 通用,兼容性最好 | 压缩率一般 |
| ZSTD | 否 | GDAL ≥ 2.3 | 需要较新的 libtiff |
| JPEG | 是 | 8 位 RGB 影像 | 只能 1 或 3 波段,不支持 16 位 |
| WEBP | 是 | 8 位 RGB | 需要 GDAL ≥ 3.x 与 libwebp |
| LERC | 有损/无损 | 浮点高程 | 需 LERC 支持 |
rio-cogeo 的 profile 表把常用组合固化了,全部是 512 块、pixel interleave:
from rio_cogeo.profiles import cog_profiles
print(cog_profiles.get("deflate"))
# {'driver': 'GTiff', 'interleave': 'pixel', 'tiled': True,
# 'blockxsize': 512, 'blockysize': 512, 'compress': 'DEFLATE'}
print(cog_profiles.get("jpeg"))
# {..., 'compress': 'JPEG', 'photometric': 'YCbCr'}
注意 jpeg profile 带 photometric: YCbCr。YCbCr 是 3 波段专用的色彩空间,用在单波段上会被 rio-cogeo 自动降级:
if dst_kwargs.get("photometric", "").upper() == "YCBCR" and meta["count"] == 1:
warnings.warn("PHOTOMETRIC=YCBCR not supported on a 1-band raster ...")
dst_kwargs["photometric"] = "MINISBLACK"
3.5.2 predictor
对整型数据,DEFLATE/LZW/ZSTD 加 predictor 能显著提升压缩率。GDAL 的 COG driver 用 PREDICTOR 选项:
| 值 | 含义 |
|---|---|
| NO | 不使用(默认) |
| STANDARD | Predictor=2,整型水平差分 |
| FLOATING_POINT | Predictor=3,浮点差分 |
gdal_translate in.tif out.tif \
-of COG -co COMPRESS=DEFLATE -co PREDICTOR=YES \
-co BLOCKSIZE=512 -co OVERVIEWS=AUTO
PREDICTOR=YES 在 GDAL 里等价于“按数据类型自动选 STANDARD 或 FLOATING_POINT”。对 uint16 的卫星影像,通常能再省 10%–20%。
3.5.3 掩膜与 alpha
nodata 有三种表达方式,代价不同:
| 方式 | 存储 | 读取开销 |
|---|---|---|
| 数据集级 nodata 值 | 一个标签 | 无额外 I/O |
| 内部掩膜 band(1 位) | 额外一个 IFD + 每块掩膜字节 | 每次读块多一次解码 |
| alpha band | 多一个波段 | 数据量 +33% |
rio-cogeo 的处理逻辑:输入有 nodata 或 alpha 时,如果输出压缩是 JPEG,会强制转成内部掩膜并给出警告,因为 JPEG 不支持 nodata。
if not add_mask and (
(nodata is not None or alpha)
and dst_kwargs.get("compress", "").lower() == "jpeg"
):
warnings.warn("Nodata/Alpha band will be translated to an internal mask band.")
add_mask = True
生产上的建议:能用数据集级 nodata 就别用掩膜。掩膜让每块数据的字节数翻倍,而且会让 Range 请求命中率下降——瓦片服务读一个块时,可能还要额外读对应的掩膜块。
3.6 手工解析一个 COG 的头部
理解布局最直接的方式是自己读字节。下面这段代码不依赖 GDAL,只解析 TIFF 头与第一个 IFD,能打印出 COG 的关键指标。
import struct
import sys
def read_header(path: str) -> dict:
with open(path, "rb") as f:
head = f.read(16)
order = "<" if head[:2] == b"II" else ">"
magic = struct.unpack(order + "H", head[2:4])[0]
if magic == 42:
offset = struct.unpack(order + "I", head[4:8])[0]
bigtiff = False
elif magic == 43:
offset = struct.unpack(order + "Q", head[8:16])[0]
bigtiff = True
else:
raise ValueError(f"not a TIFF: magic={magic}")
return {"order": order, "bigtiff": bigtiff, "first_ifd": offset}
h = read_header("scene.tif")
print(h) # {'order': '<', 'bigtiff': False, 'first_ifd': 232}
读 IFD 的逻辑只有三步:定位到 first_ifd;读 2 字节(BigTIFF 是 8 字节)得到标签数量 N;再读 N 个 12 字节(BigTIFF 20 字节)的条目,每个条目是 tag(2) + type(2) + count(4) + value_or_offset(4)。ImageWidth 是 256、TileWidth 是 322、Compression 是 259,按编号取即可。真要解析完整 TIFF,用 tifffile 或 GDAL,不要自己写。
运行结果里 first_ifd 的值直接对应“主 IFD 偏移 < 300”这条规则:232 合格,5000 不合格。压缩代码 259 的取值是 1=无压缩、5=LZW、8=DEFLATE、7=JPEG。
3.7 常见问题
| 现象 | 原因 | 验证 |
|---|---|---|
| validate 报 “not tiled” | 生成时用了默认的 strip 输出 | rio info 看 tiled 字段 |
| validate 报 IFD offset > 300 | 头部塞了大量元数据(如超长 RPC 系数) | gdalinfo -mdd all 看元数据量 |
| validate 报 overview 未排序 | 先建了 overview 再重写文件 | rio cogeo info 看 Decimation 列 |
| 文件从 200 MB 涨到 400 MB | 加了内部掩膜或 alpha | gdalinfo 看 Mask Flags |
| 16 位数据用 JPEG 压缩后花屏 | JPEG 只支持 8 位 | 换 DEFLATE |
| 单波段用 YCbCr 报错 | YCbCr 需要 3 波段 | rio-cogeo 会自动降级,GDAL 不会 |
| BigTIFF 生成后某些库读不了 | 老版本 libtiff 不支持 | 文件 < 4 GB 时用经典 TIFF |
最后一条值得说明:OGC 标准写的是“超过 4 GB 必须用 BigTIFF,小于 4 GB 建议用经典 TIFF”。GDAL 的 BIGTIFF=IF_SAFER 就是按这个逻辑自动选。如果你的下游有老系统,把 BIGTIFF 显式设成 NO 更安全,代价是文件不能超过 4 GB。
3.8 生产建议
块大小跟瓦片网格对齐。 WebMercatorQuad 的瓦片是 256 像素,COG 块 512 意味着一个块覆盖 2 × 2 个瓦片——请求单块瓦片时读一个块,请求 2 × 2 瓦片时刚好读一个块。如果块是 256,请求 2 × 2 瓦片要读 4 个块,请求数翻四倍。这也是 GDAL 与 rio-cogeo 都默认 512 的原因。
overview 用平均重采样,不要用最近邻。 最近邻在降采样时会产生锯齿和摩尔纹,尤其在建筑密集区。rio-cogeo 的 –overview-resampling 默认是 nearest(为了快),生产上应该显式改成 average。注意这与“波段是分类数据”的场景冲突——分类图必须用 nearest 或 mode。
不要在有损压缩的 COG 上做定量分析。 JPEG/WEBP 会改变像素值,NDVI、反射率反演这类计算必须用无损压缩或原始数据。有损压缩只用于展示层。
上传前跑一次 strict 校验。 一条命令的成本,换来的是线上不会出现“某些缩放级别模糊”这种难查的问题。CI 里可以这样接:
set -e
for f in dist/*.tif; do
rio cogeo validate --strict "$f" || { echo "INVALID: $f"; exit 1; }
done
3.9 本章小结
| 要点 | 结论 |
|---|---|
| COG 是什么 | GeoTIFF 的组织约定,不是新格式;标准 OGC 21-026 v1.0 |
| 三个机制 | 分块(tiled)、金字塔(overview)、元数据前置(IFD before data) |
| 关键数字 | 块 512;IFD 偏移 < 300;overview 因子 2–10;最小层只剩 1 块 |
| 地理参考 | 只写全分辨率 IFD,overview 继承 |
| ghost 区 | GDAL ≥ 3.1 写入,KNOWN_INCOMPATIBLE_EDITION 标记布局是否被破坏 |
| 校验 | rio cogeo validate –strict,CI 必跑 |
下一章把工具链装起来:GDAL、rio-cogeo、pystac、stac-fastapi、PostgreSQL 与 pgstac 的版本组合,以及国内网络环境下最容易卡住的几个点。