乐于分享
好东西不私藏

批量下载MODIS遥感影像数据的最快捷方法

批量下载MODIS遥感影像数据的最快捷方法

本文介绍基于脚本,快速、批量下载 Earthdata 中遥感影像数据的方法。

最近,需要下载 MODIS 的 GPP 数据,时间跨度从 2000 年到 2024 年,时间分辨率为 8 天,覆盖全球陆地范围。数据来源选择的是 NASA LP DAAC 提供的 MOD17A2HGF v6.1 产品——这是 MOD17A2H 的 Gap-Filled(间隙填充)版本,在年末阶段对 FPAR/LAI 输入质量较差的像元进行了清洁处理,能有效消除云污染导致的伪异常,适合做长时序分析。

之前的几篇文章中,我们多次介绍过不同的遥感影像批量下载方法,包括基于浏览器插件、本地下载器、谷歌地球引擎GEE平台等;但是,一直都没介绍过基于脚本的下载方法——而基于脚本下载,可能反而是最简单、最快捷的方法。

因此,这篇文章记录一下用 Python 批量下载这批数据的思路和完整代码——只要是需要批量下载 Earthdata 数据的,都可以参考本文思路。大家可以直接将本文发给 Agent,让 AI 一键部署本文所需的环境与脚本,真的就是点点鼠标就能批量下载了。


数据概况

本文以 MOD17A2HGF 数据为例来介绍(但 Earthdata 中的其他数据都可以用本文的方法)。MOD17A2HGF 是 Terra 卫星 MODIS 传感器生产的全球陆地总初级生产力(GPP)产品。

这一产品的主要参数如下:

项目
内容
产品名
MOD17A2HGF v6.1
时间分辨率
8 天合成
空间分辨率
500 m(原生分辨率)
数据格式
HDF4(.hdf)
空间覆盖
全球陆地,约 286~326 个 MODIS 瓦片/周期
数据时段
2000-02-18 至今
下载来源
NASA LP DAAC(earthaccess API)

全球完整下载一套(2000-2024)大约需要 1136 个 8 天周期,原始 HDF 文件总量约 1000 GB 左右(实际下载完毕后,发现数据量其实远超这个数值)。单靠手动从网页点击下载显然不现实,所以用脚本来做。


环境准备

主要依赖两个库:

pip install earthaccess tqdm

earthaccess 是 NASA 官方出品的 Python 库,专门用于搜索和下载 Earthdata(包括 MODIS 在内的所有 NASA 数据)。tqdm 用于显示下载进度条。

另外需要注册一个 NASA Earthdata 账号,地址是 https://urs.earthdata.nasa.gov,注册完成后在账号页面给 LP DAAC Data Pool 这个应用授权,否则下载会报 401 错误。

认证方面,推荐使用 .netrc 文件方式,在 Windows 上对应的文件路径是 C:\Users\<用户名>\_netrc,内容格式如下:

machine urs.earthdata.nasa.govlogin 你的用户名password 你的密码

这种方式最稳定,不依赖 Token API,在有代理软件的 Windows 环境下也能正常工作。如果系统装了 Clash、V2Ray 等工具,即使其是”关闭”状态,Windows 系统代理设置有时仍然生效,会导致 HTTPS 连接被拦截。解决方案是在脚本里显式设置 NO_PROXY="*",让 Python 的请求绕过系统代理。


代码设计思路

下载脚本的整体逻辑不复杂,核心是三步:

第一步,生成 MODIS 8 天周期列表。MODIS 的 8 天合成不是任意的 8 天,而是从每年第 1 天(1 月 1 日)开始,每 8 天一个周期,全年固定 46 个周期(最后一个周期可能不足 8 天)。所以需要先把起止日期对齐到 MODIS 的标准周期边界,再逐一生成周期列表。

第二步,按周期搜索并下载颗粒。用 earthaccess.search_data() 搜索指定时间范围内的数据颗粒(Granule),每个颗粒对应一个 MODIS 瓦片的 HDF 文件。全球范围每个周期大约有 290 个颗粒。搜索完成后,用多线程并发下载,默认 8 个线程,实测速度稳定在每个周期 3~5 分钟,全部下载完大约需要 3 天左右。

第三步,断点续传与完整性校验。下载过程中用一个 JSON 文件记录已完成的周期。每次完成一个周期后,会对比搜索到的颗粒数和本地文件数,如果下载成功率达到 85% 以上,则认为该周期完成并写入进度文件。下次运行时加 --resume 参数即可从断点继续,不会重复下载已完成的周期。

