STAC 时空资产目录实战:静态目录与检索服务落地指南

发布时间:2026/11/2 9:29:55
STAC 时空资产目录实战:静态目录与检索服务落地指南

第一次接触 STAC 规范,是在给一个数据交付项目做收尾的阶段。当时手里有几百景正射影像、几套分类产品、还有一堆说明文档,客户那边要求能按时间、按范围、按云量自己筛选,而不是每次都要问我们要清单。我们试过给 Excel 加超链接、试过自建一个简单的地图服务,最后都被“元数据怎么跟着数据走”这个问题卡住。直到把数据按 STAC 规范整理成一套时空资产目录,交付物从“一堆文件加一张表”变成了“一个可以被程序直接读懂的目录树”,后面接前端、接分析脚本、接第三方平台都顺了很多。

这篇内容就把我踩过的路完整复盘一遍:STAC 规范是什么、能解决什么问题、核心对象怎么设计、静态目录和检索服务两种形态怎么选、字段怎么填才不返工、校验和排错怎么做。适合三类人看——手里有一批带时空属性的数据要交付的人、需要给别人提供检索接口的后端开发、以及想接第三方地理数据源的分析人员。哪怕你之前没写过一行 GeoJSON,照着走也能跑通一个最小可用版本。

1. 先想清楚 STAC 到底在解决什么问题

1.1 从“一堆文件”到“可被程序读懂的资产目录”

传统的数据交付方式,本质上是把文件系统和一张说明表绑在一起。文件系统告诉你东西在哪,说明表告诉你东西是什么,但这两者之间没有强约束——文件挪个位置链接就断了,表格里少写一列下游就没法过滤。STAC 规范的思路很直接:把每个数据文件都描述成一个带时空范围的 JSON 对象,文件本身靠 URL 引用,描述和文件之间用相对路径绑定。这样一来,一个目录树走完,整批数据就自带了一份机器可读的清单。

我用一个类比来解释:STAC 相当于给每个数据文件发了一张身份证,身份证上写着它是什么时候拍的、在哪拍的、覆盖多大范围、用的是什么投影、有没有云、原始文件在哪。目录树就是户籍系统,谁是谁的子节点、谁是根,全都写在links里。下游系统不需要认识你的命名规范,只需要按规范读身份证字段就行。

关键点在于“规范”这两个字。它不是某个团队的私有格式,字段名、类型、必填项都有明确定义,所以不同来源的目录能拼在一起检索。这一点在多方数据融合的场景里价值极大——你把自家产品和外部采购的数据都整理成 STAC,检索代码一份就够。

1.2 静态目录和检索服务:两种落地形态怎么选

STAC 规范落地上有两条路,选错了后面要么返工要么白干。

第一条是静态目录:纯粹的 JSON 文件组成的目录树,放在对象存储或静态服务器上就能访问,不需要任何后端。优点是零运维、成本低、CDN 一挂全球可读;缺点是检索能力弱,客户端只能一层层遍历,集合上千个 Item 之后体验会明显下降。

第二条是检索服务:把 STAC 对象灌进带空间索引的数据库,对外暴露标准的检索端点,支持范围、时间、属性组合过滤和分页。优点是查询快、能力完整;缺点是要部署、要维护索引、要考虑并发。

我的选型经验是这样:数据量在几百到两千个 Item 之间、使用方主要是自己人或固定几个合作方,优先静态目录,半天就能上线;一旦超过这个量级,或者需要给不特定的第三方提供查询、需要按属性组合过滤,就必须上检索服务,硬扛静态目录最后一定是客户端卡死。

提示:两种形态不是互斥的。常见做法是同一批数据既生成静态目录供归档和长期引用,又同步灌入检索服务供在线查询,两边共用同一套生成脚本,保证字段一致。

1.3 最小可用版本需要哪些文件

先给一个心理预期,别被“规范”两个字吓住。一个能通过校验的最小目录只需要三个文件:一个根 Catalog、一个 Collection、一个 Item。根 Catalog 里放links指向 Collection,Collection 里放links指向 Item,Item 里放assets指向实际的数据文件。

