当前位置:首页>python>Python遥感实战1 | 多光谱 + 热红外联用:快速测算区域植被水分胁迫(TVDI 全流程解析)

Python遥感实战1 | 多光谱 + 热红外联用:快速测算区域植被水分胁迫(TVDI 全流程解析)

  • 2026-09-09 00:19:33
Python遥感实战1 | 多光谱 + 热红外联用:快速测算区域植被水分胁迫(TVDI 全流程解析)

在农业灌溉调度、生态干旱监测中,植被水分胁迫程度是核心评估指标。传统定点监测覆盖范围有限、时效性不足,而基于遥感的温度植被干旱指数(TVDI),能快速输出大范围、空间连续的干旱分级结果,是当前高植被覆盖区旱情监测的主流实用方法。

本次我们以研究区多光谱与热红外数据为基础,完整实现从 NDVI 计算、干湿边拟合到 TVDI 干旱分级的全流程,并附上可直接运行的 Python 代码,同时对结果进行空间解读。

最终结果输出

TVDI 连续值遥感图

干旱等级分类图

一、TVDI 的核心逻辑:NDVI+LST 的特征空间法

要理解 TVDI,先要清楚两个基础指标:

NDVI(归一化植被指数)

:通过红光与近红外波段计算,数值越高代表植被覆盖越茂盛、长势越好。

LST(地表温度)

:热红外影像反演得到的地表温度。植被冠层温度直接反映其水分状态 —— 水分充足的植被通过蒸腾散热,冠层温度更低;缺水胁迫的植被气孔关闭,蒸腾减弱,温度显著升高。

当我们把区域内所有像元的 NDVI 作为横轴、LST 作为纵轴绘制散点,会形成近似三角形的特征空间,存在两条关键边界:

干边

:同一 NDVI 水平下的温度上限,代表植被遭受严重水分胁迫、蒸腾作用受抑制的极端干旱状态。

湿边

:同一 NDVI 水平下的温度下限,代表植被水分充足、蒸腾旺盛的最优水分状态。

基于这两条边界,TVDI 的计算公式为:

TVDI = (当前像元 LST - 对应 NDVI 下的湿边温度) / (干边温度 - 湿边温度)

计算结果被归一化到 0~1 区间:

TVDI 越接近 0:水分越充足,胁迫越轻

TVDI 越接近 1:水分胁迫越强,干旱越严重

二、数据处理全流程

输入数据

多光谱影像:包含红光、近红外等波段,用于计算 NDVI

热红外 LST 影像:地表温度反演结果,提供全域温度数据

处理步骤

NDVI 计算

:从多光谱影像提取红光、近红外波段,逐像元计算 NDVI 植被指数

空间对齐

:将热红外 LST 影像重投影、重采样,与多光谱影像的坐标系、像元大小完全匹配

有效像元过滤

:剔除 NDVI 异常值与温度异常值,保留 0.15~0.95 范围内的植被像元用于干湿边拟合

干湿边提取与拟合

:将 NDVI 分为 35 个区间,每个区间取 95% 分位温度作为干边点、5% 分位温度作为湿边点,对两组点分别进行线性拟合,得到干湿边方程

TVDI 计算与分级

:逐像元计算 TVDI 值,并按阈值划分为 5 个干旱等级

结果输出

:生成 TVDI 连续值栅格与干旱等级分类栅格

拟合结果

修改脚本中默认的输入输出路径后直接运行,即可自动完成全部计算。本次研究区拟合得到的干湿边线性方程为:

干边:LST = -285.967 × NDVI + 476.827

湿边:LST = -20.541 × NDVI + 260.147

干边斜率的绝对值远大于湿边,符合植被蒸腾的物理规律:随着植被覆盖度提升,干旱状态下的冠层温度下降幅度更显著。

三、结果解读:空间分布特征

1. TVDI 连续值分布

(配图:TVDI 连续值遥感图)深紫色代表低 TVDI(水分充足),亮黄色代表高 TVDI(严重胁迫)。
从整体分布来看,研究区大部分区域 TVDI 低于 0.2,整体水分条件良好。高值胁迫区集中在研究区北部的建设用地、裸地区域,这类区域植被覆盖低,地表升温快,表现出明显的高温胁迫特征;中南部连片农田区域 TVDI 普遍偏低且空间均匀,说明作物长势一致,水分供应充足。
田埂、道路等线性地物的 TVDI 明显高于周边农田,与实际地物特征高度吻合,也验证了计算结果的可靠性。

2. 干旱等级分级结果

