ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

STAC规范从零落地:空间元数据编目与时空检索实践

STAC规范从零落地:空间元数据编目与时空检索实践

做地理数据这一行的人,几乎都经历过同一个阶段:硬盘里躺着几百上千个影像文件夹,每个文件夹里塞着 tif、xml、txt、prj,想知道"去年三月到六月、覆盖某片山区的影像有哪些",只能靠文件夹命名规则加一个手维护的 Excel 去猜。更麻烦的是,这套约定只活在维护它的人脑子里,换个人接手,或者让程序去读,就彻底失效。STAC 规范要解决的正是这件事——它不搬动你的数据,也不要求你换存储格式,只是给每一份时空数据配一张机器能读懂的"身份证",再用一层目录把身份证串起来,让检索变成一次标准的空间加时间查询。这篇内容围绕 STAC 规范项目从零落地讲起,包括四层对象模型怎么划分职责、第一个 Item 怎么手写和校验、静态目录和 API 两条路线怎么选、几个最容易翻车的字段,以及数据量上去之后的性能处理。无论你是刚开始接触空间元数据的新手,还是手里已经有一堆裸 GeoTIFF 想整理成目录的老手,下面这些内容都可以直接照着做。

1. STAC 在解决什么:把"目录名猜内容"换成机器可读的检索契约

1.1 先解剖一个真实痛点

假设一个团队攒了三年的遥感影像,存储是这样的:2023/0312/scene_A/、2023/0312/scene_B/,每景下面有可见光、近红外几个波段,还带一个处理级别标记。想筛"2023 年 3 月到 6 月、范围落在某条流域内、处理级别不低于 L2 的可见光影像",最原始的做法是写个脚本遍历目录、解析文件夹名、读 tif 的仿射变换算覆盖范围,再拼起来。这套脚本能跑,但每次新增数据源就得改一次,每次命名规则变一下就得改一次。

STAC 的价值不是"多了一个 JSON 文件",而是把这套隐含约定显式化。它规定了时空范围该写在哪个字段、用哪个坐标系表达、波段信息挂在哪里、真实文件通过什么方式引用。规范本身是一份基于 JSON Schema 的约束文档,所有字段的名字、类型、必填性都有明确定义,因此任何实现了 STAC 的客户端——不管是命令行工具、桌面软件还是自己写的服务——都能用同一套逻辑去遍历和检索。这是它和"自己定一套元数据格式"的本质区别:格式是自己看,规范是大家看。

1.2 一个必须先搞清楚的认知:STAC 不托管数据

新手最容易犯的误解,是把 STAC 当成一种新的存储格式,以为要先把 GeoTIFF 转换进去。完全不是。STAC 里最核心的对象 Item,本质就是一份 JSON,里面的 Asset 字段存的是href,指向你数据原本所在的位置——对象存储的 URL、HTTP 静态服务地址、本地相对路径都行。数据该是 COG 还是 COG,该在哪个桶里还在哪个桶里,STAC 只是在旁边加了一层索引。

注意:这个认知直接决定了后面所有架构选型。因为 STAC 不碰数据本体,所以它可以和对象存储、CDN、只读文件系统共存,落地成本远低于很多人的预期;反过来说,如果你的原始数据是几百个小文件拼成一景的旧式 GeoTIFF,STAC 描述得再漂亮,读取端一样会慢,因为瓶颈在数据组织而不在元数据。

1.3 什么情况下不该上 STAC

实话讲,这个规范不是万能的。数据集总量只有几十个文件、检索需求靠一次ls就能满足、团队成员也没打算写自动化流程,这种情况下做一套 STAC 属于过度工程,维护 JSON 的心力不如多写两行脚本。另外,如果你的数据根本没有时空属性——比如一堆配置文件、模型权重——那 STAC 的字段体系完全是负担。还有一个隐性成本:一旦决定对外发布 STAC 目录,字段的语义就变成了承诺,后面改datetime的口径、改bbox的算法都要考虑下游兼容,这是需要提前想清楚的。

2. 四层对象模型的职责边界:Catalog、Collection、Item、Asset 各管什么

2.1 Catalog:一棵靠 links 串起来的索引树

