ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

GEE批量下载Sentinel-1数据:任务队列与断点续传全指南

GEE批量下载Sentinel-1数据:任务队列与断点续传全指南

简介:这套代码包专为需要批量获取Sentinel-1雷达影像的研究者设计,依托Google Earth Engine云平台,免去本地下载与预处理的重型计算。代码涵盖四个核心环节:其一是定义矢量或坐标范围作为研究区;其二是按极化方式、轨道方向、时间区间等条件过滤ImageCollection;其三是将Sentinel-1原始的后向散射系数从dB单位转换为线性单位,确保后续定量分析准确;其四是通过逐景映射完成裁剪后批量Export至Google Drive。压缩包共3个文件、总体积5KB,包含一个inscode脚本(GEE代码编辑器的可执行工程)、一个index.html(用于结果展示或参数界面)和一个.gitignore(版本控制忽略规则),结构小巧而清晰。目前已有340人下载学习,适合具备基本GEE编程概念、希望直接复用现成流程缩短开发周期的遥感与GIS从业者。

1. GEE批量下载Sentinel-1数据:命令好写,任务队列才是真正的门槛

GEE批量下载Sentinel-1数据,听起来就是把影像集合循环导出成GeoTIFF,真正跑过的人都知道,卡点从来不在导出那几行命令,而在成百个任务怎么排队、怎么少失败、怎么断点续传。这个方向适合要做时序分析的从业者:洪水淹没提取、水稻制图、船舰检测、地表形变监测,都需要某个区域一年几十上百景哨兵一号SAR数据。用GEE的GRD产品配合云端硬盘批量导出,是目前最省人力的路径。但它有几个硬门槛:任务配额、投影设置、极化过滤,每一项踩错都可能整批重跑。这篇我按实际调试顺序,从集合组织讲到参数设置,再讲四个高频翻车现场和一套断点续传的办法。

2. 先搞清Sentinel-1在GEE里的组织方式:集合、条带与极化

很多人一上来就写Export,结果导出的不是自己想要的。批量下载的第一步,是确认目标数据存在哪个集合、是什么单位、有哪些可过滤的属性。这一步错了,后面所有任务都是白跑。

2.1 GRD与GRD_FLOAT:dB与线性后向散射,选错单位会带偏分析

GEE目录里哨兵一号最常用的两个集合是COPERNICUS/S1_GRD和COPERNICUS/S1_GRD_FLOAT。两个集合的数据源头和处理流程一致,都做了热噪声去除、辐射定标、基于SRTM的地形校正,区别只在存储单位:GRD直接存后向散射系数的dB值,GRD_FLOAT存线性功率值。也就是说,GRD里水体像元通常在-18到-22 dB,而GRD_FLOAT里对应的数值是0.01到0.02这种极小的小数。

我一般按目标选:如果只是看图、做阈值分割、训练分类器,GRD的dB值更直观,拉伸范围设成[-25, 0]就能看清地物;如果要做后向散射时间序列的定量分析,或者要把多个传感器数据放一起比较,GRD_FLOAT更合适,因为dB值在时间平均时会引入系统性偏差。需要显示FLOAT数据时,用10*log10转回dB即可:

// 把线性后向散射转成dB显示:dB = 10 * log10(linear) var s1_float = ee.ImageCollection('COPERNICUS/S1_GRD_FLOAT') .filterBounds(roi) .first(); var db = ee.Image(10).multiply(s1_float.select('VV').log10());

参数说明:ee.Image(10)是常数图像,log10()对每个像元取对数,两者相乘得到dB值。注意这里不要直接对GRD_FLOAT集合里的影像做算术平均后再转dB,正确顺序是先转dB再平均,否则小数值被平均后放大的误差会污染时序曲线。另外两个集合的波段名都统一成了VV、VH(或HH、HV),不再有原始产品里那种带极化顺序的长波段名,这让后面的select省了很多事。

2.2 集合过滤的三个必写条件:范围、时间与轨道

对哨兵一号GRD集合做过滤,我习惯固定写四行,少了哪一行都会给后面留坑。filterBounds负责把覆盖研究区的影像筛出来,filterDate负责时间窗,剩下两个不是可选项而是准必须项:instrumentMode等于IW,以及orbitProperties_pass等于ASCENDING或DESCENDING。IW是干涉宽幅模式,是覆盖大陆区域的主力模式;条带模式SM和海洋模式EW的几何、分辨率、极化组合都不一样,混进集合会让时间序列参差不齐。升轨和降轨的成像几何完全相反,侧视方向不同,同一地物的雷达亮度在两轨上有系统差异,混用等于给数据凭空加噪声。