(配图:干旱等级分类图)为了更直观地评估旱情,我们将 TVDI 划分为 5 个干旱等级:
等级
TVDI 区间
干旱程度
1 级
0.0~0.2
无胁迫(水分充足)
2 级
0.2~0.4
轻度胁迫
3 级
0.4~0.6
中度胁迫
4 级
0.6~0.8
重度胁迫
5 级
0.8~1.0
极重度胁迫
从分级结果可以清晰看到:

无胁迫区域(蓝色)

:占比最高,集中在中南部连片农田,是研究区的主体地类,植被生长水分条件优异。

轻度胁迫区域(绿色)

:多分布在田块边缘、植被过渡带,属于农田与非植被的交界区域,胁迫程度轻微。

中度及以上胁迫区域

:集中分布在研究区北部,以居民点、工矿用地和裸地为主,属于非植被的高温区域;农田内部零星分布的小斑块,可作为精准灌溉的重点排查区域,大概率是局部灌溉不均或作物长势差异导致。

四、应用价值与优化方向

核心应用场景

精准农业灌溉

:识别田间缺水斑块,指导差异化变量灌溉,在节约水资源的同时保障作物产量。

生态旱情监测

:大范围评估林草植被的水分胁迫状况,为区域生态保护、旱灾预警提供定量数据支撑。

灾害应急评估

:旱情发生后快速获取受灾范围与严重等级,辅助救灾决策与灾情统计。

完整 Python 实现代码

