简介:面向遥感与Python初学者的绝对辐射定标实战资源包,以GDAL库为核心,讲解如何将遥感图像原始灰度值转换为具有物理意义的辐射值(如地表反射率或亮度温度),适用于环境监测、气候变化、资源调查等领域的图像预处理教学与科研入门。包内共9个文件,包括1个Python主脚本、1份JSON定标参数文件、5个XML工程配置文档及若干项目辅助文件,整个压缩包仅16KB,结构清晰便于直接阅读。主脚本演示了从打开遥感图像、解析JSON定标参数、逐波段读取像元灰度值、计算辐射校正值到写出新图像的完整流程,JSON中提供了增益、暗电流等典型定标系数,XML文件则记录了工程配置和依赖信息,方便在IDE中还原项目环境,也能帮助读者理清不同文件在定标工作中的分工。目前已有902人学习或下载,特别适合需要亲手实践辐射定标步骤、理解GDAL栅格处理逻辑并快速迁移到自身实验中的入门者。
1. 绝对辐射定标:从 DN 到辐亮度,这一步不做后面全是白算
做遥感应用的人迟早会在某个项目里碰一次壁:下载回来的影像打开后是一片灰蒙蒙的数字,直方图挤在 0 到几千的区间里,直接拿去算 NDVI,出来的结果不是大片零值就是异常高值。原因几乎都指向同一件事——没有做绝对辐射定标。绝对辐射定标解决的是把传感器记录的 DN 值(无量纲量化值)转换成有物理意义的辐亮度或反射率的问题,它是任何定量遥感任务真正意义上的起点。本文面向正在做地表分类、植被反演或者深度影像预处理的一线开发者和研究生,用 Python 把从 DN 到反射率的全流程拆开讲,包括元数据读取、公式选择、参数陷阱和验证方法。看完你应该能独立处理 Landsat、哨兵2这类常用的中分辨率影像,并且知道怎么确认自己的结果没算错。
很多教程喜欢直接甩一句“辐射定标就是把 DN 转成反射率”,然后贴一段代码。但真正上手你就会发现,卡住你的永远不是那三行公式,而是元数据里的参数到底怎么取、反射率和辐亮度该选哪个、以及公式里的太阳角度到底用 sin 还是 cos。这篇文章不回避这些绊脚石,我们一步步把它们踩平。
2. 辐射定标的数学基础:两条公式、三个物理量,先别写代码
2.1 辐亮度和反射率:定标到底在换算什么
卫星传感器记录的是地物反射或发射的电磁波能量经过大气、传感器光学系统、光电转换后的一个数字量化值,这个值就是 DN(Digital Number)。它和真实的物理量之间有一个近似线性的响应关系,把这个线性关系还原出来的过程,就是辐射定标。定标后你会得到两种最常见的物理量:辐亮度(Radiance)和反射率(Reflectance)。辐亮度的单位是 W·m⁻²·sr⁻¹·μm⁻¹,代表传感器入瞳处单位面积、单位立体角、单位波长间隔内接收到的辐射能量;反射率是地物反射能量与入射能量的比值,无量纲,取值范围理论上在 0 到 1 之间。
这两个物理量服务于不同场景。如果你要做地表温度反演、火焰探测或者能量平衡分析,你需要的是辐亮度,因为它直接对应传感器接收到的绝对能量;如果你要做植被指数、土地覆盖分类或者机器学习样本制作,你需要的是反射率,因为它消除了太阳辐照度和太阳高度的影响,让不同时相、不同季节的影像可以相互比较。记住这个选择逻辑,后面就不会在输出文件里存错变量。
反射率又分表观反射率(Top of Atmosphere Reflectance,也叫大气顶部反射率)和地表反射率(Surface Reflectance)。绝对辐射定标直接得到的是表观反射率,因为它只做了传感器响应修正,没有去掉大气的吸收和散射影响。大气校正则是把表观反射率进一步变成地表反射率。很多人把这两个步骤混为一谈,用没做大气校正的影像直接和地面实测光谱比,误差随气溶胶和水汽含量波动,最高可以相差 20% 以上。
2.2 增益-偏置法与查表法:两种产品两套代码
辐射定标最常见的实现方式是增益-偏置法,公式是 L = gain × DN + offset,其中 gain 就是定标系数里的增益项,offset 是偏置项。不同卫星产品对这两个参数的叫法不同:Landsat 8/9 的 MTL 文件里写作 RADIANCE_MULT_BAND_x 和 RADIANCE_ADD_BAND_x,哨兵2号的 Level-1C 产品则在 XML 里直接给出了一个反射率缩放因子,用户只需要把 DN 除以 10000 就能得到表观反射率。这就是为什么网上抄来的代码换个数据源就完全跑不通——参数名字变了,公式结构也变了。
还有一种做法是查表法,适用于那些传感器响应不完全线性、或者官方没有公开增益偏置系数的数据源。查表法一般用在航空高光谱或者一些商业卫星上,通过一个查找表(LUT)把 DN 映射到辐亮度。对于日常处理 Landsat 和 Sentinel-2,增益-偏置法就够了,不需要自己建 LUT。需要注意的是,Landsat 从 Collection 1 升级到 Collection 2 之后,定标系数、几何精度、元数据格式都有变化,如果你的代码还是按 C1 时代的脚本跑 C2 数据,结果会明显偏暗或者偏亮,这个坑后面会专门展开。
2.3 为什么先做一个 mini 辐射定标实验再上批量
我曾经接过一个模拟项目X,需要一次性处理同一个区域跨三年的 80 景影像。当时图省事,直接套了一年前写的脚本批量跑,结果第三年的数据在 NDVI 直方图上整体偏移了 0.05 左右,最后排查了整整两天才发现是当年的存档数据里混入了两个不同版本的产品,定标系数来源不一致。血的教训是:批量处理之前,一定要先拿一小块影像手工验证公式、参数和单位。
mini 实验的做法很简单:选一景影像,裁剪出 100×100 像元的区域,用 Excel 或者手写计算器把其中 3 个像元从 DN 手工换算到反射率,然后再用 Python 脚本跑一遍,对比结果。这样做的成本只有十分钟,却能帮你提前暴露 90% 的公式写错、参数取错、单位弄反的问题。别嫌这一步多余,它才是整个定标流程里性价比最高的一环。
3. 定标前的数据准备:从元数据里把参数抠出来
3.1 常见影像的元数据文件长什么样
元数据是辐射定标的参数来源,拿到影像后第一件事不是打开栅格,而是打开和它放在一起的文本或 XML 文件。以最常见的 Landsat 8/9 OLI 产品为例,每景影像解压后都会有一个以 _MTL.txt 结尾的文本文件,里面以 KEY = VALUE 的形式记录了成像时间、太阳高度角、太阳方位角、辐亮度增益/偏置、反射率增益/偏置等信息。哨兵2号 Level-1C 产品则是一个 .SAFE 文件夹,里面嵌套着 MTD_MSIL1C.xml,反射率缩放因子在 XML 的 BOA 或 TOA 相关节点里。
不同产品需要提取的参数不完全一样,但核心就是下面这张表。建议把它打印出来贴在工位上,每次换数据源时对照检查。
| 参数 | Landsat 8/9 MTL 字段名 | Sentinel-2 L1C XML 字段名 | 用途 |
|---|---|---|---|
| 辐亮度增益 | RADIANCE_MULT_BAND_x | 无(官方不推荐转辐亮度) | DN 转辐亮度 |
| 辐亮度偏置 | RADIANCE_ADD_BAND_x | 无 | DN 转辐亮度 |
| 反射率增益 | REFLECTANCE_MULT_BAND_x | 无 | DN 转表观反射率 |
| 反射率偏置 | REFLECTANCE_ADD_BAND_x | 无 | DN 转表观反射率 |
| 太阳高度角 | SUN_ELEVATION | Sun_zenith_angle(或由天顶角换算) | 反射率公式修正项 |
| 日地距离 | EARTH_SUN_DISTANCE | 无(官方产品已内置) | 反射率公式修正项 |
| 通用缩放因子 | 无 | Quantification value(通常为 10000) | DN 转反射率 |
3.2 用正则表达式从 MTL 里提取定标系数
MTL 文件是纯文本格式,最稳妥的提取方式是用正则表达式按字段名抓取。我一般习惯写一个独立的解析函数,返回一个字典,这样后面不管做单景还是批量,代码都能复用。下面这段代码针对 Landsat 8/9 的 MTL 文件,核心思路是把所有 GROUP 块忽略掉,直接按字段名匹配。
import re from pathlib import Path def parse_mtl(mtl_path): """从 Landsat MTL 文本中解析定标相关参数,返回字典""" text = Path(mtl_path).read_text(encoding='utf-8', errors='ignore') params = {} # 匹配形如 KEY = VALUE 的行,只取我们需要的关键字段 # 注意:MTL 里存在 GROUP 嵌套,但字段名全局唯一,直接全局匹配即可 keys = [ 'SPACECRAFT_ID', 'SENSOR_ID', 'SUN_ELEVATION', 'EARTH_SUN_DISTANCE', 'RADIANCE_MULT_BAND_4', 'RADIANCE_ADD_BAND_4', 'REFLECTANCE_MULT_BAND_4', 'REFLECTANCE_ADD_BAND_4' ] for key in keys: pattern = rf'^{key}\s*=\s*([^\s]+)' # 行首精确匹配,避免读到其他值 m = re.search(pattern, text, re.MULTILINE) if m: params[key] = m.group(1) # 数值字段统一转成 float,方便后面运算 for key in params: try: params[key] = float(params[key]) except ValueError: pass # 非数值字段保留字符串,比如 SPACECRAFT_ID return params mtl = parse_mtl('LC08_L2SP_123032_20241010_20241010_02_T1_MTL.txt') print(mtl['REFLECTANCE_MULT_BAND_4'], mtl['SUN_ELEVATION'])这段代码里最需要注意的是正则的写法。^SUN_ELEVATION限制了匹配必须从行首开始,防止误匹配到其他包含相同子串的字段;REFLECTANCE_MULT_BAND_4这类带波段号的字段,如果后续要处理全波段,可以用 for 循环批量生成 key。提取后统一转 float 是一个容易忽略的细节——如果你拿着字符串去计算,Python 会直接抛 TypeError,而如果你手动转了一半留了一半,计算结果会变得非常奇怪且难以排查。
3.3 数据组织与检查清单
元数据提取之后,不要急着写计算代码,先用清单检查一遍数据完整性。我通常的做法是把所有待处理的影像按“产品版本号/获取日期/景号”三级目录组织,定标脚本只接收目录路径,这样做的好处是当 80 景影像处理到第 63 景报错时,你能立刻知道是哪一天、哪一个产品出的问题。检查清单包括:影像波段数和元数据波段数是否一致,MTL 里是否存在 NaN 或 0 值,太阳高度角是否落在合理范围内(正午成像一般 40° 到 70°,如果出现 0° 或 90° 基本可以断定元数据解析错了),日地距离是否在 0.983 到 1.017 这个范围之间。
还要检查一个容易被忽略的项:元数据版本。Landsat C2 产品的 MTL 里会有 COLLECTION_NUMBER 字段,如果你发现同一批次数据里混了 C1 和 C2,立刻停手,先把数据按版本分拣再继续。版本混用造成的误差不会直接报错,而是让你在后续所有定量结果里都背着一个隐蔽的系统误差,这种错误最难发现,最好在入口处就杜绝。
4. 用 Python 实现绝对辐射定标:从 DN 到表观反射率的完整流程
4.1 用 rasterio 打开影像并构造输出数据集
完成元数据解析后,进入真正的定标计算环节。我推荐用 rasterio 做这件事,它是对 GDAL 的 Python 封装,读写 GeoTIFF 很方便,而且直接支持 float32 输出。环境安装建议用 conda 一次性装好,避免源码编译 GDAL 的折腾:
conda create -n radiance python=3.11 conda activate radiance conda install -c conda-forge rasterio gdal numpy打开影像时有一个关键点:先确认像素数据类型。大多数原始 DN 影像都是 uint16,而定标公式里的增益和偏置是 float,直接把 uint16 数组和 float 标量相乘时,numpy 的自动类型提升会生成 float64,这在内存上直接翻倍。对于一景 Landsat 全幅影像(约 8000×8000 像元、7 个波段),float64 会占掉将近 3.5GB 内存,很容易把笔记本拖垮。正确的做法是读取时就用 rasterio 设置 dtype 为 float32:
import rasterio from rasterio.enums import Resampling def load_band_as_float32(band_path): """读取单波段 DN 影像,强制转换为 float32 返回""" with rasterio.open(band_path) as src: # 关键参数:out_dtype='float32' 在读取阶段完成类型转换 # 比读取后再 astype 省一笔中间内存开销 return src.read(1, out_dtype='float32') # 示例:读取红波段(Landsat 8 第 4 波段) red_dn = load_band_as_float32( 'LC08_L2SP_123032_20241010_..._SR_B4.TIF' ) print('读取完成,dtype =', red_dn.dtype, 'shape =', red_dn.shape)from pathlib import Path def check_metadata_complete(mtl_dict, required_keys): """确认元数据里的关键字段没有缺失,缺失时提前暴露错误""" missing = [k for k in required_keys if k not in mtl_dict] if missing: raise ValueError(f'元数据缺少字段: {missing}') return True required = ['RADIANCE_MULT_BAND_4', 'RADIANCE_ADD_BAND_4', 'REFLECTANCE_MULT_BAND_4', 'REFLECTANCE_ADD_BAND_4', 'SUN_ELEVATION', 'EARTH_SUN_DISTANCE'] check_metadata_complete(mtl, required)注意 rasterio 的out_dtype参数,它会在解码时直接把原始数据转换为目标类型,比read().astype(np.float32)少一次完整数组的拷贝。另外,如果你用 gdal_translate 或 gdal.Warp 做过预处理,一定要确认输出没有把 DN 裁成 8-bit,否则定标系数的有效位数就全浪费了。
4.2 DN 转辐亮度再转表观反射率的核心代码
现在给出完整链路。我们要在 float32 的 DN 数组上完成两步操作:先转辐亮度,再转表观反射率。辐亮度到反射率的转换需要太阳高度角和日地距离,Landsat C2 的元数据直接提供了这两个值,其中日地距离 EARTH_SUN_DISTANCE 可以进一步抽离成修正项。
import numpy as np def dn_to_radiance(dn, mult, add): """DN 转辐亮度:L = gain * DN + offset 输入 dn 必须是 float32 数组,mult/add 是元数据里的浮点定标系数""" return dn * mult + add def radiance_to_reflectance(radiance, sun_elevation, earth_sun_distance, esun): """辐亮度转表观反射率: reflectance = pi * radiance * d^2 / (ESUN * cos(theta_z)) 其中 theta_z = 90° - sun_elevation,太阳天顶角""" # 太阳高度角转天顶角,并统一为弧度制 theta_z = np.deg2rad(90.0 - sun_elevation) # 核心公式,注意 d^2 是日地距离平方 refl = np.pi * radiance * (earth_sun_distance ** 2) refl = refl / (esun * np.cos(theta_z)) return refl # 示例:Landsat 8 红波段常用 ESUN 值(W/m²/μm) # 严格来说不同波段不同,需按波段查表 ESUN_BAND4 = 2067.0 radiance = dn_to_radiance( red_dn, mtl['RADIANCE_MULT_BAND_4'], mtl['RADIANCE_ADD_BAND_4'] ) reflectance = radiance_to_reflectance( radiance, mtl['SUN_ELEVATION'], mtl['EARTH_SUN_DISTANCE'], ESUN_BAND4 ) print('辐亮度范围: %.3f ~ %.3f' % (radiance.min(), radiance.max())) print('反射率范围: %.4f ~ %.4f' % (reflectance.min(), reflectance.max()))参数说明:sun_elevation是太阳高度角,单位为度,元数据中直接给出;earth_sun_distance单位是天文单位,取平方是因为辐照度与距离成反比;esun是波段平均太阳辐照度,这个值不在 MTL 里,需要从数据发布方提供的定标手册中按波段查表。如果你不想自己查 ESUN,Landsat C2 产品还提供了一条更简洁的官方路线:直接使用元数据里的 REFLECTANCE_MULT_BAND_x 和 REFLECTANCE_ADD_BAND_x,公式变为 ρ = (Mρ × DN + Aρ) / sin(太阳高度角),这一步已经把所有系数的换算打包好了:
def dn_to_reflectance_fast(dn, refl_mult, refl_add, sun_elevation): """Landsat 官方简化公式:直接从 DN 得到表观反射率""" # 官方公式分母用 sin(sun_elevation),等价于 cos(天顶角) return (dn * refl_mult + refl_add) / np.sin(np.deg2rad(sun_elevation)) reflectance_fast = dn_to_reflectance_fast( red_dn, mtl['REFLECTANCE_MULT_BAND_4'], mtl['REFLECTANCE_ADD_BAND_4'], mtl['SUN_ELEVATION'] )两条路径的结果理论上应该在浮点精度内一致。如果差异超过 1%,说明你的 ESUN 取值或日地距离有误,这时候以官方简化公式的结果为准去反推排查。
4.3 批量处理多景影像并输出 GeoTIFF
单景做完之后,批量处理的核心就只剩下循环和文件命名管理。这里有一个我自己定下的规则:输出文件名里必须带上“处理时间”和“产品版本”,比如LC08_20241010_refl_f32.tif,这样三个月后回看数据,谁处理的、用的什么版本一目了然。批量脚本的组织方式如下:
import numpy as np import rasterio from pathlib import Path from datetime import datetime def calibrate_scene(dn_dir, mtl_path, output_dir, band_list): """处理单个场景的一组波段,输出定标后的多波段反射率 GeoTIFF""" mtl = parse_mtl(mtl_path) # 用第一个波段建立输出文件的投影和变换信息 first_band = sorted(band_list)[0] with rasterio.open(dn_dir / f'{first_band}.TIF') as src: profile = src.profile.copy() profile.update(dtype='float32', count=len(band_list), driver='GTiff', compress='lzw') # 注意:压缩用 LZW,浮点数据用 DEFLATE 会有精度损失风险 transform = src.transform out_path = output_dir / f'{Path(dn_dir).stem}_refl_{datetime.now():%Y%m%d}.tif' with rasterio.open(out_path, 'w', **profile) as dst: for i, band in enumerate(band_list, start=1): dn = load_band_as_float32(dn_dir / f'{band}.TIF') # 这里实际处理时可以从 mtl 按波段号取系数 refl = dn_to_reflectance_fast( dn, mtl[f'REFLECTANCE_MULT_{band}'], mtl[f'REFLECTANCE_ADD_{band}'], mtl['SUN_ELEVATION'] ) dst.write(refl.astype(np.float32), i) # 顺手把处理参数写入侧car文件,方便复查 with open(out_path.with_suffix('.log'), 'w', encoding='utf-8') as f: f.write(f'mtl: {mtl_path}\n') f.write(f'sun_elevation: {mtl["SUN_ELEVATION"]}\n') return out_path批量处理中有三个参数需要特别注意。第一个是压缩算法,浮点反射率数据用 LZW 是无损的,而默认的 DEFLATE 虽然压缩率更高,但处理不当会引入额外的计算开销,而且一些旧的 GIS 软件读取 DEFLATE 压缩的 float32 GeoTIFF 时会卡死。第二个是输出波段顺序,强烈建议和输入保持一致,不要自作主张调整顺序,否则后续做波段组合时容易张冠李戴。第三个是 profile 更新时一定要把 count 改成实际波段数,否则写入超过 count 的波段会直接抛异常。
4.4 定标结果正确性的快速自检
定标跑完不等于工作结束,你必须用几个硬指标验证输出没有系统性错误。最直接的验证是检查反射率的取值分布:表观反射率理论范围应该在 -0.01 到 1.5 之间,负值通常来自传感器暗电流偏置校正过度,超过 1 的值通常来自云、雪或镜面反射。如果整个影像的反射率均值超过了 0.5,几乎可以断定公式写错了。其次,选一个你熟悉的参考地物做单点验证:水体在近红外的反射率一般低于 0.05,裸土在红波段一般 0.2 到 0.4 之间,植被在近红外一般 0.3 到 0.7 之间。如果你的结果和这些常识量级不符,先回头检查系数,不要急着往下游走。
def quick_stat_report(refl_array, label): """输出定标结果的核心统计量,用于快速判断量级是否正确""" valid = refl_array[np.isfinite(refl_array)] print(f'[{label}]') print(f' 最小值: {valid.min():.4f}') print(f' 最大值: {valid.max():.4f}') print(f' 均值: {valid.mean():.4f}') print(f' 中位数: {np.median(valid):.4f}') # 反射率影像的正常中位数一般落在 0.05~0.30 if np.median(valid) > 0.5: print(' !! 警告:中位数异常偏高,请检查定标系数') quick_stat_report(reflectance_fast, 'Red Band TOA Reflectance')统计量之外,我还会在影像上随机取 5 个像元,用代码打印 DN、mult、add、sun_elevation 和最终反射率,做成一条完整的手工推算链路。这一步看似啰嗦,但当你需要向同行解释结果可信度时,这一条日志就是最有说服力的证据。
5. 绝对辐射定标避坑指南:五类高频翻车现场与排查方法
5.1 版本混用:C 系列产品参数拿旧版脚本算,结果偏了一个数量级
现象:同样一景影像,网上的参考脚本跑出来反射率最大值在 0.8 左右,自己跑出来只有 0.05,整体暗得发黑,而且所有波段都成比例偏小。原因:Landsat Collection 2 的定标系数与 Collection 1 不兼容,尤其是反射率增益项,C2 的 REFLECTANCE_MULT_BAND_x 是 C1 按比例缩放后的版本,直接用 C1 脚本处理 C2 数据,等于把系数错配到了一个完全不同的量级。解决:确认 mtl 文件中 COLLECTION_NUMBER 字段,处理前用脚本统一校验版本,元数据解析函数里加一个断言,遇到非目标版本直接抛错,别给批量处理留混用的机会。
5.2 太阳高度角与天顶角转换写反,NDVI 全片飘移
现象:定标后反射率整体被放大或缩小,植被区域的 NDVI 比其他来源的同类产品整体偏高约 0.05,而且正午影像和早晚影像的偏差不一致。原因:反射率公式里的角度修正项到底是 sin(高度角) 还是 cos(天顶角),很多人记混了。实际上太阳高度角 30° 时 sin 值是 0.5,cos(天顶角 60°) 也是 0.5,两者数学上等价,但如果你先算天顶角又用了 sin,分母就严重高估导致结果偏小。解决:统一按“反射率公式分母 = cos(太阳天顶角)”记忆,代码中显式写np.cos(np.deg2rad(90 - sun_elevation)),不要用 sin 或 cos 的其他英文缩写。
5.3 忘掉数据类型转换,uint16 乘浮点系数后整景翻车
现象:脚本跑完没有报错,但输出影像出现大量斑块状异常值,直方图呈阶梯状,低值区域全部变成 0。原因:uint16 数组乘以 float 标量时,numpy 会把结果提升为 float64,但如果某个中间步骤用了 in-place 运算或写回了 uint16 数组,小数部分就被直接截断,增益偏置的作用被吞掉,严重时整幅影像的暗像元全部归零。解决:定标前在读取阶段强制转 float32,定标后也用 float32 存储;在代码里对最终数组执行一次np.isfinite检查,把非有限值直接暴露出来,不要让它带着进下游。
5.4 山区影像强行用固定太阳角度定标
现象:一景山区影像定标后,阴坡的反射率整体比阳坡低很多,同样地物在南北坡的光谱曲线差异远超合理范围,拿去分类时把同一种植被分成了两类。原因:反射率公式里的太阳角度修正只考虑了太阳位置,没有考虑地形坡度坡向。卫星接收到的辐射强度在一个坡面上和在一个水平面上完全不同,固定角度修正会把地形阴影误当成地物光谱特征。解决:如果研究区地形起伏超过 300 米,建议先做地形校正(如简单的余弦校正或更严格的 C 校正),再做辐射定标;如果只是平坦区域,这个坑可以跳过。
5.5 云和饱和像元没处理,验证时被“异常元”误导
现象:验证反射率范围时发现最大值达到了 5 甚至更高,一开始以为是定标系数错,折腾半天发现是云顶反射和传感器饱和像元混在里面。原因:云在可见光波段的反射率可以超过 0.8,加上镜面反射效应,某些像元值超出传感器的线性响应范围,定标公式在饱和区域失效。解决:在定标流程里加一步质量掩膜,用 QA 波段(Landsat C2 提供了 QA_PIXEL 波段)把云、云影、冰雪像元标记为 0,再做后续统计;如果手头没有 QA 波段,至少也要在统计反射率时剔除大于 1.0 的值。
6. 定标结果的验证与进阶:把辐射定标做成预处理流水线的一环
6.1 第一眼验证:拉一条光谱剖面和官方 L2 产品对比
定标有没有做对,最好的参照物是官方发布的地表反射率产品。Landsat C2 的 L2 产品直接提供了 SR_B 系列地表反射率,虽然它多了大气校正这一步,但你可以拿一景同一区域的 L1 影像定标后再做一次大气校正,两者对比。更快的办法是做一个“光谱剖面验证”:在影像上用经纬度取同一个像元,把你的定标结果和官方 L2 产品的值放到同一坐标系里比较。如果两者差异在 0.02 以内,说明你的定标链路的传感器部分是正确的;差异主要来自大气校正与否。这里放一段提取单像元光谱的代码:
def extract_pixel_spectrum(stack_path, lon, lat, band_names): """从定标后的多波段影像中提取单像元光谱曲线""" import rasterio with rasterio.open(stack_path) as src: # rasterio 的 index 直接用投影坐标转行列号 row, col = src.index(lon, lat) spectrum = src.read( range(1, len(band_names) + 1), window=((row-1, row), (col-1, col)) ).flatten() return dict(zip(band_names, spectrum)) # 用法示例:取某一块水体像元,看近红外是否低于 0.05 spectrum = extract_pixel_spectrum( 'LC08_20241010_refl_f32.tif', 116.352, 39.873, ['B', 'G', 'R', 'NIR'] ) print(spectrum)6.2 把定标写成预处理流水线的一个环节
单独跑通一次定标并不难,难的是让定标在深度学习和时间序列分析里稳定复现。我现在的习惯是把定标函数封装成独立的 Python 模块,只暴露一个接口:输入目录和输出目录,内部处理元数据解析、系数校验、批量定标、质量掩膜,并把统计日志写入 JSON 文件。下游不管是做 NDVI 还是做样本切片,都只读定标后的反射率 GeoTIFF。这样做的好处是,换了一个研究区,换了一批发行的卫星影像,只需要改元数据解析那一层,计算逻辑不用动,这也是我踩过前面这些坑之后养成的习惯。
6.3 定标之后:大气校正的一步之遥
如果表观反射率已经满足大部分应用需求,定标工作在这里可以收尾。但如果你要反演叶面积指数、生物量或者做跨传感器的地表参数对比,建议再走一步大气校正。主流做法是用辐射传输模型正查表(比如经典的路径辐射和透过率表),或者直接用成熟的校正工具处理。需要记住的是:绝对辐射定标是大气的输入,不是它的替代。很多人以为做完定标就能直接和地面实测光谱对比,实际上那是大气校正的职责。
从第一次写定标脚本到现在,我自己最深的感触是:千万别把遥感影像当作一张普通图片去处理,定标是让数据具备物理意义的最低门槛。回到开头那 80 景影像的教训,我现在每批数据交付前都会人工抽查 3 个地物点的反射率,并且把 MTL 里的产品版本号、太阳角度、处理时间写成日志,宁可检查多花十分钟,也绝不交付一个带着隐蔽系统误差的成果。希望这篇文章能帮你少走几趟弯路,把绝对辐射定标这一步走得稳稳当当。
本文还有配套的精品资源,点击获取