此外,每个颗粒的下载支持最多 3 次指数退避重试,下载时先写入 .tmp 临时文件,完成后重命名,防止意外中断导致的不完整文件被当作有效文件跳过。


完整代码

#!/usr/bin/env python3"""MODIS MOD17A2HGF v6.1 GPP 全球下载脚本(纯下载版)=====================================================仅从 NASA LP DAAC 下载 HDF 文件,按 8 天周期组织目录存储。下载完成后,使用本地 ArcPy 将 HDF 批量转为 GeoTIFF。使用方法:  python lpdaac_gpp_download_only.py              # 默认下载 2000-2024  python lpdaac_gpp_download_only.py --resume     # 断点续传  python lpdaac_gpp_download_only.py --workers 12 # 调整线程数  python lpdaac_gpp_download_only.py --dry-run    # 仅统计,不下载依赖:  pip install earthaccess tqdm"""import argparseimport jsonimport loggingimport osimport sysimport timefrom concurrent.futures import ThreadPoolExecutor, as_completedfrom datetime import datetime, timedeltafrom pathlib import Pathimport earthaccessfrom tqdm import tqdm# ============================================================# 日志配置# ============================================================logging.basicConfig(    level=logging.INFO,    format="%(asctime)s [%(levelname)s] %(message)s",    handlers=[logging.StreamHandler(sys.stdout)],)logger = logging.getLogger(__name__)# ============================================================# 常量# ============================================================MODIS_PRODUCT = "MOD17A2HGF"MODIS_VERSION = "061"MODIS_TERRA_START = datetime(2000218)DEFAULT_RAW_DIR = r"F:\MODIS_GPP\raw_hdf"PROGRESS_FILE   = r"F:\MODIS_GPP\gpp_download_progress.json"LOG_FILE        = r"F:\MODIS_GPP\gpp_download.log"EXPECTED_TILES_MIN = 250# 低于此数量认为下载不完整# ============================================================# 辅助函数# ============================================================defgenerate_8day_periods(start_date, end_date):"""生成 MODIS 8 天合成周期列表,对齐到标准周期边界。"""    periods = []    year = start_date.year    doy  = start_date.timetuple().tm_yday    modis_doy = ((doy - 1) // 8) * 8 + 1    current = datetime(year, 11) + timedelta(days=modis_doy - 1)if current < MODIS_TERRA_START:        current = MODIS_TERRA_STARTwhile current <= end_date:        period_end = current + timedelta(days=7)        periods.append((current, period_end))        current = period_end + timedelta(days=1)return periodsdefauthenticate():"""NASA Earthdata 认证,优先级:.netrc > 环境变量 > 交互式。    同时设置 NO_PROXY,防止 Windows 系统代理干扰 HTTPS 连接。    """    os.environ["NO_PROXY"] = "*"    os.environ["no_proxy"] = "*"for strategy in ("netrc""environment""interactive"):try:            earthaccess.login(strategy=strategy)            logger.info(f"[OK] 使用 {strategy} 认证成功")returnexcept Exception as e:            logger.debug(f"{strategy} 认证失败: {e}")    logger.error("[FAIL] 所有认证方式均失败,请检查 ~/.netrc 或环境变量配置")    sys.exit(1)defdownload_single_granule(granule, output_dir, max_retries=3):"""下载单个数据颗粒,支持重试(指数退避)。"""for attempt in range(max_retries):try:            links = granule.data_links()ifnot links:return (granule, NoneFalse)            url      = links[0]            filename = os.path.basename(url)            local_path = os.path.join(output_dir, filename)# 已存在且大小合理(> 100 KB)则直接跳过if os.path.exists(local_path) and os.path.getsize(local_path) > 102400:return (granule, local_path, True)            session  = earthaccess.get_requests_https_session()            response = session.get(url, stream=True, timeout=120)            response.raise_for_status()# 先写 .tmp,完成后重命名,防止中断产生不完整文件            temp_path = local_path + ".tmp"with open(temp_path, "wb"as f:for chunk in response.iter_content(chunk_size=8192):if chunk:                        f.write(chunk)            os.rename(temp_path, local_path)return (granule, local_path, True)except Exception as e:if attempt < max_retries - 1:                wait = 2 ** attempt                time.sleep(wait)else:                logger.warning(f"  下载失败 {os.path.basename(url)}{e}")return (granule, NoneFalse)return (granule, NoneFalse)defdownload_granules_parallel(granules, output_dir, max_workers=8):"""多线程并发下载颗粒列表。"""    os.makedirs(output_dir, exist_ok=True)    downloaded, failed = [], 0with ThreadPoolExecutor(max_workers=max_workers) as executor:        futures = {executor.submit(download_single_granule, g, output_dir): gfor g in granules}with tqdm(total=len(futures), desc="  下载瓦片", unit="瓦片",                  ncols=80, leave=Falseas pbar:for future in as_completed(futures):                granule, path, success = future.result()if success and path:                    downloaded.append(path)else:                    failed += 1                pbar.update(1)                pbar.set_postfix(ok=len(downloaded), fail=failed)    logger.info(f"  下载完成: {len(downloaded)} 成功, {failed} 失败")return downloaded, faileddefcount_hdf_files(directory):ifnot os.path.exists(directory):return0return len([f for f in os.listdir(directory) if f.lower().endswith(".hdf")])defload_progress(progress_file):if os.path.exists(progress_file):with open(progress_file, "r"as f:return json.load(f)return {"completed_periods": [], "start_time": datetime.now().isoformat()}defsave_progress(progress, progress_file):    os.makedirs(os.path.dirname(progress_file), exist_ok=True)with open(progress_file, "w"as f:        json.dump(progress, f, indent=2)# ============================================================# 主流程# ============================================================defmain():    parser = argparse.ArgumentParser(        description="MODIS MOD17A2HGF v6.1 GPP 全球 HDF 批量下载",        formatter_class=argparse.RawDescriptionHelpFormatter,    )    parser.add_argument("--start",    default="2000-02-18", help="起始日期 YYYY-MM-DD")    parser.add_argument("--end",      default="2024-12-31", help="结束日期 YYYY-MM-DD")    parser.add_argument("--raw-dir",  default=DEFAULT_RAW_DIR, help="HDF 存储目录")    parser.add_argument("--workers",  type=int, default=8, help="并行线程数(默认 8)")    parser.add_argument("--resume",   action="store_true", help="从上次中断处恢复")    parser.add_argument("--dry-run",  action="store_true", help="仅统计,不下载")    parser.add_argument("--force",    action="store_true", help="强制重新下载已完成周期")    args = parser.parse_args()    os.makedirs(args.raw_dir, exist_ok=True)    os.makedirs(os.path.dirname(LOG_FILE), exist_ok=True)    fh = logging.FileHandler(LOG_FILE, encoding="utf-8")    fh.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(message)s"))    logger.addHandler(fh)    start_date = datetime.strptime(args.start, "%Y-%m-%d")    end_date   = datetime.strptime(args.end,   "%Y-%m-%d")    logger.info("=" * 60)    logger.info("MODIS MOD17A2HGF v6.1 GPP 全球 HDF 下载(纯下载版)")    logger.info("=" * 60)    authenticate()    periods = generate_8day_periods(start_date, end_date)    total_gb = len(periods) * 290 * 2.2 / 1024    logger.info(f"总 8 天周期数: {len(periods)}")    logger.info(f"时间范围: {periods[0][0]:%Y-%m-%d} ~ {periods[-1][1]:%Y-%m-%d}")    logger.info(f"并行线程数: {args.workers}")    logger.info(f"预估总量: ~{total_gb:.0f} GB")if args.dry_run:        completed = sum(1for ps, _ in periodsif count_hdf_files(os.path.join(args.raw_dir, ps.strftime("%Y-%m-%d")))               >= EXPECTED_TILES_MIN        )        logger.info(f"本地已有完整周期: {completed}/{len(periods)}")return    progress  = load_progress(PROGRESS_FILE) if args.resume else {"completed_periods": [], "start_time": datetime.now().isoformat()    }    completed = set(progress.get("completed_periods", []))if completed and args.resume:        logger.info(f"恢复模式: 已完成 {len(completed)}/{len(periods)} 个周期")    total_downloaded, total_failed = 00    start_time = time.time()for i, (period_start, period_end) in enumerate(periods):        period_key = period_start.strftime("%Y-%m-%d")        period_dir = os.path.join(args.raw_dir, period_key)ifnot args.force and (period_key in completed or                count_hdf_files(period_dir) >= EXPECTED_TILES_MIN):if period_key notin completed:                completed.add(period_key)                progress["completed_periods"] = sorted(completed)                save_progress(progress, PROGRESS_FILE)continue        elapsed = time.time() - start_timeif total_downloaded > 0:            avg = elapsed / total_downloaded            eta_h = avg * (len(periods) - len(completed)) / 3600            eta_str = f" (ETA: {eta_h:.1f}h)"else:            eta_str = ""        logger.info(f"\n--- 周期 {i+1}/{len(periods)}: "f"{period_start:%Y-%m-%d} ~ {period_end:%Y-%m-%d}{eta_str} ---")try:            granules = earthaccess.search_data(                short_name=MODIS_PRODUCT, version=MODIS_VERSION,                temporal=(period_start, period_end),            )except Exception as e:            logger.error(f"  搜索颗粒失败: {e}")            total_failed += 1continueifnot granules:            logger.warning("  未找到颗粒,跳过")            completed.add(period_key)            progress["completed_periods"] = sorted(completed)            save_progress(progress, PROGRESS_FILE)continue        logger.info(f"  找到 {len(granules)} 个颗粒,使用 {args.workers} 线程下载...")        os.makedirs(period_dir, exist_ok=True)        downloaded, failed_count = download_granules_parallel(            granules, period_dir, max_workers=args.workers        )if len(downloaded) >= len(granules) * 0.85:            logger.info(f"  [OK] 周期 {period_key} 完成 ({len(downloaded)}/{len(granules)})")            completed.add(period_key)            progress["completed_periods"] = sorted(completed)            save_progress(progress, PROGRESS_FILE)            total_downloaded += 1else:            logger.warning(f"  [WARN] 周期 {period_key} 不完整 "f"({len(downloaded)}/{len(granules)}),下次运行将自动补全")            total_failed += 1if (i + 1) % 10 == 0:            speed = total_downloaded / ((time.time() - start_time) / 3600)            logger.info(f"  >>> 进度: {len(completed)}/{len(periods)}, "f"速度: {speed:.1f} 周期/小时")# 最终汇总    total_hours = (time.time() - start_time) / 3600    logger.info("\n" + "=" * 60)    logger.info(f"下载完成! 总耗时: {total_hours:.1f} 小时")    logger.info(f"成功: {len(completed)}/{len(periods)} 个周期")    logger.info(f"HDF 文件位置: {os.path.abspath(args.raw_dir)}")    logger.info("=" * 60)if __name__ == "__main__":    main()