// 研究区:先用点缓冲,跑通以后再换成完整矢量边界 var roi = ee.Geometry.Point([120.12, 30.31]).buffer(5000); var s1 = ee.ImageCollection('COPERNICUS/S1_GRD') .filterBounds(roi) .filterDate('2023-06-01', '2023-07-01') .filter(ee.Filter.eq('instrumentMode', 'IW')) .filter(ee.Filter.eq('orbitProperties_pass', 'ASCENDING')) .filter(ee.Filter.listContains('transmitterReceiverPolarisation', 'VV')); print('影像数量:', s1.size()); print('第一景时间:', s1.first().get('system:time_start')); print('波段:', s1.first().bandNames()); print('轨道号:', s1.first().get('relativeOrbitNumberStart'));

参数说明:listContains是这里的关键——transmitterReceiverPolarisation在GEE里是一个列表属性(比如['VV','VH']),不能拿eq直接跟字符串比,必须用listContains判断数组里是否包含目标极化。buffer(5000)把点扩展成半径5公里的圆形测试区,足够验证集合和导出链路。先跑这段,确认打印出的波段里有VV、轨道号正常,再往下走。

如果是做形变类时序分析,我会再加一个relativeOrbitNumberStart过滤,让所有影像落在同一个轨道框架下。轨道号不同意味着入射角不同,直接按时间画后向散射曲线会抖得很厉害,这种抖动不是地物变化,是几何视角差异。

2.3 为什么在GEE里批量下载,而不是去原始分发站点一景一景抓

GRD在GEE里是“成品”数据。去原始分发站点拿L1级产品,要自己处理热噪声、辐射定标、地形校正,逐景下载还要维护轨道文件列表,几十景的批量任务很容易变成半个星期的体力活。GEE把预处理做掉了,还把下载复杂度收敛成“集合过滤加逐景导出”,这是它最大的价值。

边界条件也要说清楚:如果你做的是需要严格基线控制的干涉处理、需要单视复数SLC数据,或者对轨道精度有特殊要求,GRD产品不合适,GEE也不是该走的路。这个批量下载方案,针对的是“后向散射影像”这个层次的需求,拿到手就能做分类、阈值分割和时序统计。

3. 最小可跑的批量导出脚本:先验证单景,再放出一整年的任务队列

集合过滤确认无误后,按“先单景后批量”的顺序跑。直接放一百个任务上去再检查,失败率一定高。我先导出测试景,确认几何、波段、投影都对,再循环放任务。

3.1 先导出一景测试景:验证集合是否筛对

测试景的目的有两个:一是确认select的波段在目标影像里真实存在,二是确认导出参数组合不会报错。用前面过滤好的集合,先导出第一景:

// 导出测试景到云端硬盘:crs固定为UTM 50N,避免WGS84下10米无法表达 Export.image.toDrive({ image: s1.first().select('VV').clip(roi), description: 'S1_test_202306', folder: 'S1_download', region: roi, scale: 10, crs: 'EPSG:32650', maxPixels: 1e9, fileFormat: 'GeoTIFF' });

参数说明:clip(roi)把影像裁到研究区,避免导出整条条带造成浪费;crs固定为EPSG:32650是因为roi落在UTM 50N分区,10米分辨率在这个投影下是真实的地面距离。如果不指定crs,GEE默认按WGS84输出,那10米会被解释成10度,出来的就是一张分辨率惨不忍睹的巨型图,几乎必然触发maxPixels报错。提交后等一两分钟,去Tasks面板看状态,下载到本地用GIS软件打开,确认数值范围、坐标原点都正常,再进行批量。

3.2 按时间戳循环生成任务队列

单景验证通过后,批量部分的核心是“遍历时间戳,每个时间戳下的每条条带生成一个导出任务”。这里有个容易忽略的点:同一个时刻研究区可能被两到三条相邻条带同时覆盖,按日期去重会把其中几条漏掉。我用时间窗口把同一时刻的多条条带都取出来,逐个导出:

