第一次接触 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指向 CollectionCollection 里放links指向 ItemItem 里放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指向各 CollectionCollection 上有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/geojson} ]相对路径的好处是整棵树可以打包搬走换个域名不用改任何一个文件坏处是层级一深就容易数错../的个数。我的做法是生成脚本里统一用一个函数拼相对路径并且生成完之后跑一遍连通性检查把每个href解析成绝对路径再确认文件真实存在。这一步花不了几分钟但能拦掉绝大部分“链接失效”的线上问题。注意Item 的type建议写application/geojsonCollection 和 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给原始元数据 XMLthumbnail给缩略图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 库批量生成是最省事的路径核心逻辑就是“读元数据 → 算几何 → 建对象 → 加资产 → 序列化”。先建 Collectionfrom datetime import datetime, timezone import pystac collection pystac.Collection( iddemo-optical-l2a, description某模拟光学载荷 L2A 级正射产品集合, extentpystac.Extent( spatialpystac.SpatialExtent([[-180.0, -90.0, 180.0, 90.0]]), temporalpystac.TemporalExtent( [[datetime(2023, 1, 1, tzinfotimezone.utc), None]] ), ), licenseCC-BY-4.0, keywords[optical, ortho, l2a], ) collection.add_link(pystac.Link.root(./catalog.json))extent如果暂时不确定可以先用全球范围和起始时间占位等 Item 全部生成完再调collection.update_extent_from_items()让它自动收敛到真实范围。这一步建议放在流程最后避免边生成边维护范围。再逐个生成 Itemfrom shapely.geometry import shape, mapping def build_item(scene_meta, collection): footprint shape(scene_meta[footprint_geojson]) # WGS84 几何 item pystac.Item( idscene_meta[scene_id], geometrymapping(footprint), bboxlist(footprint.bounds), datetimescene_meta[acquired_utc], properties{ platform: scene_meta[platform], instruments: [scene_meta[instrument]], gsd: scene_meta[gsd_meters], }, collectioncollection.id, ) item.add_asset( visual, pystac.Asset( hreff../../assets/{scene_meta[scene_id]}_visual.tif, media_typepystac.MediaType.COG, roles[visual], title真彩色预览, ), ) item.add_asset( data, pystac.Asset( hreff../../assets/{scene_meta[scene_id]}_analytic.tif, media_typepystac.MediaType.COG, roles[data], ), ) return item投影字段单独加from pystac.extensions.projection import ProjectionExtension proj ProjectionExtension.ext(item, add_if_missingTrue) 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_missingTrue) 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/jsonItem 文件返回application/geojson更好。如果对象存储把类型识别成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], datetime2023-06-01/2023-06-30, query{eo:cloud_cover: {lt: 20}}, max_items200, ) items list(search.items()) print(f命中 {len(items)} 景) # 按云量排序取前 5 景的 data 资产 items.sort(keylambda 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而非geojsonItem 用application/geojson检索查不到几何填了投影坐标全部转成 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 生成脚本当成一等公民维护写清楚输入是什么、输出在哪、跑一次多久、失败怎么重试。因为数据是会持续更新的这份脚本你至少还要用两年把它当随手写的临时工具后面一定会在某个深夜被叫起来改它。我现在的习惯是每次数据更新都先跑一遍生成再跑一遍校验两个都过了才通知下游这个流程固定下来之后交付环节的返工基本没有了。