用Python批量下载Sentinel-2数据,听起来像是个一次性的体力活,但真正动手你会发现,里面全是细节坑。
我最早入遥感这行时,下影像全靠网页端手动点,一个区域十几景景点半小时,人麻了不说,还经常点错传感器、点错日期。后来开始写自动化脚本,才终于把精力从“下载”挪回“处理”本身。这篇东西就围绕“Python批量下载Sentinel-2”这件事,把我从账号申请、接口认证、查询过滤到最后落盘整理踩过的坑,一条条捋清楚。2024年如果你还在用老掉牙的SciHub接口,大概率已经疯狂报错,现在的正确路子是Copernicus Data Space Ecosystem的新一代接口。文章适合三类人:只下载过零星几景、被网页端折磨过的遥感从业者,Python刚入门想拿真实数据练手的同学,以及需要做长时间序列分析的科研党。跟着下面的步骤走,你能在半小时内把“搜索—筛选—批量下载—自动重命名”这条流水线跑通。
1. Sentinel-2数据集与批量下载的前置认知
1.1 Sentinel-2到底好在哪,批量下载解决的是什么问题
Sentinel-2是欧洲哥白尼计划里的光学遥感卫星星座,目前主力是2A和2B两颗卫星,两颗组网之后,赤道地区的重访周期能压到5天左右,中纬度地区甚至更短。它搭载MSI多光谱成像仪,覆盖13个波段,从可见光、近红外到短波红外一应俱全,10米、20米、60米三档空间分辨率。做植被指数、水体提取、土地利用分类,它都算得上“性价比最高的开源光学数据”。
但数据好拿不代表好下。你一旦需要某个区域连续几个月、甚至几年晴空影像,一个点少说几十景,一个县、一个流域可能就是上百景。这时候如果还在网页端一个个翻,慢慢点下载,基本等于自残。批量下载的核心价值就是把“人工翻找”变成“脚本筛选”:你只管告诉程序经纬度范围、时间区间、最大云量,剩下的检索、择优、拉文件、重命名全部自动化。这个过程本身也是遥感工作流里极少被写在论文里、但实际非常影响效率的一环。
1.2 2024年后的数据源:不要再死磕SciHub老接口
很多老教程写的是用sentinelsat连https://scihub.copernicus.eu/apihub/,用api.umd.edu之类的地方还有一个镜像,但2024年的现实是,欧空局已经把主要数据分发重心迁到了Copernicus Data Space Ecosystem(简称CDSE)。老的SciHub接口虽然部分还在,但稳定性、查询效率和功能丰富度都明显落后,新用户注册入口基本都被导向CDSE。
CDSE提供的核心能力可以分成几块:一是OData v1目录服务,用来做产品检索;二是批量下载接口,基于S3和HTTP两种方式;三是处理服务,可以上传自己的处理算法,让数据在云端帮你跑完再下载;四是一套类似Jupyter的云端环境。对我们批量下载来说,主要打交道的是OData目录和下载接口。
这里有个重要认知:CDSE的OData接口返回的不是栅格文件本身,而是产品元数据,包括S2A/S2B标识、采集时间、轨道号、云量、产品等级、空间范围等。真正的下载URL要通过产品ID再请求一次才能拿到。整套流程用一句话概括:先认证拿到Token,再查目录拿到产品列表,最后拿着产品ID去拉实体文件。理解了这三个阶段,后面写代码就有了明确骨架,不会东一榔头西一棒子。
2. 动手前的环境准备:Python配置与账号申请
2.1 搭一个干净可复现的Python环境
下载脚本虽然可以跑在任意Python版本上,但为了少碰编码和依赖的兼容性坑,我推荐直接用Python 3.9以上的版本。如果你是纯新手,安装Python本身就有讲究:Windows用户装官方安装包时,第一个界面上那个“Add Python to PATH”必须勾上,否则后面在命令行里敲python会提示“不是内部或外部命令”。macOS用户建议用Homebrew安装,尽量避免直接用系统自带的2.7时代残留。
装完之后,务必建一个虚拟环境再开始装包。虚拟环境的好处是让每个项目的依赖互不污染,尤其在遥感处理环境里,GDAL这类库对版本极度敏感,如果和别的项目混在一个环境里,迟早出现“明明我安了rasterio但import就报错”的玄学问题。创建命令很简单:
python -m venv sentinel_env source sentinel_env/bin/activate # Windows下命令是 sentinel_env\Scripts\activate如果你是VSCode用户,顺手再提一句:装好Python插件后,Ctrl+Shift+P调出命令面板,选择“Python: Select Interpreter”,指向你刚建的虚拟环境,这样Jupyter和debugger用的也是同一套依赖。配置好环境之后,后面所有代码都在这套虚拟环境里操作,省心非常多。
2.2 注册Copernicus Data Space账号并创建OAuth凭据
这是整个批量下载流程里最容易被跳过的步骤,网上很多代码示例默认你已经有账号,实际上新用户连这一步都容易卡住。
打开Copernicus Data Space的官网注册页面,用邮箱注册一个账号。注意这里推荐用机构邮箱或长期稳定邮箱,因为后续无论是申请API客户端权限还是接收服务通知,都比较依赖邮箱。注册后会收到验证邮件,点完链接才算激活。
账号激活后,进入Dashboard里的“User Settings”或“API Interface”区域,找到创建OAuth2客户端的入口。你需要创建两个信息:Client ID和Client Secret。这个组合相当于你脚本的“专属钥匙”,用来换取访问令牌。创建时一般会要求填写重定向URL,本地命令行脚本用的话填http://localhost就可以。
拿到Client ID和Secret之后,保存到一个不会被Git仓库跟踪的配置文件里,比如.env,或者干脆模板化到脚本顶部的变量里。这里没有绝对标准的目录结构,但强烈建议不要硬编码到公共代码里,万一不小心提交到Github,等于把数据源凭证裸奔了。
2.3 依赖安装清单:为什么要少而精
批量下载本身不需要太重型的库,我的建议是“先轻后重”:缺什么再补什么,没必要为了一个几十KB的脚本先把geopandas、rasterio、xarray全装上。
核心依赖其实就这几个:
pip install requests tqdm pyyamlrequests负责HTTP请求,tqdm用来显示进度条,pyyaml主要用来解析配置文件。如果你后面想直接在Jupyter里跑,可以再加装一个ipykernel。不需要装sentinelhub,也不建议用旧的sentinelsat,因为底层接口变化之后,老库维护状态本身就很拧巴,踩坑时你很难判断是自己参数写错了还是库匹配不上新接口。直接基于OData v1接口配requests手写,逻辑透明,出了问题也能一眼定位。
如果你确实想看官方封装工具,可以关注CDSE文档里给出的代码示例,但也是以纯requests为主。所以这篇指南的全部代码,只依赖上面几个包,保证你能在任意一台联网电脑上复现。
3. 核心实现:从Token获取到文件落盘
3.1 第一步:用OAuth2换取访问令牌
CDSE的OData服务采用OAuth2认证,常用的是client_credentials授权模式。简单理解,就是用上一步创建的Client ID和Client Secret,去认证服务器换一张“临时通行证”——Token。这张通行证通常有效期为几分钟到一小时不等,过期后再重新换一张即可。
代码实现非常短:
import requests CLIENT_ID = "你的ClientID" CLIENT_SECRET = "你的ClientSecret" TOKEN_URL = "https://identity.dataspace.copernicus.eu/auth/realms/CDSE/protocol/openid-connect/token" data = { "grant_type": "client_credentials", "client_id": CLIENT_ID, "client_secret": CLIENT_SECRET, } resp = requests.post(TOKEN_URL, data=data) resp.raise_for_status() token = resp.json()["access_token"] print(token[:20], "...")看到这里你可能会问:为什么要这么麻烦?直接带账号密码下载不行吗?答案是:OAuth2令牌比账号密码更安全,它限定了作用域和有效期,而且脚本只需要在内存里持有令牌,不需要反复传输密码。对我们这种批量任务来说,只要在脚本里封装一个get_token()函数,后面每个请求都带上Token头就行。
这个POST请求如果报错,最常见的可能是Invalid client credentials。别急着怀疑网络,先检查Client ID和Secret有没有复制对,尤其是Secret里那些_、-之类的字符,手敲特别容易漏。
3.2 第二步:用ODATA查询筛选产品列表
拿到Token后,就可以请求OData目录接口了。核心请求URL是:
https://catalogue.dataspace.copernicus.eu/odata/v1/Products?后面的查询参数,是整个筛选流程的精华。我举个例子:下载2024年3月1日到3月15日之间、位于某个矩形区域、云量低于20%、等级为L2A的Sentinel-2影像。
对应的请求参数大致长这样:
import requests from datetime import datetime token = "你刚拿到的Token" base_url = "https://catalogue.dataspace.copernicus.eu/odata/v1/Products" params = { "$filter": ( "Collection/Name eq 'SENTINEL-2' and " "ContentDate/Start gt 2024-03-01T00:00:00.000Z and " "ContentDate/Start lt 2024-03-15T00:00:00.000Z and " "OData.CSC.Intersects(area=geography'SRID=4326;POLYGON((116.0 39.0,117.0 39.0,117.0 40.0,116.0 40.0,116.0 39.0))') and " "Attributes/OData.CSC.StringAttribute/any(att: att/Name eq 'cloudCover' and att/OData.CSC.IntegerAttribute/Value lt 20) and " "Attributes/OData.CSC.StringAttribute/any(att: att/Name eq 'productType' and att/OData.CSC.StringAttribute/Value eq 'L2A')" ), "$orderby": "ContentDate/Start desc", "$top": 10, "$count": True, } headers = {"Authorization": f"Bearer {token}"} resp = requests.get(base_url, params=params, headers=headers) resp.raise_for_status() results = resp.json() print("符合条件的总数:", results["@odata.count"]) for item in results["value"]: print(item["Name"], item["Id"])这段代码里需要特别留意的几个点:
一是Collection/Name eq 'SENTINEL-2'是筛数据集的固定写法,别和语焉不详的ProductType混了。二是时间条件用的是ContentDate/Start字段,而不是IngestionDate——很多人习惯看采集时间,如果用错字段,拿到的是“数据上传时间”符合条件的产品,日期对不上非常坑。三是空间范围用的OData.CSC.Intersects加POLYGON,坐标顺序是经度纬度,不要写成纬度经度。四是云量过滤,我为了省事直接引用了cloudCover属性,但你也可以更严谨地对Attributes数组做any(att: ...)。
筛选完返回的内容里,每个产品都会带上Id和Name。Name类似这样:S2B_MSIL2A_20240301T024611_N0510_R016_T50TLE_20240301T070045.SAFE。这个字符串里包含了卫星编号、采集时间、轨道号、瓦片号等信息,非常有用,后面重命名阶段会用到。
3.3 第三步:拿到产品ID,拼出真实下载地址,实现断点续传
查询接口拿到的是产品元数据,不是文件本身。每个产品对应一个下载URL,拼法很规整:
https://download.dataspace.copernicus.eu/odata/v1/Products({产品ID})/$value所以下载一个产品的逻辑就是:先按ID拼URL,再带Token发起GET请求,把返回流写到本地文件。这里头最大的坑是网络中断。一个Sentinel-2产品打包压缩后通常有几百MB到1GB不等,中国网络环境下偶尔断个一两次非常正常,一旦全量重下,既浪费时间又可能触发服务端的频率限制。
所以“断点续传”不是锦上添花,而是必须。好在HTTP协议本身就支持Range头,我们可以只请求文件的某一段字节。实现思路是这样:如果本地已经存在部分文件,就读取它当前大小作为起始偏移,只下载剩余部分,然后以追加模式写入。
一个简化版的核心下载函数:
def download_product(product_id: str, save_path: str, token: str): url = f"https://download.dataspace.copernicus.eu/odata/v1/Products({product_id})/$value" headers = {"Authorization": f"Bearer {token}"} # 读取已下载大小 offset = 0 import os if os.path.exists(save_path): offset = os.path.getsize(save_path) headers["Range"] = f"bytes={offset}-" with requests.get(url, headers=headers, stream=True, timeout=30) as r: r.raise_for_status() mode = "ab" if offset else "wb" with open(save_path, mode) as f: for chunk in r.iter_content(chunk_size=1024 * 1024): f.write(chunk)这里有几个细节值得解释。如果把Range头设置成bytes=0-,服务器会直接从头传,所以已经下载过一部分时需要把偏移量设为文件大小。还要注意响应状态码:如果服务器支持分段,返回206;如果不支持,可能直接返回200,那样就必须重新全量下一次。另外,stream=True配合iter_content能避免一次性把1GB数据读入内存,否则内存直接被撑爆。
真的追求速度,还可以在单个产品内部做多线程分段下载,也就是把文件切成N段,每段一个请求,最后按顺序合并。但对大多数应用场景而言,逐产品顺序下载、配合断点续传已经足够。如果你下载量特别大,我更推荐用并发池同时下载多个不同产品,因为绝大多数瓶颈在IO和网络带宽,而不是单包下载速度。
3.4 第四步:批量下载后自动规整文件名和目录
下载完成后,你手上会是一堆以产品ID命名的.zip文件,比如S2B_3a6d0e0b-....zip。这种名字对后续处理非常不友好,因为你根本不知道它是哪天的影像、覆盖哪个瓦片。所以最好用产品自带的Name字段来重命名文件,或者维护一张映射表。
如果你已经通过查询拿到了每个产品的Name和Id,重命名逻辑很简单:
- 用
Id作为下载时的中间文件名; - 下载成功后,读取对应的
Name,重命名为Name + ".zip"; - 最后按“日期_瓦片号”的格式再做一层整理。
举个例子,把S2B_MSIL2A_20240301T024611_N0510_R016_T50TLE_20240301T070045.SAFE.zip解析成20240301_T50TLE_L2A.zip,在Linux/macOS上可以写一个简单的bash脚本,在Windows上也可以用bat脚本批量改名。很多刚学Python的朋友会问“批量改照片名称bat脚本下载”这类问题,其实核心逻辑就一行rename或os.rename,只是坑在文件名里可能包含空格、括号等特殊字符,所有重命名的代码都建议加一层安全的字符白名单过滤。
我自己习惯保留原始Name目录名,因为后续做多时相分析时,完整产品名里包含了采集时间和处理基线版本,这些都是重要元数据。处理流程里我一般会建立一个manifest.csv,记录原始文件名、新文件名、云量、采集时间和产品ID,相当于给每个影像建立一份档案。
4. 避坑清单:这些问题都是真实踩过才知道的
4.1 认证与权限类报错
批量下载最常见的拦路虎就是401和403。401表示Token无效或缺失,常见原因有几个:Token过期了、请求头格式写错、复制时多了一个空格。403则通常是权限不足,比如账号权限没开到API下载,或者服务端对某些数据级别的访问做了限制。
另外一个很容易被忽略的坑是OAuth2的Token URL拼写。CDSE的认证地址在不同时期可能略有差异,如果你是从老教程里复制的URL,很可能已经失效,请以官方文档最新的identity.dataspace.copernicus.eu地址为准。遇到401,第一步不要怀疑代码逻辑,先去浏览器的开发者工具里手动请求一次Token,确认你手上的Client ID和Secret真的能换到Token,再接回脚本。
4.2 查询结果少了或多了,问题出在筛选条件
很多人在查询时发现“明明有数据,为什么查不到”。回忆一下上面提到的几个字段:ContentDate/Start gt 2024-03-01T00:00:00.000Z,这个边界条件很敏感。如果你需要包含3月1日当天,建议把查询条件写成gt 2024-03-01T00:00:00.000Z配合lt 2024-03-02T00:00:00.000Z这种左闭右开形式,或者干脆加一天,避免时区换算导致少一天。
还有一个很容易出的问题是经纬度多边形边界。OData的POLYGON((116.0 39.0,117.0 39.0,117.0 40.0,116.0 40.0,116.0 39.0)),这个多边形的顶点顺序不能随意乱填,它要求首尾闭合,而且通常采用逆时针或顺时针规则。如果坐标反了或者漏了最后一个顶点,接口要么报错要么返回空结果。
如果想严格匹配某景影像的Tile编号,建议直接用Name字段模糊查询,比如contains(Name,'T50TLE'),这样能精确筛出指定瓦片号,也能有效避免重复下载。
4.3 下载慢、断了之后疯狂反复重试
批量下载最让人抓狂的不是不能下,而是下到一半断了,然后脚本原地报错退出,你重新跑又从头开始。所以下载模块必须写得“皮实”:每个产品下载尽可能支持断点续传;对网络抖动要有重试机制,比如连续失败3次才放弃;同时打印清晰的日志,方便定位是哪个产品出了问题。
再提醒一个常被忽略的点:大规模下载时,服务端有并发和频率限制。如果你用concurrent.futures.ThreadPoolExecutor一开就20个线程狂拉,容易被限流,甚至账号被封禁一会儿。建议先把并发数控制在3到5个,再根据网络和服务端反馈微调。
4.4 版本与产品等级混淆:L1C还是L2A
Sentinel-2产品有两个常见级别:L1C是经过正射校正的大气表观反射率产品,L2A是大气校正后的地表反射率产品。很多初学者直接下载L1C,后面做NDVI还得自己跑Sen2Cor做大气校正,多走一大圈。如果你只是做常规陆面分析,建议直接下载L2A,省时省力。
另外一个和版本相关的坑是处理基线号,比如N0510这种字段,它代表处理器的软件版本。处理基线不同,数据细节可能有细微差异。做长时间序列分析时,尽量选择处理基线一致的影像,或者在结果里保留该字段以便后续校正。
5. 下载完之后:批量解压、裁剪与基础预处理
5.1 确认文件完整性:别急着解压
从CDSE下载的.zip文件,大多数情况下是完好的,但网络传输中偶尔也会出现截断或字节损坏。一次批量下载结束后,推荐先做一轮完整性检查,而不是直接扔给解压工具。做法很简单:解压前先看文件大小是否和查询接口返回的ContentLength一致,不一致的直接用断点续传补下或剔除以重新下载。
解压后的.SAFE目录里,主要东西包括:IMG_DATA下的各波段栅格、metadata.xml元数据文件、granule目录下的辅助文件。不建议把所有产品全部解压到同一个目录,因为不同瓦片、不同日期的文件名可能完全相同,直接覆盖会丢数据。按“日期_瓦片号”建二级目录是比较稳妥的组织方式。
5.2 用GDAL/rasterio做批量裁剪与云掩膜
下载只是第一步,多数场景下你还需要把影像裁剪到研究区范围,并且把有云的地方标出来。讲到这里,就绕不开“sentinel-2遥感影像预处理流程”这个关键词。
预处理流程大致长这样:解压→读取栅格→重新投影/裁剪到目标矢量范围→生成云掩膜→(可选)计算指数或合成影像。其中最常用的是GDAL的gdal.Warp命令,一条命令就能完成投影转换加裁剪:
gdalwarp -cutline study_area.shp -crop_to_cutline -dstalpha input.tif output.tifPython里用rasterio写同样的事情也顺手:
import rasterio from rasterio.mask import mask import geopandas as gpd with rasterio.open("input_B04_10m.tif") as src: shapes = gpd.read_file("study_area.shp").geometry out_image, out_transform = mask(src, shapes, crop=True)关于云掩膜,很多人在预处理阶段会卡很久。官方提供的S2-L2A产品自带场景分类图层(SCL),其中第3类是云阴影,第8类是云,第9类是卷云。你可以读取SCL波段,把第8、9类设为掩膜,并统计云量占比,然后进一步筛选可用影像。不要相信产品名后缀里的CLOUDY_PIXEL_PERCENTAGE完全为零,那个字段表示的是整景云量平均评估,局部区域云量差异很大,真正要紧的是你研究区内的云覆盖情况。
5.3 组织输出结构:让多时相分析不再手忙脚乱
我处理完一批下载数据后,常用的目录结构大概是这样:
study/ ├── downloads/ │ ├── manifest.csv │ └── zips/ ├── safe_dirs/ │ ├── 20240301_T50TLE_L2A/ │ └── 20240306_T50TLE_L2A/ ├── subset/ │ ├── 20240301_T50TLE_B04_10m.tif │ └── 20240306_T50TLE_B04_10m.tif └── cloud_masks/建完这套结构后,后面做NDVI时间序列、影像拼接、变化检测,基本都能以“日期_波段”作为索引直接读到文件,不需要再回到原始压缩包里去翻。别小看这一步,我在项目里见过太多人把所有tif扔在一个文件夹里,文件名还是output(1).tif这种,最后自己想排查都分不清哪个是哪个。自动化下载的价值,恰恰要从数据组织开始才能放大。
如果你愿意再往前走一步,还可以把整个下载与预处理流程串成自动化任务,比如每周定时检查是否有新的晴空影像,有就自动下载并更新NDVI产品。这块后续可以单独展开,但基础架构就是你前面看到的认证、查询、下载、重命名和GDAL处理这几段代码的叠加。
说一下我个人的使用体会:批量下载Sentinel-2数据的代码逻辑并不复杂,真正让人浪费时间的是对接口机制不够熟悉,以及各种环境组件版本错配。先把账号、虚拟环境、Token验证这几个前置条件跑通,后面所有脚本都只是围绕它们做组合。2024年这个时间节点,CDSE的新接口已经足够稳定,早期那些demo接口的网络抖动也在逐步改善,早期踩过的很多坑现在都有更明确的对策了。如果你下载量不大,完全可以先按文章里的代码跑通一景,再把循环和并发加上去;如果一上来就追求全量自动化,反而容易被并发限制、权限设置这些细节绕晕。稳扎稳打,把流程走顺,之后再扩到千景级别,就是水到渠成的事。