批量下载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)产品。

这一产品的主要参数如下:
|
|
|
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
全球完整下载一套(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(2000, 2, 18)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, 1, 1) + 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, None, False) 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, None, False)return (granule, None, False)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=False) as 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 = 0, 0 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 |
|
--start
--end |
|
|
--workers |
|
|
EXPECTED_TILES_MIN |
|
|
命令行用法如下:
# 完整下载 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
夜雨聆风