用C#和GDAL计算NDVI:从逐像元遍历到栅格计算器实战
2026/9/16 15:46:20 网站建设 项目流程

简介:这是一份面向C#开发者和遥感初学者的NDVI计算示例工程,解决在.NET环境下处理栅格数据、计算归一化植被指数的问题。压缩包共26个文件,约108KB,包含7个.cs源码文件、Visual Studio解决方案与项目配置(.sln、.csproj)、可直接运行的.exe程序及.pdb调试符号,另有界面设计、资源和设置文件,便于重新编译、调试或按需改造。目前已有506人学习下载。代码覆盖逐像素遍历栅格和调用栅格计算器两种实现方式,体现NDVI公式(NIR-Red)/(NIR+Red)的落地过程,并涉及GDAL/ENVI类库操作、多线程优化、ArcGIS/ArcPy脚本调用等常用技术,可帮助读者掌握遥感栅格数据读取、像素计算和结果输出,也能迁移到其他植被指数或波段运算场景。

1. 用 C# 重写 NDVI 计算:不只是把公式抄进代码

遥感里算 NDVI 看起来就是一个减法和一个除法的组合,公式简单到一行就能写完。但真要把这个公式落到一批真实栅格上,事情就变了:数据格式怎么读、波段顺序哪个是红哪个是近红外、遇到 NoData 是当 0 算还是直接丢、一景 Landsat 几千万个像元要跑多久——这些才是实际工作里真正花时间的地方。

这个 NDVI.zip 里的工程恰好把 C# 做栅格计算的两种典型路径都覆盖了:一种是手动遍历逐像元处理,另一种是调用栅格计算器式的地图代数表达式。前者适合你理解每个像元发生了什么,后者适合批量生产。读取波段数据用的是 GDAL 的 C# 绑定,这也是目前 .NET 生态里操作栅格最主流的方案。适合谁?GIS 开发、遥感数据处理、或者要在 C# 上位机里集成影像分析能力的人。往下读之前,你需要知道一点:NDVI 不是唯一答案,但它是理解栅格计算器语法和像元遍历逻辑最好的切入点。

2. GDAL 读栅格与 C# 里组织红、近红外波段

2.1 引用 GDAL 的 C# 绑定

在 Visual Studio 里新建一个 WinForms 项目,通过 NuGet 装 GDAL 包。这个工程的 .sln 和 .csproj 结构说明它本身就是按 Visual Studio 方案组织的,所以直接还原包再编译就行。

<PackageReference Include="GDAL" Version="3.6.2" />

装完以后必须在程序入口注册驱动。不写这一步,Open 文件会直接抛异常,而且错误信息会误导你以为是路径错了。

using OSGeo.GDAL; using OSGeo.GDAL; Gdal.AllRegister();

AllRegister会把 GDAL 内置的几十种栅格驱动全部注册,包括 GeoTIFF、ENVI、HFA 等。实际开发里你不需要手动指定用哪个驱动,GDAL 会根据文件头自动识别。项目里和 GDAL 相关的原生 dll 会输出到 Debug 目录,注意保持gdal目录和你的 exe 同级,否则运行时报找不到gdal111.dll之类的错。

2.2 读取红光波段与近红外波段的两种方式

GDAL 读栅格数据不按波段名走,按波段序号走。常规的多光谱影像里,波段 3 是红光,波段 4 是近红外,但这只是 Landsat 的排列习惯,不同传感器不一样。最稳妥的方式是从元数据里读波段名称,或者用一个配置文件写明波段顺序。