stac-root/ ├── catalog.json # 根节点,links 指向子集合 ├── demo-collection/ │ ├── collection.json # 集合节点,定义空间/时间范围与公共属性 │ └── items/ │ └── scene-001.json └── assets/ └── scene-001_visual.tif

这个结构看着朴素,但已经满足了下游的核心需求:给一个 ID 能拿到元数据、给一个范围能遍历出候选、给一个 Item 能定位到文件。后面所有的复杂度——扩展字段、API、分页、批量生成——都是在这个骨架上加东西。

2. 核心对象模型拆解:四个对象撑起整棵树

2.1 Catalog、Collection、Item、Asset 的职责边界

这四个对象的分工必须一次划清楚,划不清楚后面字段一定会乱放。

对象职责典型字段常见误用
Catalog组织节点,只负责挂载子节点id、description、links往里塞业务属性
Collection数据集级别,定义公共范围和公共字段extent、license、summaries、item_assets把单景信息写进来
Item单次观测/单个成果,检索的最小单位geometry、bbox、datetime、properties、assets一个 Item 塞多个时相
Asset实际文件的引用href、type、roles、扩展字段只写一个主文件

最典型的一处误用是“把整个数据集的元数据写在每个 Item 里”。比如传感器型号、处理级别、许可协议,这些在 Collection 级别写一次就够了,在每个 Item 里重复写不仅体积大,改一次要全量重生成。反过来,时间、几何、云量这些每景不同的,必须写在 Item 上,不能塞进 Collection 的summaries里当唯一值。

这里有个实操判断标准:**如果这个字段对所有 Item 都相同,它就是 Collection 字段;如果它会变,它就是 Item 字段。**按这个标准过一遍,字段归属基本不会错。

2.2 links 的写法决定了目录能不能被遍历

STAC 的目录结构不是靠文件路径推断的,而是靠links显式声明。这一点很多新手会忽略,结果目录文件都能打开,但用客户端一跑就报“找不到关系链”。

必须写的关系有这么几类:根 Catalog 上有child指向各 Collection,Collection 上有root指回根、有parent指回根、有item指向 Item;每个 Item 上有root、parent、collection、self。self容易被漏掉,但对 API 兼容很重要——服务端返回的对象通常需要带上自身的绝对地址。

"links": [ {"rel": "root", "href": "../../catalog.json", "type": "application/json"}, {"rel": "parent", "href": "../collection.json", "type": "application/json"}, {"rel": "collection", "href": "../collection.json", "type": "application/json"}, {"rel": "self", "href": "./scene-001.json", "type": "application/geo+json"} ]

相对路径的好处是整棵树可以打包搬走,换个域名不用改任何一个文件;坏处是层级一深就容易数错../的个数。我的做法是生成脚本里统一用一个函数拼相对路径,并且生成完之后跑一遍连通性检查,把每个href解析成绝对路径再确认文件真实存在。这一步花不了几分钟,但能拦掉绝大部分“链接失效”的线上问题。

注意:Item 的type建议写application/geo+json,Collection 和 Catalog 写application/json。这不是强制项,但很多客户端会根据type决定用哪个解析器,写错了会出莫名其妙的解析错误。

2.3 几何、时间、投影三组字段最容易填错

这三组字段是所有校验报错的重灾区,逐个说。

几何:geometry必须是 WGS84 经纬度的 GeoJSON 几何,bbox是[west, south, east, north]四元组。如果你原始数据是投影坐标,必须先转换。我见过直接把投影坐标填进geometry的,校验能过(因为它只是数字),但检索时按经纬度范围永远查不到,排查起来极其费劲。bbox和geometry的外接矩形必须一致,手写很容易差一点,用代码从geometry算出来最稳。

时间:单个时相用datetime,跨时相用start_datetime加end_datetime,两套不能混用。时间必须带时区,写成2023-06-01T02:30:00Z这种形式,千万别写本地时间。我吃过一次亏:批量生成时用了datetime.now(),结果服务器时区是东八区,同一批数据的时间字段比实际早了八小时,下游做时序分析时全错位。

投影:投影相关的字段在扩展里,不在核心字段里。proj:epsg、proj:shape、proj:transform三个是最有用的。proj:transform用的是 GDAL 的六元组格式[xres, 0, x0, 0, -yres, y0],注意第五位是负的,因为栅格的行方向是自上而下。这一位写成正数会导致坐标系对不上,瓦片全部错位。

