用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插件后CtrlShiftP调出命令面板选择“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, datadata) 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(areageographySRID4326;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: fBearer {token}} resp requests.get(base_url, paramsparams, headersheaders) 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 fhttps://download.dataspace.copernicus.eu/odata/v1/Products({product_id})/$value headers {Authorization: fBearer {token}} # 读取已下载大小 offset 0 import os if os.path.exists(save_path): offset os.path.getsize(save_path) headers[Range] fbytes{offset}- with requests.get(url, headersheaders, streamTrue, timeout30) 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_size1024 * 1024): f.write(chunk)这里有几个细节值得解释。如果把Range头设置成bytes0-服务器会直接从头传所以已经下载过一部分时需要把偏移量设为文件大小。还要注意响应状态码如果服务器支持分段返回206如果不支持可能直接返回200那样就必须重新全量下一次。另外streamTrue配合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还是L2ASentinel-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, cropTrue)关于云掩膜很多人在预处理阶段会卡很久。官方提供的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接口的网络抖动也在逐步改善早期踩过的很多坑现在都有更明确的对策了。如果你下载量不大完全可以先按文章里的代码跑通一景再把循环和并发加上去如果一上来就追求全量自动化反而容易被并发限制、权限设置这些细节绕晕。稳扎稳打把流程走顺之后再扩到千景级别就是水到渠成的事。