第07章 - 静态 STAC 目录构建
不是所有影像目录都需要一个 API 服务。数据量在几万景以内、更新频率低、检索需求简单(按集合、按时间、按范围浏览)时,一套纯静态的 JSON 目录放在对象存储或 GitHub Pages 上,成本接近零,可用性由 CDN 保证,且可以被任何 STAC 客户端直接爬取。
代价是没有服务端检索。静态目录只能靠客户端把 JSON 拉下来自己过滤;几万个 Item 时,客户端要发几万个请求——这是静态方案的天花板。
这一章用 pystac 从一堆 COG 生成目录,重点讲三件容易做错的事:链接类型怎么选、href 怎么组织、元数据从哪来。最后给一条“多少 Item 该换 API”的判断线。
7.1 静态目录的三种形态
pystac 的 CatalogType 决定写盘时链接怎么写:
| 类型 | self 链接 | 层级链接 | 适用 |
|---|---|---|---|
| ABSOLUTE_PUBLISHED | 每个对象都有 | 绝对 URL | 已发布到固定域名,可被任意位置引用 |
| RELATIVE_PUBLISHED | 只有根有 | 相对 URL | 目录整体搬迁,但根位置已知 |
| SELF_CONTAINED | 都没有 | 相对 URL | 打包分发、离线浏览、随数据一起移动 |
默认是 ABSOLUTE_PUBLISHED。三种的实际差异:
import pystac
cat = pystac.Catalog(id="demo", description="demo", catalog_type=pystac.CatalogType.SELF_CONTAINED)
print(cat.catalog_type)
# CatalogType.SELF_CONTAINED
选错的后果很具体:
- 用 ABSOLUTE_PUBLISHED 但域名还没定 → 生成的 JSON 里全是 http://localhost:8000/…,发布后全是死链。
- 用 SELF_CONTAINED 但客户端从别处引用 → 没有 self 链接,STAC Browser 无法确定“当前节点是谁”,面包屑与刷新都会出问题。
- 用 RELATIVE_PUBLISHED 但目录被移动过 → 相对链接基于根目录计算,移动后仍然有效,这是它相对 SELF_CONTAINED 的唯一优势。
我的建议:对外发布的目录用 ABSOLUTE_PUBLISHED,随数据分发的用 SELF_CONTAINED,中间态用 RELATIVE_PUBLISHED。不要混用。
7.2 从 COG 提取元数据
Item 的字段大多能从 COG 自身读出来。下面这个函数把 rasterio 的元数据映射成 STAC 字段:
import rasterio
from rasterio.warp import transform_bounds
from rasterio.crs import CRS
def cog_to_props(path: str) -> dict:
"""从 COG 读出 STAC Item 需要的几何与投影字段。"""
with rasterio.open(path) as src:
# WGS84 的 bbox 与 geometry(STAC 要求经纬度)
if src.crs is None:
raise ValueError(f"{path}: 缺少 CRS,无法生成 STAC Item")
left, bottom, right, top = transform_bounds(
src.crs, CRS.from_epsg(4326),
*src.bounds, densify_pts=21,
)
bbox = [left, bottom, right, top]
# 多边形足迹:COG 是矩形网格,四个角即可
geometry = {
"type": "Polygon",
"coordinates": [[
[left, bottom], [right, bottom],
[right, top], [left, top], [left, bottom],
]],
}
# proj:transform 用 rasterio 的顺序,9 个元素
t = src.transform
proj_transform = [
t.a, t.b, t.c,
t.d, t.e, t.f,
0.0, 0.0, 1.0,
]
props = {
"proj:code": f"EPSG:{src.crs.to_epsg()}" if src.crs.to_epsg() else None,
"proj:shape": [src.height, src.width],
"proj:transform": proj_transform,
"proj:bbox": list(src.bounds),
"gsd": abs(src.transform.a),
}
# 清理 None,避免写出非法字段
props = {k: v for k, v in props.items() if v is not None}
return {"bbox": bbox, "geometry": geometry, "properties": props}
三个细节:
densify_pts=21。 transform_bounds 默认只转换四个角,投影变换是非线性的,四个角连成的矩形会切掉一部分真实范围。21 个点是 GDAL 的常用值。
proj:transform 用 9 个元素。 规范允许 6 或 9,9 个更明确,避免下游对“最后一行是否省略”的判断分歧。
proj:shape 是 [height, width]。 顺序是 Y 在前。写成 [width, height] 会让 GDAL 构造虚拟图层时宽高颠倒,瓦片全错位——这个错误不会报错。
7.3 构建一个 Collection
Collection 的元数据大多来自业务约定,只有 extent 需要从数据算:
import datetime as dt
import pystac
def make_collection(collection_id: str, title: str, items: list[pystac.Item]) -> pystac.Collection:
"""用一批 Item 反算 extent,构造 Collection。"""
bboxes = [it.bbox for it in items if it.bbox]
west = min(b[0] for b in bboxes)
south = min(b[1] for b in bboxes)
east = max(b[2] for b in bboxes)
north = max(b[3] for b in bboxes)
times = [it.datetime for it in items if it.datetime]
start = min(times)
end = max(times)
extent = pystac.Extent(
spatial=pystac.SpatialExtent([[west, south, east, north]]),
temporal=pystac.TemporalExtent([[start, end]]),
)
return pystac.Collection(
id=collection_id,
description=f"{title}:{len(items)} 景,{start:%Y-%m-%d} 至 {end:%Y-%m-%d}",
extent=extent,
title=title,
license="CC-BY-4.0",
providers=[
pystac.Provider(
name="示例数据提供方",
roles=[pystac.ProviderRole.PRODUCER, pystac.ProviderRole.HOST],
url="https://example.com",
)
],
keywords=["landsat", "surface-reflectance"],
summaries={
"platform": ["landsat-8", "landsat-9"],
"gsd": pystac.Summaries({"minimum": 30, "maximum": 30}),
},
)
summaries 的写法有两个坑:
- 传字典时键必须是被汇总的字段名,值是枚举数组或 Range 对象。pystac 的 Summaries 类会做基本校验。
- Range 的键是 minimum / maximum,不是 min / max。1.1.0 的变更日志专门修过这个(早期 schema 里写的是 min/max)。
license 字段在 1.1.0 要求 SPDX 标识符或 other。写 “proprietary” 或 “various” 会通过 schema(因为标了 deprecated 而非 removed),但会在校验时给出警告,未来版本会移除。
7.4 构建 Item 与 Asset
一个完整的 Item 构造:
import pystac
from pystac.utils import datetime_to_str
def make_item(item_id: str, cog_path: str, href: str, acquisition: dt.datetime) -> pystac.Item:
meta = cog_to_props(cog_path)
bbox = meta["bbox"]
geometry = meta["geometry"]
item = pystac.Item(
id=item_id,
geometry=geometry,
bbox=bbox,
datetime=acquisition,
properties={
"platform": "landsat-8",
"instruments": ["oli", "tirs"],
"gsd": 30,
"created": datetime_to_str(dt.datetime.now(dt.timezone.utc)),
**meta["properties"],
},
)
item.add_asset(
"visual",
pystac.Asset(
href=href,
media_type="image/tiff; application=geotiff; profile=cloud-optimized",
roles=["data", "visual"],
title="真彩色合成(B04/B03/B02)",
extra_fields={
"bands": [
{"name": "B04", "eo:common_name": "red",
"data_type": "uint16", "nodata": 0,
"raster:scale": 0.0000275, "raster:offset": -0.2},
{"name": "B03", "eo:common_name": "green",
"data_type": "uint16", "nodata": 0,
"raster:scale": 0.0000275, "raster:offset": -0.2},
{"name": "B02", "eo:common_name": "blue",
"data_type": "uint16", "nodata": 0,
"raster:scale": 0.0000275, "raster:offset": -0.2},
]
},
),
)
item.add_asset(
"thumbnail",
pystac.Asset(
href=href.replace(".tif", "_thumb.png"),
media_type="image/png",
roles=["thumbnail"],
title="缩略图",
),
)
return item
几个决定值得解释:
extra_fields 而不是扩展助手。 2026 年春季起,pystac 的扩展实现(pystac.extensions.eo、projection、raster)拆成了独立包 pystac-ext-eo、pystac-ext-projection、pystac-ext-raster,版本与扩展规范对齐。用 extra_fields 直接写字段,省掉三个依赖,且不受拆分影响。代价是没有类型检查。
media_type 写全。 image/tiff; application=geotiff; profile=cloud-optimized 是 IANA 注册的类型,profile 参数告诉客户端“这是 COG”。很多客户端据此决定要不要走 Range 读取。
roles 给两个。 data 表示这是数据,visual 表示可以可视化。STAC Browser 的资产选择逻辑会优先挑带 visual 的。
thumbnail 的 href 是猜的。 生产上应该先生成缩略图再写 href,不要写一个不存在的路径——STAC Browser 会去请求它,404 会在控制台刷一片红。
7.5 组织层级与写盘
7.5.1 两种组织方式
方式 A:Collection 下平铺所有 Item
catalog.json
└─ collection.json
├─ items/LC08_001.json
├─ items/LC08_002.json
└─ ...(几千个)
方式 B:按时间或空间分子目录
catalog.json
└─ collection.json
├─ 2023/05/LC08_001.json
├─ 2023/06/LC08_002.json
└─ ...
方式 A 的问题是 collection.json 里会有几千条 item 链接,文件本身几百 KB,客户端拉一次就要几百 KB;方式 B 把链接分散到子 Catalog,每次只拉一层。
但方式 B 会引入子 Catalog,而 STAC 的 Collection 里不能再嵌 Collection(只能是 Item 或子 Catalog)。所以结构上要么用 Catalog 组织(Collection 作为叶子),要么接受 Collection 直接挂所有 Item。
实践中更常见的第三种:Collection 直接挂 Item,但把 Item 的 JSON 分目录存放。链接仍然在 collection.json 里,只是 href 指向不同目录。这样避免了子 Catalog 的语义问题,但没解决“collection.json 太大”。
# 分目录存放,链接仍在 Collection 上
item.set_self_href(f"{base}/items/{date:%Y/%m}/{item_id}.json")
我的建议:Item 数少于 5000 用平铺,超过就上 API。硬要在静态目录里塞十万个 Item,客户端的爬取成本会超过自建服务的成本。
7.5.2 normalize_hrefs 与 save
pystac 的写盘分两步:先算 href,再落盘。
cat = pystac.Catalog(id="demo", description="示例影像目录")
cat.add_child(collection)
collection.add_items(items)
# 1. 算 href:按 id 生成路径
cat.normalize_hrefs("https://cdn.example.com/stac")
# 2. 落盘:写本地目录
cat.save(catalog_type=pystac.CatalogType.ABSOLUTE_PUBLISHED, dest_href="./dist")
normalize_hrefs 的参数是发布后的根 URL,不是本地路径。它据此生成每个对象的绝对 href。save 的 dest_href 才是本地写盘位置。
两者不一致时会出现“本地文件里的 self 链接指向 CDN,但文件在 ./dist”——这正是我们想要的:本地生成、上传 CDN、链接直接可用。
save 之后目录结构:
dist/
├── catalog.json
└── demo/
└── collection.json
├── item-001/item-001.json
└── item-002/item-002.json
每个 Item 一个目录、目录里同名 JSON,这是 pystac 的默认布局策略。想改成扁平结构,用 strategy 参数:
from pystac.layout import AsIsLayoutStrategy
cat.normalize_hrefs("https://cdn.example.com/stac", strategy=AsIsLayoutStrategy())
AsIsLayoutStrategy 会保留对象已有的 self href 路径。适合“文件名已经规划好”的场景。
7.6 完整生成脚本
把上面的片段拼成一个可跑的脚本:
#!/usr/bin/env python3
"""从 COG 目录生成静态 STAC 目录。"""
import argparse
import datetime as dt
from pathlib import Path
import pystac
from stac_meta import cog_to_props, make_collection, make_item # 前面定义的函数
def build(src_dir: Path, out_dir: Path, base_url: str, collection_id: str) -> None:
cogs = sorted(src_dir.rglob("*.tif"))
if not cogs:
raise SystemExit(f"no .tif under {src_dir}")
items = []
for cog in cogs:
# 用文件名当 id,生产上应该用数据提供方的原始 id
item_id = cog.stem
# 从文件名解析时间,这里假设形如 LC08_123032_20230501.tif
date_part = item_id.split("_")[-1]
acq = dt.datetime.strptime(date_part, "%Y%m%d").replace(tzinfo=dt.timezone.utc)
href = f"{base_url}/imagery/{cog.parent.name}/{cog.name}"
items.append(make_item(item_id, str(cog), href, acq))
collection = make_collection(collection_id, "示例 Landsat 集合", items)
collection.add_items(items)
root = pystac.Catalog(
id="demo-catalog",
description="示例影像目录",
title="示例影像目录",
)
root.add_child(collection)
root.normalize_hrefs(f"{base_url}/stac")
root.save(catalog_type=pystac.CatalogType.ABSOLUTE_PUBLISHED, dest_href=str(out_dir))
n = root.validate_all()
print(f"wrote {len(items)} items, validated {n}")
if __name__ == "__main__":
ap = argparse.ArgumentParser()
ap.add_argument("src", type=Path)
ap.add_argument("out", type=Path)
ap.add_argument("--base-url", required=True)
ap.add_argument("--collection", default="demo-landsat")
args = ap.parse_args()
build(args.src, args.out, args.base_url.rstrip("/"), args.collection)
python build_stac.py ./cog ./dist --base-url https://cdn.example.com
# wrote 128 items, validated 128
validate_all 返回的是校验过的 Item 数量。它需要 pystac[validation],否则会抛 ImportError 提示装 jsonschema。
7.7 发布与托管
7.7.1 对象存储
# 上传,注意 Content-Type
aws s3 sync ./dist s3://my-bucket/stac \
--exclude "*" --include "*.json" \
--content-type "application/json" \
--cache-control "public, max-age=300"
# Item 文件用 geo+json
aws s3 sync ./dist s3://my-bucket/stac \
--exclude "*" --include "*/item.json" \
--content-type "application/geo+json" \
--cache-control "public, max-age=3600"
Content-Type 很重要:application/json 与 application/geo+json 会让某些客户端走不同的解析路径。cache-control 给 300 秒是因为目录会更新;Item 变化少,可以给 3600。
7.7.2 GitHub Pages
静态目录放 GitHub Pages 有个硬限制:不支持自定义响应头,所以没法配 CORS。这意味着浏览器里的 STAC Browser 读不了 Pages 上的目录(同源除外)。
可行方案是把 STAC Browser 与目录部署在同一个 Pages 站点下,同源就不需要 CORS。第 11 章会给具体做法。
另一个限制是 historyMode 必须用 hash——Pages 不支持 URL 重写,history 模式刷新会 404。
7.8 排错
| 现象 | 原因 | 修法 |
|---|---|---|
| STAC Browser 打开根节点显示“无法加载” | 根 catalog 缺 self 链接 | 用 ABSOLUTE_PUBLISHED 重新 save |
| 点进 Item 后 404 | normalize_hrefs 的根 URL 与实际上传路径不一致 | 检查 base_url 与桶内前缀 |
| validate_all 报 ‘bbox’ is required | geometry 非 null 但 bbox 为 None | 用 cog_to_props 的返回值 |
| Collection 的 extent 是全世界的 | 没有 Item 时算不出范围,用了默认值 | 先加 Item 再算 extent |
| summaries 校验失败 | 用了 min/max 而不是 minimum/maximum | 改成规范字段名 |
| Item 的 href 是相对路径但客户端解析错 | 混用了 SELF_CONTAINED 与绝对 base | 统一 catalog_type |
| pystac 报 Unresolvable JSON reference | 缺 pystac[validation] | 装 extras |
第一条与第三条最常见。第一条的根因是:pystac 在内存里可以没有 href,直接 to_dict() 输出的 JSON 就没有 self 链接;必须走 normalize_hrefs + save。
7.9 生产建议
把生成过程做成幂等的。 同一个输入跑两次,输出应该字节一致(除了 created 时间戳)。做法是排序输入、固定时间字段、不用随机 id。这样可以用 diff 判断“这次到底改了什么”。
Collection 的 extent 与 summaries 每次重建都重算。 手工维护一定会过期。重算的成本是遍历所有 Item,几万个以内可以接受。
给 Item 加一个 checksum 字段。 对象存储的 ETag 对分片上传不可靠,自己算 sha256 存进 properties,便于发现“元数据没变但文件变了”。
静态目录也要有 CI 校验。 一条 pystac validate_all 加一条 check-internal-links,能拦住 90% 的发布事故。
规划好“什么时候换 API”。 判断线不是 Item 数量,而是查询模式:如果用户需要“按空间范围 + 时间 + 云量过滤”,静态目录做不到,就该上第 8 章的 stac-fastapi。
7.10 本章小结
| 要点 | 结论 |
|---|---|
| catalog_type | 对外用 ABSOLUTE_PUBLISHED,分发用 SELF_CONTAINED |
| 元数据来源 | 从 COG 读 CRS、bounds、transform、shape,densify 后再转 WGS84 |
| 关键字段 | proj:shape 是 [高, 宽];proj:transform 用 9 元素 rasterio 顺序 |
| 写盘 | normalize_hrefs 给发布 URL,save 给本地路径 |
| 校验 | validate_all 返回 Item 数,需要 pystac[validation] |
| 规模上限 | 5000 个 Item 以内平铺;需要组合过滤就上 API |
下一章开始搭服务:stac-fastapi 的分层结构、如何实现一个后端、扩展怎么装、以及从零写一个最小可用的内存后端。