2.4 Asset 的组织方式:视觉文件和分析文件怎么分

一个 Item 下面往往不止一个文件:预览图、原始波段、正射产品、质量掩膜、切片。全部堆在assets里用一个 key,下游就没法按用途挑。规范的做法是每个文件一个 key,用roles标注用途。

我一般用这套命名:visual给真彩色预览,data给主分析文件,metadata给原始元数据 XML,thumbnail给缩略图,qa给质量掩膜。roles里写visual、data、metadata、thumbnail这些标准值,再配合type字段写明媒体类型。

这里有个很实用的技巧:Collection 里可以写item_assets,声明“本集合所有 Item 都会有这几个 key”,客户端就能提前知道有哪些资产可用,不用先拉一个 Item 看。这在大规模检索时能省掉一轮请求。

3. 动手搭一个静态 STAC 目录

3.1 目录结构与分片策略

数据量一上去,第一个撞上的问题就是“所有 Item 塞一个目录,客户端加载不动”。解决办法是分片。

分片维度我按数据特性选:时间跨度大的按年份分(2022/、2023/),空间覆盖广的按网格分(tile-50TMK/),两者都大的就按“年份 + 网格”两级。每一级都是一个 Catalog 节点,根节点用child挂下去。这样客户端可以只遍历自己关心的那一支,加载量直接降一个数量级。

stac-root/ ├── catalog.json └── demo-collection/ ├── collection.json ├── 2023/ │ ├── catalog.json │ └── 06/ │ ├── catalog.json │ └── scene-20230601T0230.json └── 2024/ └── catalog.json

分片粒度的经验值:单个 Catalog 节点下的子节点控制在 200 个以内,单个目录里的 Item 文件控制在 1000 个以内。超过这个量,无论是客户端遍历还是你自己用文件管理器打开都会变慢。这个数字不是硬指标,是实测下来比较舒服的区间。

3.2 用脚本批量生成 Item

手写 JSON 是不现实的,几百个 Item 手写必然出错。用 Python 生态里的主流 STAC 库批量生成是最省事的路径,核心逻辑就是“读元数据 → 算几何 → 建对象 → 加资产 → 序列化”。

先建 Collection:

from datetime import datetime, timezone import pystac collection = pystac.Collection( id="demo-optical-l2a", description="某模拟光学载荷 L2A 级正射产品集合", extent=pystac.Extent( spatial=pystac.SpatialExtent([[-180.0, -90.0, 180.0, 90.0]]), temporal=pystac.TemporalExtent( [[datetime(2023, 1, 1, tzinfo=timezone.utc), None]] ), ), license="CC-BY-4.0", keywords=["optical", "ortho", "l2a"], ) collection.add_link(pystac.Link.root("./catalog.json"))

extent如果暂时不确定,可以先用全球范围和起始时间占位,等 Item 全部生成完再调collection.update_extent_from_items()让它自动收敛到真实范围。这一步建议放在流程最后,避免边生成边维护范围。

再逐个生成 Item:

from shapely.geometry import shape, mapping def build_item(scene_meta, collection): footprint = shape(scene_meta["footprint_geojson"]) # WGS84 几何 item = pystac.Item( id=scene_meta["scene_id"], geometry=mapping(footprint), bbox=list(footprint.bounds), datetime=scene_meta["acquired_utc"], properties={ "platform": scene_meta["platform"], "instruments": [scene_meta["instrument"]], "gsd": scene_meta["gsd_meters"], }, collection=collection.id, ) item.add_asset( "visual", pystac.Asset( href=f"../../assets/{scene_meta['scene_id']}_visual.tif", media_type=pystac.MediaType.COG, roles=["visual"], title="真彩色预览", ), ) item.add_asset( "data", pystac.Asset( href=f"../../assets/{scene_meta['scene_id']}_analytic.tif", media_type=pystac.MediaType.COG, roles=["data"], ), ) return item

投影字段单独加:

