第18章 - 云原生影像处理流水线
把”扫描件变 COG、COG 变 STAC Item、Item 变可出图服务”这三步连起来,就是一个影像流水线。听起来是三个脚本串起来,实际做起来 80% 的工程量在四件事上:幂等(重复跑不产生重复数据)、校验(坏文件不静默通过)、可观测(失败能定位到哪个文件)、可重入(部分失败能只补跑失败的部分)。
这一章按”一次真实入库”的顺序走完整条链,每一步给出可运行的命令,最后给出失败处理与成本控制的判断标准。
18.1 流水线的六个阶段
原始数据(TIF/JP2/HDF,可能在 FTP/S3/本地盘)
│ ① 摄取:下载或直读,重命名成可路由的 key
▼
暂存层(原格式,保留,作为回滚依据)
│ ② 转换:rio cogeo create → COG(重采样 / 压缩 / overview)
▼
COG 层(s3://bucket/cog/{collection}/{id}.tif)
│ ③ 校验:rio cogeo validate + 头信息检查
▼
通过校验的 COG
│ ④ 元数据抽取:rio-stac / pystac 生成 Item
▼
STAC Item(写目录 / 写 pgstac)
│ ⑤ 索引:生成或更新 MosaicJSON
▼
可出图(TiTiler 读 COG,按 MosaicJSON 或 STAC 检索)
│ ⑥ 验收:抽样瓦片比对
▼
上线
六个阶段的失败代价完全不同:转换最贵(CPU 密集,单景几分钟),索引最便宜(几秒),校验最应该早做(它挡住所有后续浪费)。所以工程上的顺序通常是先校验输入、再转换、转换后立刻校验、校验通过才写 Item。任何一步失败都不应该污染下游。
18.2 转换:rio cogeo create 的参数
rio cogeo create 是整条流水线里参数最多的一步。完整选项很长,真正影响产出质量与体积的是这九个:
| 参数 | 默认 | 影响 |
|---|---|---|
--cog-profile |
deflate |
压缩算法:deflate/lzw/zstd/jpeg/webp/lerc/raw |
--blocksize |
512 | 内部瓦片尺寸,必须 2 的幂 |
--overview-resampling |
nearest |
金字塔重采样算法 |
--overview-level |
自动 | 手动指定金字塔层数,0 表示不建 |
--overview-blocksize |
128 或 GDAL_TIFF_OVR_BLOCKSIZE |
overview 的块大小 |
--dtype |
保持源 | 输出数据类型 |
--nodata |
源值 | 覆盖 nodata |
--add-mask |
关 | 生成内部掩膜 |
--use-cog-driver |
关 | 用 GDAL 3.1+ 的 COG driver |
压缩算法怎么选,取决于数据内容与用途:
| profile | 有损 | 典型压缩比 | 适用 |
|---|---|---|---|
| deflate | 否 | 2~4 倍 | 通用,分析用 |
| lzw | 否 | 1.5~2.5 倍 | 兼容性最好,慢 |
| zstd | 否 | 2.5~5 倍 | 新版 GDAL,解压快 |
| jpeg | 是 | 10~20 倍 | 影像展示,8 位 |
| webp | 是 | 15~25 倍 | 影像展示,体积最小 |
| lerc | 有损(可控) | 3~8 倍 | DEM、浮点数据 |
展示用途用 jpeg 或 webp 能把体积降一个数量级,代价是不能再用作分析源。实践中常见做法是同一份数据出两个版本:cog/analysis/ 用 deflate,cog/display/ 用 jpeg,TiTiler 按用途指向不同目录。
一条完整的转换命令:
rio cogeo create \
input.tif output.tif \
--cog-profile deflate \
--blocksize 512 \
--overview-resampling average \
--add-mask \
--co PREDICTOR=2 --co ZLEVEL=6 \
--co NUM_THREADS=ALL_CPUS \
--use-cog-driver
--co PREDICTOR=2 与 ZLEVEL=6 是 deflate 的黄金组合:predictor 2 是水平差分,对连续色调影像特别有效;ZLEVEL 6 是压缩率与速度的平衡点,9 只多 3%~5% 的压缩率却慢一倍。
--overview-resampling average 对连续数据是对的,对分类数据必须用 nearest。这一点和第 14 章的重采样选择规则完全一致——金字塔本质就是一次降采样。
--blocksize 不要盲目设大。512 是 GDAL 与 COG 规范的常用值,1024 能减少每张瓦片的请求数但增加单次读取量。对 256×256 的瓦片输出,512 的块意味着一次瓦片请求通常只碰 1~4 个块,是最优的。
18.3 校验:三层检查
rio cogeo validate 只检查结构(是否 tiled、是否有 overview、IFD 顺序),不检查内容。生产环境三层都要做:
# 1. 结构校验
rio cogeo validate output.tif
# 2. 严格模式,警告也当错误(缺 overview 会被揪出来)
rio cogeo validate --strict output.tif
# 3. 关键头信息,供人工或脚本判断
rio cogeo info output.tif
rio cogeo info 的输出里有几个字段值得写进自动化检查:
COG: True
Compression: DEFLATE
Tiled: True
Dtype: uint16
NoData: 0.0
Alpha Band: False
Internal Mask: False
IFD
Id Size BlockSize Decimation
0 10980x10980 512x512 0
1 5490x5490 128x128 2
检查项:COG 必须为 True;Tiled 必须为 True;Internal Mask 或 Alpha Band 至少要有一个(否则透明区会变黑);IFD 的 Decimation 必须是 2 的幂递增(否则金字塔层级错乱);最底层 overview 的尺寸应小于 blocksize(否则等于白建)。
第三层是内容校验:抽样比对原图与 COG 的统计值。压缩是有损的时候(jpeg/webp),必须比对;无损时可以只比对 band 数与 dtype。
import rasterio
from rasterio.enums import Resampling
def sanity_check(src_path: str, cog_path: str) -> dict:
"""比对源与 COG 的元数据和抽样统计。"""
with rasterio.open(src_path) as src, rasterio.open(cog_path) as dst:
assert dst.profile.get("tiled"), "输出未分块"
assert dst.overviews(1), "缺少 overview"
assert src.count == dst.count, "波段数不一致"
# 用 overview 读一个低分辨率版本做统计比对
scale = max(1, dst.width // 512)
arr = dst.read(
1,
out_shape=(dst.height // scale, dst.width // scale),
resampling=Resampling.average,
)
valid = arr[arr != (dst.nodata or 0)]
return {
"dtype": str(dst.dtypes[0]),
"nodata": dst.nodata,
"overview_levels": len(dst.overviews(1)),
"mean_sampled": float(valid.mean()),
}
arr[arr != nodata].mean() 这个写法在有 nodata 时是必要的,直接把 nodata 值算进均值会让结果完全失真——nodata 常写成 0 或 -9999,两者都会把均值拉偏。
18.4 元数据:从 COG 生成 STAC Item
rio-stac 提供了 create_stac_item(),能把一个 COG 的投影、栅格、EO 信息写成标准 Item。TiTiler 的 /cog/stac 端点就是它的 HTTP 封装。
from rio_stac.stac import create_stac_item
item = create_stac_item(
"s3://bucket/cog/s2/2024/01/S2A_50TMK_20240101.tif",
id="S2A_50TMK_20240101",
collection="sentinel-2-l2a",
input_datetime="2024-01-01T03:12:00Z",
asset_name="visual",
asset_media_type="image/tiff; application=geotiff; profile=cloud-optimized",
with_proj=True,
with_raster=True,
with_eo=True,
raster_max_size=1024,
)
item.set_self_href(
"https://catalog.example.com/collections/sentinel-2-l2a/items/S2A_50TMK_20240101"
)
print(item.to_dict()["properties"]["proj:epsg"])
asset_media_type 必须写对。第 16 章提过,STACReader 用媒体类型白名单筛资产,写成随意字符串会导致 /stac/assets 里看不到它。用 rio-stac 的默认值比自己手写可靠。
set_self_href() 不是可选的。相对路径的资产 href 要靠它解析,缺了就会出现”Item 能读、瓦片全黑”——这和第 16 章的排错项是同一条。
生成后要过一遍 schema 校验,stac-validator 或 pystac 的 validate_all() 都行。校验能挡掉 80% 的”看起来对但读不出来”的问题:必填字段缺失、bbox 与 geometry 不一致、assets 里没有 href。
18.5 编排:三种形态与选择
流水线的编排形态决定了单景成本和运维复杂度:
| 形态 | 单景耗时 | 单景成本 | 适用 |
|---|---|---|---|
| 单机脚本 + 队列 | 分钟级 | 最低 | 日增百景以内,可接受隔夜 |
| AWS Lambda | 受 15 分钟上限约束 | 按调用计费,短任务便宜 | 小文件、突发流量 |
| 容器任务(Batch/K8s Job) | 分钟到小时 | 按实例时长 | 大文件、稳定批量 |
Lambda 的 15 分钟硬上限是关键约束。一个 10980×10980 的 4 波段 Sentinel-2 场景,rio cogeo create 在 1 vCPU 上约 3~5 分钟,2 vCPU 上 2 分钟左右;但 30000×30000 的航拍正射影像轻松超过 15 分钟。判断标准很简单:在目标实例上转换时间的 P95 是否小于上限的一半。小于就用 Lambda,否则用容器任务。
Lambda 的另一个约束是 /tmp 只有 512 MB(可配到 10 GB)和内存上限 10 GB。COG 转换是流式的,内存占用通常是源文件解压后的一块,不大;但中间文件写到 /tmp 时要注意清理,否则同一执行环境复用会撑爆。
一个最小的幂等脚本,核心是三件事:检查输出已存在则跳过、失败写标记、成功写清单:
import json
import subprocess
from pathlib import Path
def convert_one(src: Path, dst: Path, manifest: Path) -> bool:
"""转换单个文件,幂等。返回是否真的执行了转换。"""
if dst.exists():
print(f"skip {dst.name}: already exists")
return False
tmp = dst.with_suffix(".tmp.tif")
cmd = [
"rio", "cogeo", "create", str(src), str(tmp),
"--cog-profile", "deflate",
"--overview-resampling", "average",
"--add-mask",
"--co", "PREDICTOR=2",
"--co", "ZLEVEL=6",
]
subprocess.run(cmd, check=True)
# 先校验再改名,避免半成品被当成成品
subprocess.run(["rio", "cogeo", "validate", "--strict", str(tmp)], check=True)
tmp.rename(dst)
with manifest.open("a", encoding="utf-8") as f:
f.write(json.dumps({"src": str(src), "dst": str(dst)}) + "\n")
return True
tmp.rename(dst) 这一步是幂等的关键。直接把输出写成目标文件名,转换失败会留下半成品,下次运行看到文件存在就跳过了——一个损坏的 COG 就这样进了库。先写临时名、校验通过再改名,是唯一可靠的顺序。
18.6 索引更新
COG 入完,MosaicJSON 要更新。全量重建简单但耗时,增量更新快但要处理并发。
# 全量重建:适合每天一次、文件数在万级以内
find /data/cog -name "*.tif" > list.txt
cogeo-mosaic create list.txt -o s3://bucket/mosaic/ortho.json.gz \
--minzoom 8 --maxzoom 16 --quadkey-zoom 10 --min-tile-cover 5
# 增量更新:把新文件追加进已有 mosaic
cogeo-mosaic update new_files.txt -o s3://bucket/mosaic/ortho.json.gz
update 的行为要特别小心:它读已有 mosaic、合并新文件、写回。两个人同时跑,后写的会覆盖先写的。生产环境要用锁(比如 S3 的条件写、DynamoDB 的乐观锁),或者干脆串行化到一个任务里。
--min-tile-cover 5 在增量更新时尤其重要:新文件往往是边缘补片,覆盖率低,不设阈值会让 MosaicJSON 迅速膨胀。
18.7 失败重试与断点续跑
批量入库几乎不可能一次全成功:源文件损坏、网络抖一下、对象存储返回 503。要把重跑做成常态,而不是事故处理。三个机制缺一不可。
按文件记录状态,而不是按批次。 批次级的“成功/失败”粒度太粗,一景坏掉整批重跑。改成每个文件一行状态记录,重跑时只处理非 done 的行:
import json
import time
from pathlib import Path
def load_state(path: Path) -> dict[str, dict]:
if not path.exists():
return {}
rows = path.read_text(encoding="utf-8").splitlines()
return {r["key"]: r for r in map(json.loads, rows)}
def retry_pending(src_dir: Path, cog_dir: Path, state_path: Path, max_attempts: int = 3) -> None:
"""只重跑未完成、且失败次数未超限的文件。"""
state = load_state(state_path)
for src in sorted(src_dir.glob("*.tif")):
key = src.stem
rec = state.get(key, {"key": key, "status": "pending", "attempts": 0})
if rec["status"] == "done" or rec["attempts"] >= max_attempts:
continue
rec["attempts"] += 1
try:
convert_one(src, cog_dir / f"{key}.tif", state_path)
rec["status"] = "done"
except Exception as exc: # noqa: BLE001
rec["status"] = "failed"
rec["error"] = f"{type(exc).__name__}: {exc}"
rec["updated_at"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
with state_path.open("a", encoding="utf-8") as f:
f.write(json.dumps(rec, ensure_ascii=False) + "\n")
状态文件用 JSONL 追加写,是因为它能被 tail、被 grep、被并发追加(POSIX 下单行写入小于管道缓冲时是原子的)。读的时候按 key 取最后一条,就得到最新状态。
区分可重试与不可重试的错误。 全量重试是浪费,全部不重试是掩盖问题:
| 错误 | 可重试 | 处理 |
|---|---|---|
| 对象存储 5xx / 超时 | 是 | 指数退避重试 3 次 |
| 网络中断 | 是 | 同上 |
| 源文件不存在(404) | 否 | 记 missing,人工确认 |
| 源文件损坏(GDAL 打不开) | 否 | 记 corrupt,归档待查 |
| 磁盘满 / OOM | 换资源后是 | 记 resource,扩容后重跑 |
| 校验不通过 | 否 | 记 invalid,进人工复核队列 |
指数退避的最小实现:
import time
def with_retry(fn, attempts: int = 3, base: float = 2.0):
"""对瞬态错误做指数退避重试。"""
last: Exception | None = None
for i in range(attempts):
try:
return fn()
except (ConnectionError, TimeoutError) as exc:
last = exc
time.sleep(base ** i)
raise last # type: ignore[misc]
把失败信息落到可查询的地方。 只写日志的话,几百个失败文件要翻半小时。把 status、error、attempts 写进状态文件后,两行命令就能汇总:
# 按状态汇总
jq -r '.status' state.jsonl | sort | uniq -c
# 只对失败的按错误类型汇总
jq -r 'select(.status=="failed") | .error' state.jsonl | sort | uniq -c | sort -rn
断点续跑的前提是幂等,而幂等的前提是“半成品不会冒充成品”。18.5 节的“临时名 → 校验 → 改名”就是为此:重跑时看到的目标文件一定完整,直接跳过是安全的。若流水线直接写目标文件名,重跑就得先删掉可疑文件,否则会把坏文件当成功。
最后一条经验:别在失败时立即重试。对象存储的 503 往往持续几秒到几十秒,立即重试只是把同一个错误重复三次。退避 1/2/4 秒起步、最多 3 次,成功率远高于“立刻重试 10 次”。
18.8 排错
转换成功但 TiTiler 返回 500,日志是 RasterioIOError。 最常见的是权限:写 COG 的凭证和读 COG 的凭证不是同一个。TiTiler 的容器角色需要 s3:GetObject 与 s3:GetObjectAttributes,少一个就会 403 再被包装成 500。
rio cogeo validate 报 The file is not a valid COG,但 QGIS 能打开。 validate 检查的是 IFD 顺序与分块,一些老 TIF 能被 GDAL 读但不满足 COG 规范。这类文件必须重新转换,”能打开就上线”是债。
overview 没生成。 --overview-resampling nearest 配 --blocksize 1024 时,如果源图小于 2 倍 blocksize,就没有可建的 overview 层级。检查源图尺寸与 --overview-level,必要时显式指定层数。
转换后体积反而变大。 源文件已经是 JPEG 压缩的 TIF,再用 deflate 重新压一遍,压缩率不如原来的 JPEG。这种情况该用 --cog-profile jpeg 保持有损压缩,或者直接跳过转换(如果源文件已经是合规 COG)。
批量任务跑到一半 OOM。 rio cogeo create 默认在小于 1.2 亿像素时全内存处理,超过就分块。容器内存小于 4 GB 时对超大文件要加 --no-in-memory,并确保 CPL_TMPDIR 指向有空间的目录。
Item 写进去了但搜不到。 pgstac 的 ingest 有延迟(分区创建、索引重建),刚写完立即检索可能为空。等 10~30 秒再查,或者查 /collections/{id}/items 而不是 /search(前者走主键)。
18.9 生产建议
- 原始文件永久保留。COG 是派生产物,转换参数改了要重跑。原始文件删了就再也回不去,这是最贵的一类错误。
- 转换参数写进文件元数据。在 COG 的 TIFF tag 或同名 sidecar JSON 里记下
--cog-profile、--blocksize、--overview-resampling,半年后排查”为什么这批图比那批大”时能立刻回答。 - 按 collection 分目录,不要在同一个前缀下混放。
s3://bucket/cog/{collection}/{year}/{month}/{id}.tif这个结构同时满足按前缀列举、按生命周期规则清理、按 collection 做权限。 - 幂等靠”临时名 + 校验 + 改名”,不靠数据库状态。数据库会漂移,文件系统不会。
- 把校验失败的文件单独归档,不要直接丢弃。失败样本是改进转换参数的最好素材,攒上几十个就能看出规律(比如某个传感器的特定压缩方式)。
18.10 本章要点
| 结论 | 依据 |
|---|---|
--co PREDICTOR=2 与 ZLEVEL=6 是 deflate 的平衡点 |
GDAL GTiff 驱动的创建选项 |
| 分类数据的 overview 必须用 nearest | 插值会造出不存在的类别 |
validate --strict 会把缺 overview 当错误 |
rio-cogeo CLI 文档 |
| 幂等靠临时名 + 校验 + 改名 | 半成品残留是最常见的脏数据来源 |
媒体类型写错会让 /stac/assets 看不到资产 |
STACReader.DEFAULT_VALID_TYPE |
| Lambda 的 15 分钟上限决定编排形态 | Lambda 服务限制 |
cogeo-mosaic update 无并发保护 |
后写覆盖先写 |
下一章把结果交给前端:MapLibre 与 Leaflet 怎么接 TiTiler 的瓦片、tileSize 与 scheme 怎么对齐、跨域和鉴权头怎么配。