资讯详情

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

📅 2026/11/2 22:59:50 | 华诺云谱 👁 阅读
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、只读文件系统共存落地成本远低于很多人的预期反过来说如果你的原始数据是几百个小文件拼成一景的旧式 GeoTIFFSTAC 描述得再漂亮读取端一样会慢因为瓶颈在数据组织而不在元数据。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 或 Collectionrel为item指向具体的 Itemrel为self指向自己的地址。这个设计的好处是客户端不需要预先知道整棵树长什么样从根节点顺藤摸瓜就能把全部内容遍历出来。坏处也很明显——树的形状完全由你决定分几层、按年份分还是按区域分规范不管。我的经验是按最少层级来一般两到三层足够第一层按数据产品线分第二层按时间或空间分块再往下直接挂 Item。层数堆多了客户端遍历时的请求数会成倍增长后面第 6 节会细说这件事。2.2 Collection产品线的公共属性集中地Collection 是 Catalog 的子类额外承担了一件重要的事把同一产品线的公共属性提取出来。像许可协议、数据提供方、时空覆盖范围的总体边界、统一的处理级别、波段定义这些东西每景影像都写一遍既冗余又容易不一致放在 Collection 里写一次就够了。其中extent字段值得单独提一句它包含spatial.bbox和temporal.interval两部分描述的是这个集合整体的覆盖范围不是某一景的范围。很多人会把它和 Item 的bbox搞混导致客户端拿 Collection 的 extent 去做单景裁剪结果裁出一大片空白。另外summaries字段适合放那些可枚举的统计值比如这个集合里出现过的所有平台代号、所有处理级别客户端可以用它来做筛选器的下拉选项省掉一次全量扫描。2.3 Item一次观测时空范围的最小单位Item 是整个体系的重心也是你手写最多、校验最频繁的对象。它必须要有的东西包括一个集合内唯一的id、geometryGeoJSON 几何经纬度、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; applicationgeotiff; profilecloud-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和licenseItem 层补上geometry、bbox、properties.datetimeAsset 层补上真实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, idscene-20240312, with_projTrue, with_rasterTrue, asset_roles[data], asset_media_typeimage/tiff; applicationgeotiff; profilecloud-optimized, ) # 时间必须自己补 item.properties[datetime] 2024-03-12T03:20:00Z item.set_datetime(item.properties[datetime])跑完之后检查两件事bbox是不是经纬度范围经度落在 -180 到 180纬度落在 -90 到 90geometry的类型是不是Polygon而不是Point。这两点错了后面所有检索都会歪。3.2 用 pystac 组装 Item 并做结构校验rio-stac 给的是单景雏形要把整批数据组织成 Catalog 树用 pystac 更顺手。下面这段是完整流程构建 Collection、批量加 Item、规范化路径、落盘。import pystac from datetime import datetime, timezone collection pystac.Collection( idoptical-l2, description示例光学影像产品线, extentpystac.Extent( spatialpystac.SpatialExtent([[-180, -90, 180, 90]]), temporalpystac.TemporalExtent([[None, None]]), ), licenseproprietary, ) for scene in scene_list: item pystac.Item( idscene[id], geometryscene[geometry], bboxscene[bbox], datetimescene[dt], properties{processing:level: L2}, ) item.add_asset( visual, pystac.Asset( hreff./assets/{scene[id]}.tif, media_typepystac.MediaType.COG, roles[data], ), ) item.validate() collection.add_item(item) collection.normalize_hrefs(./output/optical-l2) collection.save(catalog_typepystac.CatalogType.SELF_CONTAINED)normalize_hrefs这一步别跳过。它会根据 Catalog 的层级结构重新生成所有self和item链接保证落盘之后的相对路径是自洽的。手工拼路径的后果通常是本地打开正常一上传到对象存储所有child链接全断。3.3 结构校验抓不到的三类语义错误item.validate()做的是 JSON Schema 校验能查出字段缺失、类型错误、枚举值越界。但它管不了语义。我踩过的坑集中在三类错误类型具体表现检测方法范围与几何不一致bbox 是 A 区域geometry 指向 B 区域用几何重新算 bbox 做比对时间与内容不符文件名写 0312datetime 填了 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。排查我一般按这个顺序走先用curl -I直接请求一个 Asset 的完整 URL确认文件本身在不在、权限对不对。很多情况下问题根本不在 STAC而是对象存储的读权限没配。如果完整 URL 能通检查 Item 里的href是相对还是绝对。相对路径的基准是 Item 文件自身的位置一旦 Catalog 树被移动或者导出时目录结构变了相对路径就全错。检查links里的self、root、parent三个关系是否指向正确。parent指错会导致客户端向上遍历时进入死循环或者跳到别的分支。如果用了 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; applicationgeotiff; profilecloud-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而在前期把口径定死——时间取哪个时刻、范围怎么转经纬度、扩展引哪几个、路径用相对还是绝对。这几件事在第一批数据上想清楚后面就是纯粹的批量处理想不清楚做到几万条再回头改等于把所有下游客户端全部推翻重来。所以真要我给一个建议就是先用十景数据把整条链路走通、把所有字段填一遍、拿两个不同客户端各读一次确认没问题之后再上量。这个十景验证法听着笨但省下的返工时间远超那点前期投入。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