from pystac.extensions.projection import ProjectionExtension proj = ProjectionExtension.ext(item, add_if_missing=True) proj.epsg = scene_meta["epsg"] proj.shape = [scene_meta["height"], scene_meta["width"]] # 由左上角原点与分辨率推算 GDAL 六元组 x0, y0 = scene_meta["origin_x"], scene_meta["origin_y"] res = scene_meta["resolution"] proj.transform = [res, 0.0, x0, 0.0, -res, y0]

这段里的transform是纯计算:假如某景影像左上角原点在投影坐标系下是(500000.0, 4400000.0),地面分辨率 10 米,那么六元组就是[10.0, 0.0, 500000.0, 0.0, -10.0, 4400000.0]。第一位表示列方向每像素前进 10 米,第五位-10.0表示行方向每像素下降 10 米——因为图像第一行在最北边。这个负号写错是最隐蔽的错误之一,校验不报错,但下游按坐标取像元时会整体偏移。

云量这类属性字段走扩展:

from pystac.extensions.eo import EOExtension eo = EOExtension.ext(item, add_if_missing=True) eo.cloud_cover = scene_meta["cloud_cover_percent"]

3.3 校验、纠错、再校验的循环

生成完不等于能用。我的固定动作是跑两遍校验:库内校验和外部校验工具各跑一次。

item.validate() # 库自带的本地校验
pip install stac-validator stac-validator ./stac-root/catalog.json --recursive

外部工具的好处是它会真的去递归遍历整棵树,把链接断链、字段类型错误、枚举值不合法一起报出来。第一次跑大概率会有一屏错误,别慌,按下面的顺序处理:先修 schema 层面的类型错误(比如bbox写成了字符串),再修必填字段缺失,最后处理扩展字段的枚举值。

有个细节值得说:--recursive会顺着链接一路爬,如果你的资产文件放在同一个域名下且链接指向了.tif,工具可能会尝试去读它,导致变慢或报错。这种情况把校验范围限制在 JSON 文件上,或者先把href检查拆出来单独做。

3.4 发布到对象存储时的三个配置项

静态目录最终要放到能被 HTTP 访问的地方。上传本身不难,难的是三个容易漏的配置。

第一个是Content-Type。.json文件必须返回application/json,Item 文件返回application/geo+json更好。如果对象存储把类型识别成application/octet-stream,浏览器会直接下载文件而不是渲染,客户端也可能拒绝解析。上传时显式指定元数据最保险。

第二个是CORS。前端页面直接读 STAC 目录时,跨域头没配会全线报错。至少放开GET和HEAD,允许的源按实际需要收敛,别图省事写通配。

第三个是缓存策略。Collection 和 Catalog 会被频繁读取,缓存时间长一点没问题;Item 文件生成后基本不变,可以给长缓存;但如果你的数据会持续追加,Collection 的extent和links会变,就别给太长。

提示:上传完成后一定用公开地址把根catalog.json拉一遍,再从任意一个 Item 反着走回根节点。正向、反向都能走通,链接才算真的没问题。

4. 把静态目录升级成可检索的服务

4.1 API 的能力边界先划清楚

检索服务的核心价值是“组合条件 + 分页 + 快”。能力边界建议一开始就定死,不然需求会无限膨胀。

必要能力有四项:按 ID 取单个对象、按集合列出 Item、按空间范围加时间范围过滤、按属性做数值比较(例如云量小于 20)。进阶能力包括排序、字段裁剪(只返回需要的字段,减少传输量)、聚合统计。前三项不实现,服务基本没法用;后三项按实际需求加。

有一个常见误解要澄清:检索服务返回的 Item 里,assets.href最好是绝对地址。因为客户端拿到结果后是从完全不同的域名去下载文件的,相对路径会解析到客户端自己的域上,直接 404。生成静态目录时用相对路径方便搬迁,进服务时统一改写成绝对地址,这一步别忘了。

4.2 部署与索引:为什么必须上空间索引

自己写一个“遍历所有 JSON 然后过滤”的服务,几十个 Item 时能用,几百个就开始卡。原因是每次查询都要全量扫描,跟数据库没有索引时一样。

正规做法是把 Item 灌进支持空间索引的存储,几何字段建空间索引、时间字段建范围索引、常用属性字段建普通索引。查询时数据库先按索引把候选集缩到很小,再逐条比对剩余条件。我做过一个粗略对比:同样 5 万个 Item,全量扫描一次查询要几秒,建好索引后是几十毫秒,差了两个数量级。