// 获取该区域全部影像的时间戳(毫秒),一次性取回客户端 var timestamps = s1.aggregate_array('system:time_start').getInfo(); timestamps.forEach(function(ms) { var d = new Date(ms); var pad = function(x) { return (x < 10 ? '0' : '') + x; }; var dayStr = d.getFullYear() + pad(d.getMonth() + 1) + pad(d.getDate()); var hhmm = pad(d.getHours()) + pad(d.getMinutes()); // 同一时刻可能跨多条条带,用正负一分钟窗口取回全部景 var sameTime = s1.filterDate( ee.Date(ms).advance(-1, 'minute'), ee.Date(ms).advance(1, 'minute') ).toList(10); var subCount = sameTime.size().getInfo(); for (var i = 0; i < subCount; i++) { var img = ee.Image(sameTime.get(i)); var desc = 'S1_' + dayStr + '_' + hhmm + '_' + (i + 1); Export.image.toDrive({ image: img.select(['VV', 'VH']).clip(roi), description: desc, folder: 'S1_batch', region: roi, scale: 10, crs: 'EPSG:32650', maxPixels: 1e10, fileFormat: 'GeoTIFF' }); } });

逻辑说明:getInfo()是同步调用,把服务器端的时间戳数组取回客户端变成普通JS数组,后面才能用forEach循环。每个时间戳对应一次过境,同一秒内过境的影像用filterDate配正负一分钟窗口全部捞出来,避免丢失同轨相邻条带。desc里带上日期、时刻和序号,保证任务描述唯一——任务描述重复会直接提交失败,这是批量场景里最常见的低级错误。循环里每次toList(10)最多处理10条条带,对绝大多数陆地场景够用。

3.3 任务提交后去哪看:Tasks面板与任务状态轮询

批量任务提交后,右侧Tasks面板会依次出现READY、RUNNING、COMPLETED、FAILED四种状态。READY表示在排队等配额,不是出错;RUNNING超过一两个小时就要警惕。几百个任务时逐个盯面板不现实,我用代码把最近任务的状态拉出来核对:

// 列出最近100个任务的id、描述与状态,复制到表格里做清单 ee.data.getTaskList().slice(0, 100).forEach(function(t) { print(t.id, t.state, t.description); });

参数说明:t.id是任务的唯一编号,t.description就是提交时写的描述名,后面断点续传时要拿它跟本地文件名对账。复制控制台输出的这些描述名存成文本,就是你的导出清单。

4. 影响批量导出成败的四个参数:分辨率、投影、maxPixels与分块

批量导出失败,绝大多数不是代码逻辑问题,而是参数组合不对。四个参数最值得花时间调:crs、crsTransform、maxPixels、fileDimensions。

4.1 crs与crsTransform:为什么“10米”在WGS84下会导出失败

GEE里的scale不是绝对分辨率,它表示输出CRS下的像元尺寸。默认CRS是EPSG:4326,单位是度,这时scale: 10代表10度,差不多1110公里,GEE会尝试把影像重采样成这种巨像元,然后区域里的像素数反而少得反常,任务要么报像素超限要么报重采样失败。正确做法是给输出指定投影坐标系。

// 根据研究区中心点经度估算UTM分区号,拼出crs var lon = roi.centroid().coordinates().get(0).getInfo(); var zone = Math.floor((lon + 180) / 6) + 1; var crsStr = 'EPSG:326' + (zone < 10 ? '0' + zone : '' + zone); print('建议输出投影:', crsStr);

参数说明:UTM分区号等于经度加180除以6取整再加1。北半球用326开头,南半球用327开头,例如南纬区域是EPSG:32750。这段代码在批量循环外面跑一次,把crs填进每个导出任务。如果你的结果要跟其他栅格严格对齐,可以用crsTransform锁定网格原点,格式是[x分辨率, x剪切, y剪切, y分辨率, 原点x, 原点y],其中y分辨率为负数表示影像自上而下排列,比如[10, 0, 0, -10, 466960, 3500520]。日常批量下载不强制用,但如果你是做时序栈叠加,建议固定它,省得每期影像网格原点漂移。

4.2 maxPixels与fileDimensions:大区域导出的两条出路

maxPixels默认是1e8,单景IW条带范围裁剪后通常够用,但如果你把多景mosaic成一张大图再导出,像素数轻松破亿。我的建议是:能不加maxPixels就不加,优先改导出策略。

