
简介数据集收录了二〇〇一至二〇一六年青藏高原植被物候关键参数面向生态学、气候变化及遥感应用研究人员。数据涵盖生长季开始、结束与长度等核心指标可用于分析高寒植被对气候变化的响应模式。包内共二百七十二个文件以GeoTIFF栅格为主配套tfw坐标参考、xml元数据及ovr金字塔便于在GIS平台直接读取与批量处理压缩包整体三百六十点二五MB同时包含四十八个物候图层结构清晰、命名规则统一。已有二百九十二人学习获取。借助该数据集用户可开展长时间序列物候趋势分析、生态模型参数化及区域碳循环评估也可结合NDVI等遥感指数深入挖掘植被动态与气候因子的耦合关系为高原生态保护提供数据支撑对于理解高寒生态系统响应全球变暖具有直接参考价值。1. 青藏高原植被物候数据集能解决什么问题为什么值得自己动手处理在青藏高原这种地形复杂、气候敏感的区域植被物候数据集2001-2016比单纯NDVI时间序列更有生态解释力。它通常以GeoTIFF形式打包成RAR里面每个文件对应一年或一个物候指标像返青期SOS、枯黄期EOS、生长季长度LOS。很多同行拿到压缩包后习惯先解压再在ArcGIS里一个个拉图层这样做三年、16个文件还行但如果要按站点提取时间序列、做趋势显著性检验就必须转成代码流程。这篇文章从解压校验开始讲怎么把RAR里的栅格变成带时间维的数据立方体再完成趋势分析和一致性验证。适合生态学、地信和遥感方向的工程师也适合刚接触栅格时间序列的人。2. 文件结构解析与预处理从RAR到标准化GeoTIFF堆栈2.1 植被物候指标在数据集里的表达方式物候数据集的记录单位通常是儒略日DOY表示第几天。比如返青期SOS121表示第121天开始变绿。由于青藏高原跨多个气候带从东南部的湿润河谷到西北部的荒漠物候指标的空间分布差异很大所以像素值范围不能简单用全局阈值去切。有的产品把SOS定义为植被指数达到当年振幅一定比例的那天这个阈值20%或50%会直接影响数值。理解这个背景很重要后面所有清洗规则都要和数据生成方式保持一致不能把冬季的值直接当异常丢掉。这类数据集通常出现以下缩写指标缩写全称常见含义典型值域SOSStart of Season返青期生长季开始日1~365 或 1~366EOSEnd of Season枯黄期生长季结束日1~365 或 1~366LOSLength of Season生长季长度EOS减去SOS0~365POSPeak of Season峰值出现日1~365VPPVegetation Productivity生长季总生产潜力或峰值生物量视产品而定在文件名里常见模式是“指标缩写_年份”例如SOS_2010.tif。但也有很多数据用YYYY_SOS或SOS_2010_1km.tif所以后面写解析脚本时会强调用正则而不是固定split(_)。2.2 用UnRAR批量解压并检查CRC校验拿到RAR后第一步是解压但不要盲目全部解压。先用unrar l看压缩包内文件列表确认是单层目录还是散文件这一步能避免解压出一堆嵌套目录。# 查看压缩包内容不实际解压 unrar l 青藏高原植被物候数据集2001-2016.rar # 解压到指定目录保留原目录结构 mkdir -p /data/qh/raw unrar x -o 青藏高原植被物候数据集2001-2016.rar /data/qh/raw/ # 逐文件计算校验和确保不是损坏文件 for f in /data/qh/raw/*.tif; do gdalinfo -checksum $f /dev/null 21 echo $f OK || echo $f FAIL doneunrar x保留内部目录结构unrar e会摊平所有文件遇到同名文件可能互相覆盖。-o表示覆盖已有文件如果不加交互式询问会中断批量脚本。gdalinfo -checksum会读一遍全部像元既能验证文件可读写也能发现TIF损坏如果数据量很大可以改成gdalinfo -stats但速度更慢。在 Windows 环境下建议把中文文件名改为phenology_2001_2016.rar这类 ASCII 名称避免 PowerShell 的编码问题。2.3 用Python扫描文件并生成元数据表解压完成之后不要急着读图先用脚本建立数据目录的元数据索引后面所有批量处理都靠这个索引来定位文件。这里用rasterio读取头信息因为默认不加载全像元速度非常快。from pathlib import Path import re import rasterio import pandas as pd data_dir Path(/data/qh/raw) records [] # 只扫描tif文件避免把.aux.xml等辅助文件当栅格读 for tif in sorted(data_dir.glob(*.tif)): # 用正则从文件名里提取指标和年份兼容多种命名 full_name tif.stem.upper() m re.search(r(SOS|EOS|LOS|POS), full_name) y re.search(r(19|20)\d{2}, full_name) if not m or not y: print(fskip unknown file: {tif.name}) continue with rasterio.open(tif) as src: records.append({ file: tif.name, metric: m.group(1), year: int(y.group(0)), rows: src.height, cols: src.width, crs: src.crs.to_string() if src.crs else None, transform: src.transform, nodata: src.nodata, dtype: src.dtypes[0], res: src.res[0], }) df pd.DataFrame(records) df.to_csv(index.csv, indexFalse) print(df.head())这里用正则而不是split(_)因为文件名可能是SOS_2010.tif也可能是2010_SOS_1km.tif固定切分很容易踩坑。src.dtypes[0]是第一个波段的数据类型物候数据多以Int16或Float32存储。nodata字段非常重要很多下游算法依赖它做掩膜如果原文件缺了必须在程序里手动补一个合理值。后续所有读取都会查这个index.csv所以务必确认年份和指标列都解析正确。生成的index.csv可以当作轻量级目录管理也方便直接拿去给xarray或GeoPandas做后续关联。字段含义在工作中需要重点关注这些坑字段用途需要注意的坑file后续读取的唯一入口在Windows上避免中文路径或使用原始字符串metric物候指标类型大小写不统一时需要统一比如全转大写year时间维度可能缺少2001-2016中的某一年要单独标记crs投影坐标系不同指标文件之间CRS不一致是常事nodata空值标识有的文件给0有的给-9999必须分别处理res空间分辨率分辨率不同的文件不能直接堆叠到同一数组3. 用xarray和rioxarray读成带时间维的数据立方体3.1 为什么不用循环读TIF而用xarray传统做法是用循环打开每个TIF取出band src.read(1)再人为维护year和metric两个列表。如果只做一次趋势分析这种方法没问题。但物候分析通常要反复切片、按站点插值、在时间维上做聚合每次都要重新对齐行列号非常容易出错。xarray的优势在于给数组加上维度名和坐标。把16个SOS文件拼成一个(time, y, x)数组后可以直接用da.sel(time2010)、da.sel(y..., x...)做空间切片也能用da.groupby(time)做逐年统计。物候数据本质上是“时间×空间”的连续场这种数据模型正好匹配。3.2 用xr.concat拼接多时相栅格下面的代码假设已经用index.csv筛选出所有SOS文件。为了避免内存暴增先用rioxarray.open_rasterio做懒加载再用xr.concat拼接。import rioxarray import xarray as xr import numpy as np import pandas as pd from pathlib import Path # 读取第2章生成的元数据表 df pd.read_csv(index.csv) sos_df df[df[metric] SOS].sort_values(year) data_dir Path(/data/qh/raw) arrays [] for _, row in sos_df.iterrows(): da_t rioxarray.open_rasterio(data_dir / row[file]) # 去掉只有一个band的维度 da_t da_t.squeeze(band, dropTrue) arrays.append(da_t) # 按时间维拼接 da_sos xr.concat(arrays, dimtime) # 显式指定时间坐标避免默认整数坐标 da_sos da_sos.assign_coords(timesos_df[year].values) da_sos.name SOS print(da_sos)open_rasterio在读取单个文件时不会立刻把所有像元读进内存只有真正进行计算时才会读取。squeeze(band, dropTrue)专门用来处理单波段GeoTIFFdropTrue确保维度被移除而不是保留长度为1。xr.concat要求所有数组的y和x维度大小一致如果年份之间分辨率或范围不一致在concat时就会报错这也是一道天然的检查。assign_coords(timesos_df[year])显式给时间坐标赋值方便后续按年份索引。3.3 统一投影并裁剪到研究区范围rioxarray在读取时保留CRS信息但不同来源的文件可能一个用正弦投影、一个用经纬度。最稳妥的做法是在拼接前或拼接后统一转成EPSG:4326这样后续和站点坐标做比对不会出现偏移。# 统一投影到WGS84经纬度 da_sos_ll da_sos.rio.reproject(EPSG:4326) # 裁剪到青藏高原主体范围lat从大到小是slice的习惯 bbox {lon: (73, 105), lat: (25, 40)} da_crop da_sos_ll.sel( lonslice(bbox[lon][0], bbox[lon][1]), latslice(bbox[lat][1], bbox[lat][0]) # 注意北边界在前 )reproject默认会做最近邻重采样如果物候指标是离散的儒略日用resamplingnearest是安全的如果想保留亚像元级变化可以对浮点型数据用bilinear但这会产生非整数天数后续统计时要清楚舍入规则。对青藏高原这种大范围研究区裁剪到经纬度矩形能明显减少内存占用。3.4 缺测值和异常值处理物候栅格的nodata五花八门有的用-9999有的用32767还有的把水体填成0。清洗时不能只处理一类要先把原始nodata转np.nan再按物理范围过滤。情况典型值处理方式无数据-9999, 32767转np.nan不再参与计算水面/云掩膜0按产品说明决定是否保留超出物候范围1 或 365置np.nan单个时间全为nan整层无有效值记录层占比决定是否剔除import numpy as np # 先处理栅格元数据里的nodata值 if da_crop.rio.nodata is not None: da_crop da_crop.where(da_crop ! da_crop.rio.nodata) # 再按物候物理范围过滤 da_clean xr.where((da_crop 1) | (da_crop 366), np.nan, da_crop) # 统计每年有效像元比例发现哪一年的数据质量差 valid_frac da_clean.notnull().mean(dim[lat, lon]) print(valid_frac)这一步会直接决定后续趋势分析的可靠性。比如某一年因为云覆盖严重有效像元比例不足30%用它算出的区域均值就会明显偏早或偏晚。建议把valid_frac保存下来后面做趋势分析时可以把这一层作为权重或剔除条件。4. 按站点提取物候时间序列用Mann-Kendall做趋势检验4.1 用双线性插值批量提取站点序列生态站点的经纬度通常不在像元中心直接取最近邻会带来半个像元的空间误差。青藏高原的地形起伏大这种误差可能让生长季日期偏移好几天。常见的做法是用interp做双线性插值它对连续变化的物候日期比最近邻更平滑。import pandas as pd import numpy as np # 站点表至少包含 id, lon, lat sites pd.read_csv(sites.csv) def extract_site_ts(site_lon, site_lat): ts da_clean.interp(lonsite_lon, latsite_lat, methodlinear) return ts.values rows [] for _, site in sites.iterrows(): vals extract_site_ts(site[lon], site[lat]) rows.append({id: site[id], lon: site[lon], lat: site[lat], **dict(zip(range(2001, 2017), vals))}) series_df pd.DataFrame(rows) series_df.to_csv(site_series.csv, indexFalse)interp需要da_clean有lon和lat坐标且坐标是升序排列。提取出来的vals直接作为该站点的SOS时间序列缺失值是nan。这里把年份作为列名展开保存后续在统计和可视化时不需要再循环拼表。4.2 计算Theil-Sen斜率和Mann-Kendall显著性趋势分析不能只看线性回归的斜率因为物候日期经常出现年度跳跃普通最小二乘容易被头尾几个异常值带偏。所以常见做法是用scipy.stats.theilslopes计算稳健斜率再用pymannkendall做显著性检验。# 如果还没有安装 pip install pymannkendall scipyfrom scipy import stats import pymannkendall as mk years np.arange(2001, 2017) def calc_trend(vals): valid ~np.isnan(vals) if valid.sum() 5: return {slope: np.nan, p: np.nan, tau: np.nan, n: valid.sum()} y vals[valid] x years[valid] # Theil-Sen斜率中位数配对斜率抗异常值 slope, intercept, lo, hi stats.theilslopes(y, x) # Mann-Kendall检验序列是否有单调趋势返回对象 result mk.original_test(y) return {slope: slope, p: result.p, tau: result.Tau, n: valid.sum()} results [] for _, row in series_df.iterrows(): vals row.loc[range(2001, 2017)].values.astype(float) trend calc_trend(vals) trend[id] row[id] trend[lon] row[lon] trend[lat] row[lat] results.append(trend) trend_df pd.DataFrame(results) trend_df.to_csv(phenology_trend.csv, indexFalse)stats.theilslopes的返回值中有置信区间如果只需要斜率就取slope。mk.original_test返回对象常用的属性是p和Tau当p 0.05认为趋势显著。注意original_test要求序列长度至少10这里16年满足条件但如果你的数据存在多年缺失有效年份少于10就要考虑跳过或做插补。条件对SOS趋势的解释p 0.05 且 slope 0返青期显著提前物候前移p 0.05 且 slope 0返青期显著延迟物候后移p 0.05趋势不显著但斜率仍有参考意义对EOS来说斜率正负对应的含义与SOS相反所以下游分析要区分指标不要在循环里用同一套解释逻辑。4.3 做三年滑动平均并可视化单站序列站点序列的单年观测误差较大做趋势检验前可以先看平滑曲线。下面的代码对每个站点做三年居中滑动平均然后与原始序列一起绘制用这个图检查异常年份。import matplotlib.pyplot as plt for _, row in series_df.iterrows(): vals row.loc[range(2001, 2017)].values.astype(float) ts pd.Series(vals, indexrange(2001, 2017)) # 居中滑动平均窗口3年两端用最小周期1 smooth ts.rolling(3, centerTrue, min_periods1).mean() plt.plot(ts.index, ts.values, alpha0.6, markero, ms4, labelraw) plt.plot(smooth.index, smooth.values, r-o, lw2, label3yr smooth) plt.title(fSOS trend at site {row[id]}) plt.xlabel(Year) plt.ylabel(SOS (DOY)) plt.legend() plt.savefig(fsite_{row[id]}.png, dpi150, bbox_inchestight) plt.close()rolling(3, centerTrue)让第 t 年的平滑值由 t-1、t、t1 三年平均得到min_periods1避免首尾出现nan。如果站点某年原始值明显偏离相邻两年比如突然提前20天通常是当年影像云覆盖导致需要回到第3章的有效像元比例里核对而不是直接当作趋势证据。5. 把数据重写为Zarr格式让区域统计和重复切片变快5.1 Zarr为什么适合物候数据前面用xarray直接读取GeoTIFF堆栈每次做一次区域均值都要重新打开16个文件反复触发栅格驱动和文件头解析非常慢。物候数据集本质上是一个(time, y, x)三维数组适合用Zarr这种分块存储格式保存。Zarr把数组切成独立块每个块可以单独压缩和读写后续按站点切片、按年份统计时只需要读取涉及范围内的块而不是整个文件。常见的做法是先对da_clean设置合理的chunk再写入Zarr。对物候数据来说时间维最好不切块空间维切块这样时间序列提取时只读取一块。# 时间维保留整块空间维切成正方形块 da_clean da_clean.chunk({time: -1, lat: 256, lon: 256}) # 使用zstd压缩压缩和读取速度均衡 import numcodecs compressor numcodecs.Blosc(cnamezstd, clevel3, shuffle2) # 写入zarr da_clean.to_zarr(phenology_sos.zarr, modew, encoding{SOS: {compressor: compressor}})chunk参数是关键。time-1代表时间维不切块这样沿时间轴的站点提取只需要读一个空间块lat和lon切块大小需要考虑数据行宽一般取能被影像宽度整除的值。写出来之后可以用ds xr.open_zarr(phenology_sos.zarr)重新打开它同样带lat/lon坐标和CRS信息。5.2 验证Zarr重写结果与原栅格一致重写之后不能直接删原文件必须对比抽样像元和整体统计量确认压缩重写没有引入数据偏移。import xarray as xr import numpy as np # 重新打开zarr zarr_ds xr.open_zarr(phenology_sos.zarr) # 抽样前50行、50列对比原数据的相同区域 slice_sel dict(timeslice(0, 10), latslice(0, 50), lonslice(0, 50)) np.testing.assert_allclose( zarr_ds[SOS].isel(**slice_sel).values, da_clean.isel(**slice_sel).values, equal_nanTrue ) # 再对比全区域有效平均值 print(original mean:, da_clean.mean().item()) print(zarr mean:, zarr_ds[SOS].mean().item())equal_nanTrue表示nan位置不参与比较这样不会因为空值位置不同而报错。抽样切片验证后再用全区域均值做一次粗精度校验两者差异在1e-5以下说明写入正常。还有一点要提醒如果不同年份的SOS数据的空间范围不完全重合xr.concat时会引入nan转Zarr后这些nan会被压缩器处理不影响数值但会稍微增加存储占用。最后在实际工作中我一般会在重写后把原始RAR保留在冷存储里Zarr版本作为分析主用数据。这样既保留了一手源文件又让后续的站点提取、区域统计和趋势制图都只跟一个三维数组打交道不再反复解压和拼接。本文还有配套的精品资源点击获取