关键参数说明

脚本中几个容易需要根据实际情况调整的变量如下:

参数
默认值
说明
DEFAULT_RAW_DIR F:\MODIS_GPP\raw_hdf
HDF 文件存储目录,按需修改到有足够空间的磁盘
--start

 / --end
2000-02-18 / 2024-12-31
下载的时间范围
--workers
8
并行下载线程数,NASA 服务器一般限制约 10~15 个并发,不建议超过 15
EXPECTED_TILES_MIN
250
认为一个周期”下载完整”所需的最低文件数,全球约 290 个瓦片,设 250 作为容错阈值

命令行用法如下:

# 完整下载 2000-2024(首次运行)python lpdaac_gpp_download_only.py# 断点续传(中途中断后恢复)python lpdaac_gpp_download_only.py --resume# 先检查本地已有多少,不实际下载python lpdaac_gpp_download_only.py --dry-run# 调整线程数为 12,加快下载python lpdaac_gpp_download_only.py --workers 12# 自定义时间范围python lpdaac_gpp_download_only.py --start 2010-01-01 --end 2020-12-31

几个踩坑记录

关于 MOD17A2HGF 与 MOD17A2H 的选择:如果做长时序分析,建议优先选 GF 版本。标准版 MOD17A2H 在年末几个周期会因为 FPAR/LAI 质量差而出现明显的低估异常,在时序曲线上表现为突然的负值或极低值,用 GF 版本可以规避这个问题。

关于 Windows 代理干扰问题:这个问题比较隐蔽。即便在 Clash 或 V2Ray 里点击了”关闭系统代理”,Windows 注册表里的代理设置有时候不会立即清除,导致 Python 的 requests 库仍然走代理,在代理不稳定或没有开启 TUN 模式时会出现 SSL 握手失败(SSLEOFError)。解决方法是在脚本里显式设置 os.environ["NO_PROXY"] = "*" 和 os.environ["no_proxy"] = "*",强制让所有请求绕过代理直连。

关于 earthaccess v0.18 认证接口变更:早期版本的 earthaccess.login() 支持 strategy="password" 直接传用户名密码,v0.18 之后这个策略被移除,改成了 strategy="netrc"(读取 .netrc 文件)、strategy="environment"(读取环境变量)和 strategy="interactive"(交互式引导)三种方式。如果用老版本代码会报 ValueError: Invalid strategy,换用上面的方式就好。

至此,大功告成。


欢迎关注:疯狂学习GIS