Catalog 是最外层的容器,作用是分组和递归。它本身几乎不描述数据,只有id、description、links这几个字段,真正的结构藏在links数组里——rel为root指向整棵树的根,rel为child指向下一级 Catalog 或 Collection,rel为item指向具体的 Item,rel为self指向自己的地址。

这个设计的好处是:客户端不需要预先知道整棵树长什么样,从根节点顺藤摸瓜就能把全部内容遍历出来。坏处也很明显——树的形状完全由你决定,分几层、按年份分还是按区域分,规范不管。我的经验是按"最少层级"来:一般两到三层足够,第一层按数据产品线分,第二层按时间或空间分块,再往下直接挂 Item。层数堆多了,客户端遍历时的请求数会成倍增长,后面第 6 节会细说这件事。

2.2 Collection:产品线的公共属性集中地

Collection 是 Catalog 的子类,额外承担了一件重要的事:把同一产品线的公共属性提取出来。像许可协议、数据提供方、时空覆盖范围的总体边界、统一的处理级别、波段定义这些东西,每景影像都写一遍既冗余又容易不一致,放在 Collection 里写一次就够了。

其中extent字段值得单独提一句,它包含spatial.bbox和temporal.interval两部分,描述的是这个集合整体的覆盖范围,不是某一景的范围。很多人会把它和 Item 的bbox搞混,导致客户端拿 Collection 的 extent 去做单景裁剪,结果裁出一大片空白。另外summaries字段适合放那些可枚举的统计值,比如这个集合里出现过的所有平台代号、所有处理级别,客户端可以用它来做筛选器的下拉选项,省掉一次全量扫描。

2.3 Item:一次观测,时空范围的最小单位

Item 是整个体系的重心,也是你手写最多、校验最频繁的对象。它必须要有的东西包括:一个集合内唯一的id、geometry(GeoJSON 几何,经纬度)、bbox(边界框)、时间信息(datetime或者start_datetime加end_datetime),以及properties、assets、links、stac_extensions。

properties是自由发挥的地方,但自由是有边界的——凡是规范已经定义了扩展的字段,就应该按扩展的写法来,别自己另起炉灶。比如观测角度、云量这些,标准扩展里已经有约定俗成的键名,自己造一个my_cloud只会让下游无法解析。stac_extensions里列出的 URL 就是告诉客户端"这个 Item 用到了哪些扩展",客户端据此决定要不要加载对应的校验逻辑。

2.4 Asset:指向真实文件的指针

Asset 描述的是可以实际读取的东西——影像本体、缩略图、元数据文件、掩膜、质量评估报告。关键字段有三个:href(地址)、type(媒体类型,比如image/tiff; application=geotiff; profile=cloud-optimized)、roles(角色,data、thumbnail、metadata、overview等)。

一个常见的做法是把同一景的不同波段拆成多个 Asset,每个 Asset 单独挂eo:bands或raster:bands描述波段信息。这样客户端可以只请求它需要的那个波段,配合 COG 的分块读取,流量能省下大部分。反过来,如果一景是一个多波段大文件,也可以只挂一个 Asset,把波段信息统一写在raster:bands数组里。两种都合规,选哪种取决于你的数据实际组织方式和主要读取模式。

2.5 一个最小可用的四层骨架

下面这份骨架去掉了大部分可选字段,只保留能跑通遍历和基本检索的部分,可以直接拿来改:

{ "type": "Catalog", "stac_version": "1.0.0", "id": "demo-root", "description": "示例影像目录根节点", "links": [ {"rel": "root", "href": "./catalog.json"}, {"rel": "self", "href": "./catalog.json"}, {"rel": "child", "href": "./optical/collection.json"} ] }

Collection 层补上extent和license,Item 层补上geometry、bbox、properties.datetime,Asset 层补上真实href。四层写完之后,用任意一个 STAC 客户端从根节点打开,应该能一路点到底——这是最基本的验收标准。

3. 手写第一个 STAC Item:字段来源、构建与校验

3.1 从 GeoTIFF 反推字段:哪些能算,哪些必须人工确认