本次计算基于 Python 开源遥感栈实现,依赖numpy和rasterio库,可通过以下命令安装依赖:
pip install numpy rasterio
完整可运行脚本如下,支持通过命令行参数修改输入输出路径、波段索引、拟合参数等配置:
import argparseimport mathimport osimport numpy as npimport rasteriofrom rasterio.enums import Resamplingfrom rasterio.warp import reprojectdef parse_args():    parser = argparse.ArgumentParser(        description="Compute TVDI / CWSI from multispectral and thermal raster inputs."    )    parser.add_argument(        "--multispectral",        default=r"D:\data\MultiSpectral\0707\result1.tif",        help="Path to multispectral input raster with Red and NIR bands.",    )    parser.add_argument(        "--thermal",        default=r"D:\data\MultiSpectral\0708s\TIR_LST20260708.tif",        help="Path to thermal LST input raster.",    )    parser.add_argument(        "--output-dir",        default=r"D:\data\MultiSpectral\output",        help="Directory where TVDI and drought class outputs will be written.",    )    parser.add_argument(        "--nir-band",        type=int,        default=3,        help="Band index for NIR in the multispectral file (1-based).",    )    parser.add_argument(        "--red-band",        type=int,        default=2,        help="Band index for Red in the multispectral file (1-based).",    )    parser.add_argument(        "--min-ndvi",        type=float,        default=0.15,        help="Minimum NDVI threshold for edge estimation and TVDI calculation.",    )    parser.add_argument(        "--max-ndvi",        type=float,        default=0.95,        help="Maximum NDVI threshold for edge estimation.",    )    parser.add_argument(        "--bin-count",        type=int,        default=35,        help="Number of NDVI bins used for dry/wet edge extraction.",    )    parser.add_argument(        "--dry-threshold",        type=float,        default=0.95,        help="Percentile used to select the dry-edge temperature within each NDVI bin.",    )    parser.add_argument(        "--wet-threshold",        type=float,        default=0.05,        help="Percentile used to select the wet-edge temperature within each NDVI bin.",    )    return parser.parse_args()def reproject_to_match(source_path, target_profile):    with rasterio.open(source_path) as src:        dest = np.full((target_profile["height"], target_profile["width"]), np.nan, dtype=np.float32)        reproject(            source=rasterio.band(src, 1),            destination=dest,            src_transform=src.transform,            src_crs=src.crs,            dst_transform=target_profile["transform"],            dst_crs=target_profile["crs"],            resampling=Resampling.bilinear,            src_nodata=src.nodata,            dst_nodata=np.nan,        )    return destdef calculate_ndvi(red, nir):    red = red.astype(np.float32)    nir = nir.astype(np.float32)    denom = nir + red    with np.errstate(divide="ignore", invalid="ignore"):        ndvi = (nir - red) / denom    ndvi[denom == 0] = np.nan    return ndvidef fit_edge(ndvi_values, lst_values, bin_count, quantile):    valid = ~np.isnan(ndvi_values) & ~np.isnan(lst_values)    ndvi_values = ndvi_values[valid]    lst_values = lst_values[valid]    if ndvi_values.size == 0:        raise ValueError("No valid NDVI/LST pixels for edge fitting.")    bins = np.linspace(np.nanmin(ndvi_values), np.nanmax(ndvi_values), bin_count + 1)    bin_centers = []    edge_lst = []    for left, right in zip(bins[:-1], bins[1:]):        mask = (ndvi_values >= left) & (ndvi_values < right)        if mask.sum() < 50:            continue        values = lst_values[mask]        edge_value = np.nanpercentile(values, quantile)        if np.isfinite(edge_value):            bin_centers.append((left + right) / 2.0)            edge_lst.append(edge_value)    if len(bin_centers) < 2:        raise ValueError("Not enough bins with valid values to fit a line.")    slope, intercept = np.polyfit(bin_centers, edge_lst, 1)    return slope, intercept, np.array(bin_centers), np.array(edge_lst)def calculate_tvdi(lst, ndvi, dry_line, wet_line):    dry_slope, dry_intercept = dry_line    wet_slope, wet_intercept = wet_line    lst_dry = dry_slope * ndvi + dry_intercept    lst_wet = wet_slope * ndvi + wet_intercept    denom = lst_dry - lst_wet    with np.errstate(divide="ignore", invalid="ignore"):        tvdi = (lst - lst_wet) / denom    tvdi[denom == 0] = np.nan    tvdi = np.clip(tvdi, 0.0, 1.0)    return tvdidef classify_tvdi(tvdi):    classes = np.full(tvdi.shape, 0, dtype=np.uint8)    valid = ~np.isnan(tvdi)    classes[(tvdi >= 0.0) & (tvdi < 0.2)] = 1    classes[(tvdi >= 0.2) & (tvdi < 0.4)] = 2    classes[(tvdi >= 0.4) & (tvdi < 0.6)] = 3    classes[(tvdi >= 0.6) & (tvdi < 0.8)] = 4    classes[(tvdi >= 0.8) & (tvdi <= 1.0)] = 5    classes[~valid] = 0    return classesdef save_raster(path, array, profile, dtype, nodata=None, compress="lzw"):    profile = profile.copy()    profile.update(        dtype=dtype,        count=1,        compress=compress,        nodata=nodata,    )    with rasterio.open(path, "w", **profile) as dst:        dst.write(array.astype(dtype), 1)def main():    args = parse_args()    os.makedirs(args.output_dir, exist_ok=True)    with rasterio.open(args.multispectral) as ms:        red = ms.read(args.red_band).astype(np.float32)        nir = ms.read(args.nir_band).astype(np.float32)        ms_profile = ms.profile    ndvi = calculate_ndvi(red, nir)    print(f"Computed NDVI from bands {args.red_band} and {args.nir_band}.")    lst = reproject_to_match(args.thermal, ms_profile)    print(f"Reprojected thermal raster to multispectral grid ({lst.shape}).")    valid_mask = (        ~np.isnan(ndvi)        & ~np.isnan(lst)        & (ndvi >= args.min_ndvi)        & (ndvi <= args.max_ndvi)        & (lst > 0)        & (lst < 500)    )    if valid_mask.sum() < 1000:        raise RuntimeError("Too few valid pixels for edge estimation after masking.")    ndvi_for_edge = ndvi[valid_mask]    lst_for_edge = lst[valid_mask]    dry_line = fit_edge(ndvi_for_edge, lst_for_edge, args.bin_count, 100 * args.dry_threshold)    wet_line = fit_edge(ndvi_for_edge, lst_for_edge, args.bin_count, 100 * args.wet_threshold)    dry_slope, dry_intercept, dry_centers, dry_values = dry_line    wet_slope, wet_intercept, wet_centers, wet_values = wet_line    print("Dry edge: LST = {:.3f} * NDVI + {:.3f}".format(dry_slope, dry_intercept))    print("Wet edge: LST = {:.3f} * NDVI + {:.3f}".format(wet_slope, wet_intercept))    tvdi = calculate_tvdi(lst, ndvi, (dry_slope, dry_intercept), (wet_slope, wet_intercept))    classification = classify_tvdi(tvdi)    tvdi_path = os.path.join(args.output_dir, "TVDI_20260708.tif")    class_path = os.path.join(args.output_dir, "Drought_Class_20260708.tif")    save_raster(tvdi_path, tvdi.astype(np.float32), ms_profile, dtype=rasterio.float32, nodata=np.nan)    save_raster(class_path, classification, ms_profile, dtype=rasterio.uint8, nodata=0)    print(f"Saved TVDI raster to {tvdi_path}")    print(f"Saved drought class raster to {class_path}")    print("Drought classes: 0=invalid, 1=no stress, 2=light, 3=moderate, 4=severe, 5=extreme")if __name__ == "__main__":    main()

最新文章

随机文章