部署上,走容器化是最省心的。典型的组合是一个应用服务加一个数据库服务,用编排文件一把拉起来,Item 通过批量导入接口灌进去。导入时注意分批,别一次性提交几万条,容易超时;每批几百到一千条比较稳。

4.3 查询语法:把过滤条件组织对

检索请求通常是一个 JSON 体,几个关键字段要理解清楚。

curl -s -X POST "http://localhost:8080/search" \ -H "Content-Type: application/json" \ -d '{ "collections": ["demo-optical-l2a"], "bbox": [116.0, 39.0, 117.0, 40.0], "datetime": "2023-06-01T00:00:00Z/2023-06-30T23:59:59Z", "query": {"eo:cloud_cover": {"lt": 20}}, "limit": 50, "sortby": [{"field": "properties.datetime", "direction": "desc"}] }'

bbox的四位顺序是[west, south, east, north],跨 180 度经线时要注意拆分,别直接写一个跨越半个地球的范围,结果往往和预期不符。datetime支持区间写法,用斜杠分隔,开区间可以留空一侧,比如2023-06-01T00:00:00Z/..表示从这天开始往后。query里的字段名必须带扩展前缀,cloud_cover是不认的,必须写eo:cloud_cover。

分页用limit加token,返回体里会带一个next链接,取到next为空说明到底了。这里有个坑:不要用页码式分页去猜总数,STAC 检索本身不保证稳定排序,翻页过程中底层数据变了结果可能重复或遗漏。跟着next走是最稳的。

4.4 客户端检索脚本:从查询到下载一条龙

服务端通了之后,实际工作流都在客户端。用配套的客户端库,把检索、筛选、下载串成一段脚本,是日常最高频的操作。

from pystac_client import Client catalog = Client.open("http://localhost:8080") search = catalog.search( collections=["demo-optical-l2a"], bbox=[116.0, 39.0, 117.0, 40.0], datetime="2023-06-01/2023-06-30", query={"eo:cloud_cover": {"lt": 20}}, max_items=200, ) items = list(search.items()) print(f"命中 {len(items)} 景") # 按云量排序取前 5 景的 data 资产 items.sort(key=lambda it: it.properties.get("eo:cloud_cover", 100)) for it in items[:5]: asset = it.assets["data"] print(it.id, it.datetime, asset.href)

max_items是客户端层面的上限保护,服务端可能返回更多,这个参数能防止一次性拉回几万条把内存打满。实际使用中我一般先拉一批看看命中量级,确认范围合理再放开。

需要下载文件时,直接用asset.href走普通的 HTTP 下载就行。如果资产本身是云优化格式的栅格,配合支持按窗口读取的库,可以只取感兴趣的那一小块,不必整景下载——这一点在数据分析场景里能省掉大量时间和流量。

5. 常见问题与排查技巧实录

5.1 校验报错速查表

下面这张表是我攒下来的高频问题,基本覆盖了八成以上的报错。

报错现象根本原因处理方式
datetime相关校验失败缺失该字段,或datetime与start/end混用单时相填datetime,跨时相两者都删、填起止时间
bbox长度不对写成了 6 位(三维)或元素顺序错统一用[w, s, e, n]四位
几何校验失败多边形未闭合、坐标越界首尾点必须相同,经度 ±180、纬度 ±90 内
扩展字段无效应字段名少了前缀,或没声明扩展写eo:cloud_cover,并在 Item 里加对应扩展声明
链接不可达相对路径层数算错统一函数拼接,生成后做一次连通性检查
客户端解析失败type写成application/json而非geo+jsonItem 用application/geo+json
检索查不到几何填了投影坐标全部转成 WGS84 再写入
时间对不上用了本地时间统一转 UTC 并带Z后缀

这张表建议生成脚本里就做前置拦截,别等到校验阶段才发现。比如在写 Item 之前先断言bbox长度是 4、断言几何是经纬度范围,把错误挡在源头。

5.2 几何与投影的三个隐蔽坑

第一个坑是跨 180 度经线。一个跨越换日线的范围,bbox会变成[179, ..., -179, ...],也就是west > east。很多自建服务不处理这种情况,查询直接返回空。遇到跨经线的数据,要么把几何切成两块,要么在服务端显式支持。我在做全球数据时就撞过这个,排查了半天才发现范围写反了。