拿到一个 tif,能自动推导的字段比想象中多:空间范围可以从仿射变换加行列数算出来,投影信息可以读 EPSG 代号,波段数和数据类型也能读到。不能自动推导的只有一个关键字段——时间。影像文件里通常没有可靠的采集时间,datetime要么从原始元数据文件里解析,要么靠文件名加人工确认。

这里有个容易忽略的细节:算出来的范围是数据自身坐标系下的,而 STAC 要求geometry和bbox统一用经纬度。所以中间必须做一次投影转换,转换后再算 bbox。我见过直接拿投影坐标填进 bbox 的,客户端按经纬度去查,永远查不到。

用现成的库能省掉这一步的手工活。下面这段用 rio-stac 直接从栅格生成 Item 雏形,投影和栅格信息都自动带上了:

from rio_stac import create_stac_item item = create_stac_item( "scene_20240312.tif", id="scene-20240312", with_proj=True, with_raster=True, asset_roles=["data"], asset_media_type="image/tiff; application=geotiff; profile=cloud-optimized", ) # 时间必须自己补 item.properties["datetime"] = "2024-03-12T03:20:00Z" item.set_datetime(item.properties["datetime"])

跑完之后检查两件事:bbox是不是经纬度范围(经度落在 -180 到 180,纬度落在 -90 到 90),geometry的类型是不是Polygon而不是Point。这两点错了,后面所有检索都会歪。

3.2 用 pystac 组装 Item 并做结构校验

rio-stac 给的是单景雏形,要把整批数据组织成 Catalog 树,用 pystac 更顺手。下面这段是完整流程:构建 Collection、批量加 Item、规范化路径、落盘。

import pystac from datetime import datetime, timezone collection = pystac.Collection( id="optical-l2", description="示例光学影像产品线", extent=pystac.Extent( spatial=pystac.SpatialExtent([[-180, -90, 180, 90]]), temporal=pystac.TemporalExtent([[None, None]]), ), license="proprietary", ) for scene in scene_list: item = pystac.Item( id=scene["id"], geometry=scene["geometry"], bbox=scene["bbox"], datetime=scene["dt"], properties={"processing:level": "L2"}, ) item.add_asset( "visual", pystac.Asset( href=f"./assets/{scene['id']}.tif", media_type=pystac.MediaType.COG, roles=["data"], ), ) item.validate() collection.add_item(item) collection.normalize_hrefs("./output/optical-l2") collection.save(catalog_type=pystac.CatalogType.SELF_CONTAINED)

normalize_hrefs这一步别跳过。它会根据 Catalog 的层级结构重新生成所有self和item链接,保证落盘之后的相对路径是自洽的。手工拼路径的后果通常是:本地打开正常,一上传到对象存储,所有child链接全断。

3.3 结构校验抓不到的三类语义错误

item.validate()做的是 JSON Schema 校验,能查出字段缺失、类型错误、枚举值越界。但它管不了语义。我踩过的坑集中在三类:

错误类型具体表现检测方法
范围与几何不一致bbox 是 A 区域,geometry 指向 B 区域用几何重新算 bbox 做比对
时间与内容不符文件名写 0312,datetime 填了 0313抽样比对原始元数据
链接失效href 是相对路径,迁移后基准目录变了遍历所有 href 做一次 HEAD 请求

第三类最隐蔽。Schema 校验完全通过,客户端遍历也没报错,但真正去读数据时 404。我的做法是在发布流程里加一步链接探活:把所有 Asset 的 href 拼成完整 URL,逐个发 HEAD 请求,状态码不是 200 就拦下来。这一步在数据量大的时候可以抽样,但路径结构变了之后必须全量跑一次。

4. 静态 Catalog 与 STAC API:两条路线怎么选

4.1 静态 Catalog 的适用面和真实成本

静态 Catalog 就是把整个 Catalog 树导出成一堆 JSON 文件,扔在对象存储或者任意 HTTP 静态服务上。客户端从根节点一路links递归下去,需要自己过滤。这条路线的成本几乎为零——没有服务进程、没有数据库、没有运维,CDN 一挂就能扛住很高的读取量。