参数默认行为建议值 / 使用场景
scale取影像原始分辨率与crs配套使用,别单独改
crsEPSG:4326按UTM分区指定,如EPSG:32650
crsTransform由crs和scale推导多期影像网格对齐时用
maxPixels1e8大区域提到1e9到1e10,再高容易撑爆worker内存
fileDimensions整幅输出设成[10240, 10240]切块,适合超大区域

大面积导出我一般不做mosaic,而是每景单独导出,让每个任务的像素数可控。SAR影像不同条带之间有辐射差异,mosaic还会在条带重叠区引入拼接痕,后续分析反而要再处理。fileDimensions适合你确实需要一张完整大图的场景,输出会自动加_0_0这种分块后缀,下载后本地用栅格工具合并。

4.3 极化选择:集合过滤只负责“有没有”,select负责“下哪些”

集合里过滤了VV,不代表每景都有VH。IW模式下绝大多数GRD产品是VV+VH双极化,但单极化场景和覆盖异常的场景确实存在。如果select(['VV','VH'])里有一个波段在某景中不存在,整个导出任务直接失败。保险做法是把两个极化都写进集合过滤:

// 保险做法:VV和VH都要求存在,select才不会中途报错 var s1_dp = s1 .filter(ee.Filter.listContains('transmitterReceiverPolarisation', 'VV')) .filter(ee.Filter.listContains('transmitterReceiverPolarisation', 'VH'));

参数说明:两条listContains是“并且”关系,最终集合里的每一景都同时含VV和VH波段,select可以放心用。如果你只要VV做单极化时序,也要保持只过滤VV、只select VV,别顺手把VH带上。另外注意:海洋模式EW下的GRD产品极化组合是HH+HV,不是VV+VH。要下海洋区域时,把instrumentMode换成EW,极化过滤同步换成HH或HV,这一条经常有人踩。

5. 批量下载Sentinel-1的常见坑与排查:从任务失败到数据断档

批量任务跑起来以后,问题不会只出现在代码阶段。这里整理四个高频场景,全部按“现象、原因、解决”来写,能直接对着排查。

5.1 任务在队列里报错:先看这张错误特征对照表

Tasks面板里FAILED任务的错误信息要主动点开看,不要只看状态。常见的报错特征和处理路径如下:

错误特征(Tasks面板摘要)常见原因第一时间做什么
Band 'VH' does not existselect里写了某些景不存在的波段打印bandNames核对,极化条件补进集合过滤
Computed image has too many pixels像素数超过maxPixels提高maxPixels或按fileDimensions切块
Export region must be a Geometryregion传了FeatureCollection调用.geometry(),或在Map手动画几何
Invalid region / Geometry has too many edges研究区边界过细或自相交用simplify(maxError)简化后再导出
Project has too many concurrent exports同时提交的任务超过项目配额分批提交,每批控制在50个以内

我踩得最多的是第二条和第三条。第三条的隐蔽之处在于,从GIS软件导出的矢量在GEE里默认是FeatureCollection,直接塞进region参数必报错;解决就是在导入时first().geometry()取几何体。第一条的办法则在4.3已经写好,把VH也过滤进去,从源头掐断。

5.2 大面积区域导出“超限”或者任务永远Running

现象:任务状态长时间停在RUNNING,或者提交后立刻FAILED,错误里带pixels或memory字样。

原因:区域面积大又用了mosaic,单任务像素数爆炸;或者一次提交了几百个任务,把项目的并发预算和小伙伴共用资源一起挤爆。

解决:先按条带导出,不做mosaic,把每个任务的区域收敛到单景覆盖范围;再不行就fileDimensions: [10240, 10240]切块,单块像素量立减。maxPixels最多加到1e10就收手,再往上加容易把执行节点内存打爆,任务反而挂掉。

注意:同一个项目里多个人同时放任务,共享并发配额。放批量任务前跟同项目的人打声招呼,避免互相踢掉对方的RUNNING任务,这种事发生过不止一次。

5.3 云端硬盘里下载一半断掉:是重下,不是重导出

现象:浏览器下载大GeoTIFF到一半断掉,第一反应是回GEE重新跑任务。

原因:任务其实早就COMPLETED了,GeoTIFF完整躺在云端硬盘里,断的是浏览器下载通道,不是导出链路。重新导出既烧配额又浪费时间。