using (Dataset ds = Gdal.Open("landsat8_multiband.tif", Access.GA_ReadOnly)) { Band redBand = ds.GetRasterBand(3); // 红光波段 Band nirBand = ds.GetRasterBand(4); // 近红外波段 int width = ds.RasterXSize; int height = ds.RasterYSize; float[] redData = new float[width * height]; float[] nirData = new float[width * height]; redBand.ReadRaster(0, 0, width, height, redData, width, height, 0, 0); nirBand.ReadRaster(0, 0, width, height, nirData, width, height, 0, 0); }

ReadRaster的参数从左到右依次是:读取起始列的像素坐标、起始行的像素坐标、读取窗口的列数、读取窗口的行数、存放数据的目标数组、目标数组的列宽、目标数组的行高、X 方向像素采样间隔、Y 方向像素采样间隔。最后两个参数设为 0 表示不隔行采样,每个像素都读;如果设成 2,就相当于降采样到一半分辨率。

Band 序号从 1 开始计数,这和 C# 数组从 0 开始相反,新手很容易在这里弄差一个——读到的数据实际是近红外波段而不是红光,算出来的 NDVI 会整体偏低,而且公式里分母不会为负,从直方图上不容易一眼看出来。

3. 逐像元计算 NDVI 与多线程分块策略

3.1 单线程遍历与公式陷阱

拿到两个波段的数组后,NDVI 的计算就是逐像元套公式。但浮点数运算在这里有细节:分母NIR + Red可能为零,比如影像里存在全黑像元或无效区,直接除会得到 NaN 或无穷大,写到栅格里就成了坏值。

float[] ndvi = new float[width * height]; for (int i = 0; i < redData.Length; i++) { float sum = nirData[i] + redData[i]; if (Math.Abs(sum) < 1e-6f) { ndvi[i] = -9999f; // 无效值标记 } else { ndvi[i] = (nirData[i] - redData[i]) / sum; } }

分母接近零的判断阈值1e-6f不是拍脑袋定的。GDAL 读出来的反射率数据一般做了定标,数值范围在 0 到 1 之间,极小值意味着该像元基本没有有效反射信号,直接参与计算会把噪声放大成异常高值。把无效值设置成 -9999 是遥感数据的老传统,ArcGIS 和 QGIS 都能识别这个约定,后续做统计分析时可以直接掩膜掉。

3.2 多线程分块与数组边界处理

大影像单线程遍历是最容易想到也最容易无聊的写法。一景 8000×8000 的影像有 6400 万个像元,C# 里纯运算大概要几秒,加上波段读取的时间也还在可接受范围。但如果是批量处理几十景影像,就得做多线程分块,而且不能简单地把整个数组按索引切成几段。

Parallel.ForEach( Partitioner.Create(0, redData.Length, width * 512), range => { for (int i = range.Item1; i < range.Item2; i++) { float sum = nirData[i] + redData[i]; ndvi[i] = Math.Abs(sum) < 1e-6f ? -9999f : (nirData[i] - redData[i]) / sum; } });

Partitioner.Create的第三个参数是每个分块的大小,这里按width * 512来设置,意思是每次读取 512 行像素作为一个计算块。这样做的好处是让线程调度器减少跨线程的数据争用,比直接用Parallel.For从 0 到 Length 逐元素循环要快得多。分块颗粒度的选择参考的是 CPU 的 L2 Cache 大小,512 行 × 8000 列 × 4 字节大约 16 MB,刚好能塞进主流服务器 CPU 的 LLC(最后一级缓存)。

3.3 数据类型的选择:float 还是 double

GDAL 读出来的反射率波段默认是 float 还是 double,取决于影像文件内部的数据类型。多数 GeoTIFF 用 Float32 存储,ReadRaster传 float 数组可以直接命中底层数据类型,避免一次类型转换。double 精度更高但在这种场景下没实际收益——NDVI 的结果通常只保留两三位小数用于后续分类。如果读的是整型存储的 DN 值影像,需要先除以定标系数转换成反射率再做计算,这属于数据预处理的事情,但放在同一段代码里的话要格外注意变量类型混用导致计算精度丢失。

4. 栅格计算器表达式与 C# 调用 ArcGIS Raster Calculator 的差异

4.1 栅格计算器的表达式本质

逐像元遍历适合你完全掌控计算过程,但实际工程里很多团队更倾向于使用栅格计算器直接写表达式。ArcGIS 的 Raster Calculator 和 QGIS 的 Raster Calculator 底层用的都是地图代数语法,NDVI 的写法几乎是同构的:

Float("B4" - "B3") / Float("B4" + "B3")

外面套的Float很关键。如果波段数据是整型,运算时会做整数除法,NDVI 在植被区通常是 0.6 到 0.8 的小数,整数除法直接把小数部分丢了,结果全变成 0 或 1。写成Float强制转换为浮点数运算,相当于在表达式的层面修复了数据类型问题。

C# 工程里实现这个的方式,通常是找到 ArcGIS 的安装目录,在工程里添加对 ESRI.ArcGIS.SpatialAnalyst 的引用,通过栅格计算器 API 把字符串表达式作为参数传进去。下面的代码是一个可运行的模板:

using ESRI.ArcGIS.DataSourcesRaster; using ESRI.ArcGIS.SpatialAnalyst; IRasterDataset rasterDataset = ...; IRasterAnalysisEnvironment env = rasterDataset as IRasterAnalysisEnvironment; IMapAlgebraOp mapAlgebra = new RasterMathOpsClass(); mapAlgebra.SetAnalysisEnvironment(env); IRaster outRaster = mapAlgebra.Execute( "Float(\"B4\" - \"B3\") / Float(\"B4\" + \"B3\")" ) as IRaster;

GIS 软件在执行这段表达式之前会先做语法解析,双引号里的 B4 和 B3 会被识别为波段变量,而不是文件路径。变量名的解析规则各家软件不完全一样:ArcGIS 直接用波段在图层里的显示名;QGIS 的表达式需要用@波段名前缀引用,或者用双引号引完整路径。那个Float是所有 GIS 栅格计算器的通用约定,遇到整型影像算出来全是 0 和 1 的时候,先检查这里。

4.2 两种实现路径的对比与取舍

对比维度逐像元遍历栅格计算器
数据类型控制完全代码内控制靠表达式内的类型转换函数
性能上限取决于代码质量,可控取决于 GIS 软件底层实现
依赖外部软件只需要 GDAL,可部署到 Linux需要 ArcGIS/QGIS 环境,Windows 桌面为主
表达式可维护性改代码逻辑,重新编译改字符串,热更新
批处理全自动自己写循环就行需要调用 ArcPy 或 QGIS Processing

两个方案在 C# 工程里可以共存:用 GDAL 读元数据,然后用栅格计算器 API 计算。我一般会在界面层做成动态选择,给用户一个表达式输入框,默认填好标准 NDVI 表达式,方便扩展成 EVI、SAVI 等其他植被指数。

5. NDVI 栅格属性分析与异常像元处理

5.1 读取 NDVI 栅格的属性信息与统计特征

NDVI 算完之后要能输出给别人用,栅格属性这一环就不能省。GDAL 里读栅格属性最常用的是读取栅格统计信息(包括最小值、最大值、均值、标准差)和投影信息。C# 里这样取:

Band ndviBand = ds.GetRasterBand(1); double min, max, mean, stddev; ndviBand.ComputeStatistics(0, out min, out max, out mean, out stddev, null, null); string projection = ds.GetProjectionRef(); double[] geoTransform = new double[6]; ds.GetGeoTransform(geoTransform);

六个数字组成的 GeoTransform 数组代表栅格左上角原点坐标、像元宽高和旋转系数,这是所有栅格空间定位的基础。NDVI 结果要和原始影像保持完全一致的 GeoTransform,否则后续叠加到地图上会偏位。输出栅格时需要手动把这个数组写到新文件里,GDAL 不会自动帮你拷贝。

5.2 NaN 与 NoData 掩膜:处理那些算不出来的像元

正常情况下 NDVI 应该在 -1 到 1 之间,但实际数据里总会出现超出这个范围的异常值。水体在红光和近红外的反射率都极低,两者相减除以求和之后,噪声会把比值顶到很大。遇到这种情况,不要在计算之前做全局的阈值过滤——GEOS 反射率再低也不是 0,真正要过滤的是云和阴影。

public static float NormalizeNdvi(float rawValue) { if (rawValue < -1.5f || rawValue > 1.5f) { return -9999f; } return rawValue; }

阈值设定在 ±1.5 而不是 ±1,是因为气溶胶和水汽吸收会引入轻微的系统偏差,正常植被像元不会突破这个范围,但薄云边缘偶尔会出现 1.2 这种值。这个值在 NDVI 计算中保留一定余量比严格设置 ±1 更实用——如果未来要做时序分析,把 1.2 硬设成无效值会让像元序列断掉。NoData 值设为 -9999 后,要在输出栅格上用SetNoDataValue注册:

ndviBand.SetNoDataValue(-9999f);

如果不设置 NoData 值,下游拿数据的人会把这批 -9999 当成真实 NDVI 参与统计,均值会被拉成一个毫无意义的大负数。

6. 批量计算多景影像的 NDVI 并保持栅格属性一致

这里给出一个可以直接套用的批量处理流程。多景影像的 NDVI 计算在工程上的核心诉求不是算得准——公式都摆在那里,算不准是自己代码的问题——而是每景影像输出之后,属性信息不能丢、命名不能乱、异常值处理策略全局一致。

遍历目录下的所有 TIFF 文件,逐个处理,参考以下方式组织处理管线:

string[] inputFiles = Directory.GetFiles(inputFolder, "*_B3.tif"); foreach (string redFile in inputFiles) { string nirFile = redFile.Replace("_B3.tif", "_B4.tif"); string outputFile = Path.Combine(outputFolder, Path.GetFileName(redFile).Replace("_B3.tif", "_NDVI.tif")); if (!File.Exists(nirFile)) { continue; // 跳过缺波段的影像,不中断整批任务 } ProcessNdvi(redFile, nirFile, outputFile); }

这里用了最简单的字符串替换来配对红波段和近红外波段文件,不要小看这个设计:生产环境里文件名是最不可靠的元数据,因为不同卫星数据的命名规范差异很大。我有一次处理 Sentinel-2 的数据,文件名末尾编号的两位是波段编号,看起来是 04 和 08,结果函数里为了补零做了一次字符串拼接,跑到第 2000 景影像的时候才发现从第 1000 景开始正则表达式匹配错了波段,整批结果作废重跑。更稳的做法是从 TIFF 文件的元数据横幅里读取波段描述字符串,再匹配到“Red”和“Near Infrared”关键字,Git 提交记录和同事评审都会感谢你这个决定。

输出前做一次像元统计验证算出来的 NDVI 是否落在合理区间,是最后一道保险:

Band outBand = outDs.GetRasterBand(1); double min, max, mean, stddev; outBand.ComputeStatistics(0, out min, out max, out mean, out stddev, null, null); if (mean < -0.2 || mean > 0.9) { Console.WriteLine($"警告: {outputFile} 平均 NDVI 异常, 请检查红/近红外波段顺序"); }

这个均值范围的经验判据是基于大量 Landsat 和 Sentinel-2 影像统计出来的——全植被覆盖区的平均 NDVI 很难超过 0.9,完全无水无植被的裸地均值很少低于 -0.2。均值落在范围外,大概率是波段顺序接反了,而不是地表真的出现了极端情况。检查波段顺序的步骤放这里做比最开始就校验要更符合实际开发流程——很多数据源的波段顺序在你把代码写好之后才会遇到,运行时告警比启动时全盘检查更高效。这种流水线风格的处理流程和 C# 写上位机、扫码枪触发事件的那种实时交互思路不一样,栅格计算不追求微秒级响应,追求的是异常率尽可能低、出错了能被统计特征直接抓出来。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询