它的瓶颈在于"客户端要自己走完整棵树"。数据量在几千个 Item 以内时,遍历一次可能就几百个请求,客户端加个并发还能接受;到几万个 Item 就明显不行了,请求数随 Item 数量线性增长,而客户端能拿到的只有路径信息,做不了真正的空间索引。所以静态 Catalog 的合理定位是:只读、更新不频繁、Item 数量在万级以下、使用方主要是内部脚本或少数几个固定客户端。

4.2 STAC API 换来的是什么

API 路线是在 Catalog 之上加一层服务,提供/search这样的检索端点,把空间范围、时间范围、属性过滤下推到服务端完成。同样是那条查询——"三月到六月、覆盖某流域、处理级别不低于 L2"——客户端只需要发一个 POST 请求:

{ "bbox": [116.2, 39.6, 117.1, 40.3], "datetime": "2024-03-01T00:00:00Z/2024-06-30T23:59:59Z", "query": {"processing:level": {"eq": "L2"}}, "limit": 100 }

返回的是已经过滤好的 Item 列表,附带分页 token。省掉的不只是请求数,更是客户端实现复杂度——不用再关心目录树长什么样,换一个数据源只要换 base URL。

代价是实打实的:需要一个常驻服务、一套支持空间索引的数据库、一套部署和监控。空间检索在关系型数据库里要靠几何索引支撑,建不好索引,一次 bbox 查询能扫全表。这块的工程量不小,选之前得掂量清楚。

4.3 两条路线的对照

维度静态 CatalogSTAC API
运维成本近乎为零,文件放上去就行需要服务进程与数据库
检索能力客户端全量遍历后自行过滤服务端空间、时间、属性组合检索
适合规模万级 Item 以下十万级以上,且持续增长
更新方式重新生成对应 JSON增量写入数据库
缓存友好度极高,可直接走 CDN需要针对查询做缓存策略
实现难度低,一个导出脚本中高,涉及索引与分页

提示:如果起步阶段拿不准,就走静态 Catalog,但把生成 Catalog 的脚本写成幂等的、可重复执行的。等到 Item 数量或者查询复杂度顶不住了,再把这套脚本的输出对接进 API 的写入接口。前期结构规范的话,迁移成本主要在数据库这一侧,元数据本身不用重做。

4.4 从静态迁到 API 的最小改动路径

我推荐的做法是:元数据构建逻辑和发布逻辑分开。构建部分只负责产出符合规范的 Item 对象(用 pystac 组装好的内存对象),发布部分有两个实现——一个是导出成静态 JSON 树,一个是转成字典写进 API 后端。这样切换路线时只换发布器,构建逻辑一行不改。

如果选择自建服务,pgstac 这类把 STAC 语义映射到数据库表结构的方案能省掉大量索引设计工作,它内部用几何类型加索引来支撑空间查询,分页也做了处理。上层的 stac-fastapi 提供标准端点,基本是配置加少量定制就能跑起来。要提醒的是,这类方案对数据库版本和扩展有要求,部署前先确认环境能装,别写到一半发现扩展不可用。

5. 最容易翻车的四个字段:时间、投影、bbox、链接

5.1 datetime 的时区与"时刻 vs 区间"

时间字段的规则看起来简单,出错率却最高。第一条:必须带时区。2024-03-12T03:20:00是不合规的,要么写Z,要么写+08:00这样的偏移。不带时区的时间在不同客户端上会被解释成不同的绝对时刻,跨时区协作时必然出问题。

第二条:datetime和start_datetime/end_datetime是互斥的用法。单次观测填datetime;如果这份数据本身是一个时间区间(比如月度合成产品),就把datetime设为null,同时填上start_datetime和end_datetime。三个都填、或者只填一个start_datetime,都会让校验失败。

第三条:区间的时间语义要统一。同一批数据里,有的用采集开始时刻,有的用区间中点,检索结果的排序就会莫名其妙。我的习惯是在 Collection 的description里把这个口径写清楚,比如"datetime 统一取成像起始时刻",避免后面的人猜。

5.2 投影:数据是投影坐标,STAC 要经纬度

这是新手最常卡住的地方。影像数据的原生坐标系通常是某种投影坐标系,单位是米;而 STAC 的geometry和bbox强制要求是经纬度。中间必须转换,转换完再算包围盒。