第二个坑是几何精度和内接矩形不一致。有些团队为了省体积,把多边形的点数抽稀得很厉害,结果bbox是用原始几何算的,两者对不上。校验工具不查这个,但检索时按bbox命中、按geometry二次过滤时会漏。解决办法只有一个:bbox永远从最终写入的geometry算,别用中间结果。

第三个坑是投影参数和实际文件不一致。proj:epsg写的是 32650,实际文件是另一个带偏参数的坐标系,看起来只是差个几十米,叠加分析时才发现错位。这种情况从元数据里读,别靠猜。文件里的元数据是唯一可信来源,人工填的值最不可靠。

5.3 性能与成本上的实操心得

关于静态目录的性能,我的体会是“分片比压缩重要”。有人试图把 JSON 压到最小,字段名缩写、去掉空白,结果省下来的那点体积远不如把目录拆开有效。客户端一次只拉自己需要的那一支,比把整个 20MB 的 JSON 拉下来再本地过滤快得多。

关于检索服务,索引建好之后要注意属性字段的索引选择。不是所有字段都值得建索引,只给你真正会用来过滤的字段建。我见过一股脑给十几个字段全建索引的,写入速度掉了一半,查询提升微乎其微。经验是:空间、时间必建,云量这类高频数值字段建,文本描述类的别建。

还有一个容易被忽略的成本项:资产的访问方式。如果资产和目录放在同一个存储上且客户端都在同一网络内,直接给内部地址最快;如果数据要给外部用,资产地址得是公网可达的。同一份元数据在两套环境下分别生成,或者用配置注入的方式替换前缀,比生成完再全局改字符串安全。

注意:批量生成时一定加日志,把每个 Item 的 ID 和耗时打出来。我遇到过一次脚本跑到第 380 个 Item 时静默失败,因为某个文件的时间字段格式异常抛了异常但被外层吞掉了,结果只生成了 379 个,后面所有统计都对不上。加日志之后这类问题一眼就能定位。

6. 扩展怎么选,版本怎么跟

6.1 扩展的选用原则:够用就行,别贪多

核心规范只规定了最基础的字段,业务属性都靠扩展。常见的扩展覆盖光学属性(云量、太阳角度)、投影参数、观测几何(入射角、方位角)、标注数据、机器学习模型描述等。

我的选用原则是三条:只在真有数据时启用,字段全空的扩展不如不加;优先用已有扩展而不是自造字段,自造字段下游不认识,等于没有;一个文件里启用的扩展不超过四个,多了之后维护成本陡增,而且不同客户端对扩展的支持程度参差。

有个具体的判断例子:如果你有云量、太阳高度角、太阳方位角三个值,就启用光学属性扩展,三个字段一起加;如果只有一个“质量等级”这种自定义指标,别硬塞进光学扩展,用通用的附加属性字段存着就行,检索时照样能按属性过滤。

6.2 版本演进与向后兼容的处理

规范本身在演进,字段会有增补,校验规则会变严。这里最重要的动作是在 Collection 里固定声明你遵循的版本,让下游知道按哪个版本的规则解析。

升级时的兼容策略:新老版本并行一段时间,老目录不动,新生成的走新版本,检索服务侧同时兼容两种。别一次性全量重生成,几万个 Item 重跑一次成本不低,而且中间态容易出问题。

还有一点值得提醒:校验工具的版本也要跟着升。工具落后于规范时,会出现“明明按新规范写的却报错”的情况,浪费时间。把校验工具版本写进项目的依赖文件,跟代码一起管理,这个习惯能省掉很多莫名其妙的排查。

最后分享一个我在多个项目里都用过的做法:把 STAC 生成脚本当成一等公民维护,写清楚输入是什么、输出在哪、跑一次多久、失败怎么重试。因为数据是会持续更新的,这份脚本你至少还要用两年,把它当随手写的临时工具,后面一定会在某个深夜被叫起来改它。我现在的习惯是每次数据更新都先跑一遍生成,再跑一遍校验,两个都过了才通知下游,这个流程固定下来之后,交付环节的返工基本没有了。