解决:不要重跑任务。下载这一步改用支持断点续传的方式:挂同步客户端,或者写脚本调Drive API分块拉取,都比浏览器稳。批量几百个文件时尤其明显,浏览器逐个另存为的操作量不现实。下载完成后跟任务清单对账,缺哪个补哪个,对账脚本见第6章。

5.4 时序断档与边缘覆盖不足:升轨降轨分开存

现象:小区域一年只导出十几景,相邻日期间隔超过两周;或者部分导出图有一半区域是NoData。

原因:单轨道重访周期本身就长,加上研究区靠近条带边缘时,同一景影像只覆盖研究区一部分,硬导出就会得到半幅黑图。混用升轨降轨虽然能加密时间序列,但两套几何的亮度不可直接比。

解决:升轨和降轨都导,但按轨道方向分目录存,分析时再做配准和归一化。对边缘覆盖不足的景,加一个覆盖率过滤,覆盖少于八成的直接跳过,别把NoData大片带进时序:

// 覆盖率过滤:相交面积占比低于0.8就跳过这景 var covRatio = img.geometry().intersection(roi, 1).area().divide(roi.area()); if (covRatio.getInfo() > 0.8) { Export.image.toDrive({ image: img.select('VV'), description: desc, folder: 'S1_pass', region: roi, scale: 10, crs: crsStr, maxPixels: 1e10 }); }

逻辑说明:intersection(roi, 1)的第二个参数是误差容差,单位米,1米足够;面积比等于相交面积除以研究区面积。getInfo()拿到数值后跟阈值比较,是客户端分支,脚本里很好用,只是循环里每景多一次网络往返,几十景规模完全能接受。

6. 进阶:用文件名指纹做断点续传,重跑不再从头再来

最后这套东西是我被整批重跑教训过之后总结出来的。批量下载的痛点是:文件缺几个,你往往要翻半天才知道缺谁,最后干脆全部重跑。我的习惯是把任务描述直接定为“文件指纹”,让对账变成一条命令的事。

指纹用影像的system:index,这是GEE里每景的唯一标识,结构类似S1A_IW_GRDH_1SDV_20230615T101230_20230615T101255_043791_0530EC_2F19,包含卫星、模式、时间、轨道和产品编号,全程只有字母数字和下划线,天然适合当文件名。提交任务前清洗一遍,去掉意外出现的非法字符:

// 拿系统唯一索引当任务描述,清洗后做文件名指纹 var rawIndex = img.get('system:index').getInfo(); var desc = rawIndex.replace(/[^a-zA-Z0-9_\-]/g, '_');

然后按四步走:第一步,批量任务提交后,从Tasks面板把任务列表导出成CSV,或用3.3的代码打印描述名存成文本;第二步,云端硬盘的全部文件同步到本地,一个目录对应一个folder;第三步,跑下面的对账脚本,列出本地缺失的描述名;第四步,只对缺失清单重新提交导出,已经完成的景完全不动。

import os, re, csv, sys local_dir = sys.argv[1] # 本地存放GeoTIFF的目录 manifest = sys.argv[2] # 从GEE导出的任务CSV have = set() for f in os.listdir(local_dir): m = re.search(r'(S1[AB]_[A-Z0-9_]+)', f) if m: have.add(m.group(1)) need = [] with open(manifest, encoding='utf-8') as fp: for row in csv.reader(fp): for token in ",".join(row).split(): if re.match(r'^(S1[AB]_[A-Z0-9_]+)$', token) and token not in have: need.append(token) print("本地已有:", len(have), "缺失:", len(need)) for t in sorted(set(need))[:100]: print(t)

说明:re.search从文件名里提取指纹,re.match严格校验CSV里的描述名。因为指纹就是system:index清洗后的结果,本地文件名、任务CSV、GEE影像三方能直接对上,不需要任何人工翻译。重跑前如果要清理还在排队或运行的旧任务,可以用ee.data.cancelTask(t.id)逐个取消,但这段代码会停掉所有任务,只在你确认要重来时用。

这整套流程我现在每次放批量任务前都会走一遍:建目录、存CSV、同步、对账,缺哪景补哪景,再没为一次断网重跑整批。指纹命名的好处是长期积累后目录里全是规律文件名,别人接手也看得懂。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

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