转换本身不难,难在两件事。第一,转换后的 bbox 会比原始范围略大,因为经纬度网格在投影下是弯的,用四个角点算包围盒会漏掉中间凸出的部分。如果数据覆盖范围很大、纬度很高,这个误差不能忽略,稳妥做法是把边界采样成多个点再取极值。第二,转换会带来极小的坐标偏移,如果下游有严格的像素级对齐需求,不要拿 STAC 的geometry去做裁剪,它只适合做检索和范围概览。

至于原始投影信息,不要丢,写在proj扩展里:proj:epsg记录坐标系代号,proj:shape记录行列数,proj:transform记录仿射变换的六个参数。这三个字段配合起来,客户端就能从经纬度范围反推出像素窗口,做精确的按需读取。少了proj:transform,客户端只能整景下载再裁,COG 的优势就废了一半。

5.3 bbox 与 geometry 的顺序陷阱

bbox是四个数或六个数组成的数组,顺序固定为[西, 南, 东, 北],也就是[minLon, minLat, maxLon, maxLat]。geometry里的坐标是 GeoJSON 顺序,[经度, 纬度]。这两个顺序不一致的地方坑过不少人:有人按[minLat, minLon, ...]填了 bbox,校验能过(因为都是数值),但检索结果完全错位——在南半球和西半球的数据上尤其明显。

还有一个特殊情况:跨 180 度经线的数据。GeoJSON 里表达跨越日期变更线的多边形,需要把经度拆成两段来表达,bbox 的west会大于east。这种数据别指望所有客户端都能正确处理,如果数据量不大,我倾向于在入库时做一次切分,把跨线的景拆成两块,宁可多几条 Item,也别留一个语义含糊的范围。

5.4 链接失效的完整排查链路

href 出问题的表现是:Catalog 能打开,Item 能列出,点进去读数据 404。排查我一般按这个顺序走:

  1. 先用curl -I直接请求一个 Asset 的完整 URL,确认文件本身在不在、权限对不对。很多情况下问题根本不在 STAC,而是对象存储的读权限没配。
  2. 如果完整 URL 能通,检查 Item 里的href是相对还是绝对。相对路径的基准是 Item 文件自身的位置,一旦 Catalog 树被移动或者导出时目录结构变了,相对路径就全错。
  3. 检查links里的self、root、parent三个关系是否指向正确。parent指错会导致客户端向上遍历时进入死循环或者跳到别的分支。
  4. 如果用了 CDN 或者反向代理,确认 URL 重写规则没有把路径裁掉一段。这个最隐蔽,因为直连源站正常,走代理才出错。

实际经验是,八成以上的链接问题来自"导出时用了绝对路径,后来换了域名"或者"导出时用了相对路径,后来改了目录层级"。解决办法很朴素:发布流程固定用normalize_hrefs走相对路径,迁移时整棵树一起搬,别单独挪某个文件。

6. 规模上量之后:索引、分页与读取性能

6.1 静态目录在几万 Item 时会卡在哪

前面提过,静态 Catalog 的遍历成本随 Item 数量线性增长。但真正的痛点不是"慢",而是"客户端无法做有效过滤"。客户端拿到的是整棵树的链接列表,只能全部拉下来在本地过滤空间范围,内存和请求数双爆。观测到的现象通常是:几百个 Item 时秒开,几千个时等十几秒,上万之后客户端直接超时。

缓解手段有几个。一是压缩层级、减少中间 Catalog 节点,让遍历路径更短;二是把一个 Catalog 下的 Item 数量控制在合理区间,比如单个子目录不超过几千个,靠分片把单次遍历的规模压下去;三是在 Collection 的summaries里放上充分的统计信息,让客户端能在不下钻的情况下先判断"这个 Collection 值不值得看"。这些都是权宜之计,治本还是得上 API。

6.2 COG 加按需读取,才是 STAC 的完整体

STAC 只负责"告诉你在哪",真正决定体验的是读数据那一步。如果 Asset 指向的是普通 GeoTIFF,客户端为了读一个小窗口,必须先下载整个文件——元数据描述得再精确也没用。

