首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >批量下载MODIS遥感影像数据的最快捷方法

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

作者头像
疯狂学习GIS
发布2026-06-09 16:14:11
发布2026-06-09 16:14:11
70
举报
文章被收录于专栏:疯狂学习GIS疯狂学习GIS

本文介绍基于脚本,快速、批量下载 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 左右(实际下载完毕后,发现数据量其实远超这个数值)。单靠手动从网页点击下载显然不现实,所以用脚本来做。


环境准备

主要依赖两个库:

代码语言:javascript
复制
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,内容格式如下:

代码语言:javascript
复制
machine urs.earthdata.nasa.gov
login 你的用户名
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 临时文件,完成后重命名,防止意外中断导致的不完整文件被当作有效文件跳过。


完整代码

代码语言:javascript
复制
#!/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 argparse
import json
import logging
import os
import sys
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import datetime, timedelta
from pathlib import Path

import earthaccess
from 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# 低于此数量认为下载不完整


# ============================================================
# 辅助函数
# ============================================================

def generate_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_START
    while current <= end_date:
        period_end = current + timedelta(days=7)
        periods.append((current, period_end))
        current = period_end + timedelta(days=1)
    return periods


def authenticate():
    """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} 认证成功")
            return
        except Exception as e:
            logger.debug(f"{strategy} 认证失败: {e}")

    logger.error("[FAIL] 所有认证方式均失败,请检查 ~/.netrc 或环境变量配置")
    sys.exit(1)


def download_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)


def download_granules_parallel(granules, output_dir, max_workers=8):
    """多线程并发下载颗粒列表。"""
    os.makedirs(output_dir, exist_ok=True)
    downloaded, failed = [], 0

    with ThreadPoolExecutor(max_workers=max_workers) as executor:
        futures = {executor.submit(download_single_granule, g, output_dir): g
                   for 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, failed


def count_hdf_files(directory):
    ifnot os.path.exists(directory):
        return0
    return len([f for f in os.listdir(directory) if f.lower().endswith(".hdf")])


def load_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()}


def save_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)


# ============================================================
# 主流程
# ============================================================

def main():
    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 periods
            if 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_time
        if 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 += 1
            continue

        ifnot 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 += 1
        else:
            logger.warning(f"  [WARN] 周期 {period_key} 不完整 "
                           f"({len(downloaded)}/{len(granules)}),下次运行将自动补全")
            total_failed += 1

        if (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 作为容错阈值

命令行用法如下:

代码语言:javascript
复制
# 完整下载 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

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2026-06-09,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 疯狂学习GIS 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 数据概况
  • 环境准备
  • 代码设计思路
  • 完整代码
  • 关键参数说明
  • 几个踩坑记录
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档