COG 让这件事变得可行:它内部按块组织并建有金字塔概览,配合 HTTP 的 Range 请求,客户端可以只取需要的块。所以 Asset 的type字段填image/tiff; application=geotiff; profile=cloud-optimized不是装饰,它是在告诉客户端"你可以放心用范围请求"。同样的道理,对象存储要开启 Range 支持,前置的 CDN 也要透传 Range 头,中间任何一环不支持,按需读取就退化成整文件下载。

6.3 分页、limit 与 token 的正确用法

用 API 检索时,limit只控制单页返回数量,不控制总结果数。客户端拿到响应后要看links里有没有rel为next的链接,有就带着它继续请求,直到没有为止。常见的错误实现是只发一次请求就认为拿全了,结果在大范围查询时静默丢数据——这种 bug 很讨厌,因为小范围测试永远发现不了。

limit的取值也有讲究。设得太大,单次响应体过大,序列化和传输都慢;设得太小,请求轮次太多。实践里 100 到 500 是比较舒服的区间。另外注意不同实现对limit上限有硬性约束,超过会被截断到上限值,客户端不要假设自己设多少就返回多少。

6.4 缓存应该加在哪一层

静态 Catalog 的缓存最简单,给 JSON 文件加上较长的Cache-Control就够了,因为内容不变。API 侧的缓存要复杂一些,检索结果是查询参数的函数,缓存键必须包含完整的请求体。我的做法是对高频的固定查询(比如某个前端页面默认加载的那一次)做短周期缓存,其余走实时查询,避免缓存键爆炸。

Item 级别的 JSON 本身也是可以缓存的,尤其是静态 Catalog 场景。把 Item 的 JSON 和对应的 COG 放在同一个存储桶、同一套 CDN 后面,客户端读元数据和读数据的路径就统一了,减少一次跨域往返。

7. 扩展怎么选:只装真正用得上的

7.1 常见扩展的用途与引入成本

STAC 核心规范刻意保持了精简,光学、雷达、投影、栅格细节这些都放在扩展里。常用的几个:

扩展用途引入时机
proj记录原始投影与仿射变换需要按像素窗口读取时必装
raster描述波段统计值、数据类型、无数据值需要做拉伸或波段选择时
eo云量、观测角度、波段中心波长光学影像产品线基本都要
sar极化方式、入射角、轨道方向雷达数据专用
view视角相关的统一字段需要归一化不同传感器的观测参数
item-assets为 Collection 定义资产模板同产品线资产结构完全一致时

每引一个扩展,stac_extensions里就多一个 URL,客户端在校验和渲染时就多一份逻辑。扩展引多了,Item 的体积会明显变大,静态目录场景下这个成本会乘以 Item 总数。

7.2 最小必要原则的落地判断

判断标准很直接:这个字段下游真的会用吗?如果没人读,就不写。我见过把能想到的扩展全挂上的 Item,字段几百个,实际读取方只用了datetime、bbox和href,其余全是负担。

另一个实操建议是:扩展的选择要在 Collection 层面统一。同一个产品线里,有的 Item 带eo、有的不带,客户端就得写兼容分支,这类不一致比少几个字段麻烦得多。定好一套扩展组合,写进构建脚本的模板里,新数据进来自动套用,别让人手工加。

还有一点关于版本:扩展的 schema 地址里带版本号,锁定版本比追最新更稳。追新的时候先在小批量数据上跑一遍校验,确认客户端也认得新版本再全量推,否则容易出现"元数据合规但客户端解析失败"的尴尬局面。

我在几个不同类型的项目里反复验证过一件事:STAC 落地的难点从来不在写 JSON,而在前期把口径定死——时间取哪个时刻、范围怎么转经纬度、扩展引哪几个、路径用相对还是绝对。这几件事在第一批数据上想清楚,后面就是纯粹的批量处理;想不清楚,做到几万条再回头改,等于把所有下游客户端全部推翻重来。所以真要我给一个建议,就是先用十景数据把整条链路走通、把所有字段填一遍、拿两个不同客户端各读一次,确认没问题之后再上量。这个"十景验证法"听着笨,但省下的返工时间远超那点前期投入。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进