☰
Python GDAL栅格实战:tif转数组与数组转tif的完整方法
2026/10/14 1:25:59 网站建设 项目流程

处理了一批流域DEM数据后,我越来越确定一件事:在Python里做栅格分析,最值得先练熟的基本功,就是GDAL的“tif转数组、数组转tif”。因为不管是算坡度、算NDVI,还是做掩膜、重分类,最终都绕不开这一步。你不可能永远开着桌面GIS软件手工点菜单,尤其是批量处理几十个文件的时候,脚本才是最靠谱的。但这项基础操作也是新手最容易翻车的地方——写出一个能打开的tif很容易,写出一个带坐标、带投影、能正确叠加到地图上的tif,那是另一回事。

这篇东西不会讲那些花哨的遥感算法,就围绕“读取栅格数据转换成数组,数组转换栅格数据(tif文件)”这条主线,把我实际项目中用到的完整流程、踩过的坑、以及最后沉淀下来的通用写法,一次讲透。

1. 为什么“tif转数组”是栅格处理的必经之路

1.1 栅格数据在GDAL里的真实形态

大多数人对tif文件的认知就是一个“图片”,双击能打开,用看图软件能看到山山水水。但在GDAL眼里,tif文件的结构比图片多两层东西:文件本身是一个Dataset,里面有若干个RasterBand(波段),每个波段才真正存放着像元值。DEM通常是单波段,遥感影像通常是多波段(红、绿、蓝,甚至包含近红外)。

这就解释了为什么新手第一次写GDAL代码时会愣住:gdal.Open()拿到的不是数组,而是一个Dataset对象。必须通过GetRasterBand(1)拿到波段,再调用ReadAsArray(),才能得到numpy数组。这个“文件→波段→数组”的三层结构,是理解一切GDAL操作的前提。很多人卡住,不是代码写错,而是脑子里没有这个模型。

波段里存的才是数值,文件对象上挂的是地理参考信息。把这个模型记住,后面的读和写都不会乱。

1.2 数组模式带来的三个优势

把tif转成numpy数组之后,最直观的好处有三个。

第一,计算自由。numpy的广播机制和科学计算函数,让栅格计算变成纯粹的矩阵运算。比如影像的NDVI,本质就是(近红外-红)/(近红外+红),直接用数组算,几行代码搞定,还不用关心波段顺序。

第二,生态联动。数组出来后,可以无缝对接scipy的滤波、skimage的分割、pandas的统计,甚至扔给机器学习模型做训练。栅格数据的本质是“矩阵+地理坐标”,矩阵部分用数组处理,坐标部分留给GDAL,职责非常清晰。

第三,批量处理。循环读一堆tif,转成数组,统一计算,最后统一写回。这个模式在批量拼图、批量裁剪、批量统计里几乎是无敌的。手工在GIS软件里点100个文件的重复操作,脚本只需要几秒钟。

2. 环境准备:GDAL安装与版本问题的实战结论

2.1 最稳的安装姿势

GDAL的安装是个老生常谈的坑,网上各种“装不上、导入失败”的求助帖遍地都是。我的建议很直接:优先用conda,不要裸pip,除非你知道自己为什么非pip不可。

conda install -c conda-forge gdal

conda-forge的GDAL会把依赖全部打包解决好,包括底层C库、proj、geos这些。如果用pip,经常遇到gdal包和系统里已有的GDAL库版本对不上,或者缺少DLL/so文件导致from osgeo import gdal直接报错。

如果确实只能用pip,记住一个原则:pip版本号必须和系统GDAL主版本号完全对应。先跑gdalinfo --version看系统版本,再装对应版本:

pip install GDAL==3.6.2

版本错一位,导入时就会报DLL load failed或者undefined symbol之类莫名其妙的问题。

2.2 验证安装与导入路径

装好之后,不要急着处理数据,先跑一个探针脚本确认环境没问题:

from osgeo import gdal print(gdal.VersionInfo())

能输出类似3060200的版本号,说明导入成功。这里要留意一点:很多人喜欢写import gdal,在新版GDAL中这已经不行了,标准写法是from osgeo import gdal。旧教程误导了不少人,如果你照着老代码写import gdal,会直接ModuleNotFoundError。

另外,如果你处理的数据量特别大,或者涉及WGS84、UTM等多种投影转换,建议把gdal.UseExceptions()打开。默认情况下GDAL报错是往stderr输出但不抛出异常,程序跑着跑着你都不知道哪一步失败了。开了UseExceptions之后,错误会以Python异常形式抛出来,定位问题方便很多。

3. 读取tif转数组:三层调用链与代码拆解

3.1 核心调用链:Open → GetRasterBand → ReadAsArray

读取tif转数组的标准姿势,本质是“三次握手”:先Open拿到Dataset,再GetRasterBand拿到波段,最后ReadAsArray转成数组。下面是单波段DEM的完整示例:

from osgeo import gdal def read_single_band(path): # 1. 打开文件 ds = gdal.Open(path, gdal.GA_ReadOnly) if ds is None: raise ValueError(f"无法打开文件: {path}") # 2. 拿到基本信息(先别急着读数组,信息很重要) cols = ds.RasterXSize # 列数 = 宽度 rows = ds.RasterYSize # 行数 = 高度 bands_count = ds.RasterCount geotransform = ds.GetGeoTransform() # 六参数仿射变换 projection = ds.GetProjection() # 投影字符串 # 3. 读取第1个波段 band = ds.GetRasterBand(1) data = band.ReadAsArray() # 返回 numpy.ndarray print(f"尺寸: {rows} 行 x {cols} 列") print(f"波段数: {bands_count}") print(f"像元类型: {gdal.GetDataTypeName(band.DataType)}") print(f"数据形状: {data.shape}") print(f"值范围: {data.min()} ~ {data.max()}") return data, geotransform, projection

这里有个特别容易搞混的点:data.shape返回的是(rows, cols),也就是(RasterYSize, RasterXSize),先y后x。而RasterXSize是列数(宽度),RasterYSize是行数(高度)。很多人第一次写代码时会把这两个对调,导致后面写tif时宽高颠倒,输出影像被拉变形。记住一句话:shape是先行后列,栅格尺寸是先列后行。

3.2 读多波段和按窗口裁剪读取

处理遥感影像时通常要读多个波段。我建议不要循环里反复调ReadAsArray(),而是用一个列表接住,再用numpy堆叠,这样维度才是(波段, 行, 列),方便后续批量运算:

def read_multiband(path): ds = gdal.Open(path, gdal.GA_ReadOnly) bands = [] for i in range(1, ds.RasterCount + 1): band = ds.GetRasterBand(i) bands.append(band.ReadAsArray()) # 堆叠成 (band_count, rows, cols) if len(bands) > 1: data = np.stack(bands, axis=0) else: data = bands[0] return data, ds.GetGeoTransform(), ds.GetProjection()

如果只需要某个局部范围,ReadAsArray支持传窗口参数,按需裁剪读取,不用把整幅影像都加载进内存:

# ReadAsArray(xoff, yoff, xsize, ysize) # 注意xoff是列偏移(从左往右),yoff是行偏移(从上往下) sub_data = band.ReadAsArray(100, 100, 512, 512)

这个功能在“只想看某块区域”或者“分块处理大图”时特别重要。比如一张20000×20000的影像,全量读进内存可能要1.6GB(float32),机器差一点直接卡死,按窗口读就轻松很多。

3.3 顺手取走地理参考信息

很多教程只顾着读数组,完全忽略Geotransform和Projection,这是个大隐患。因为你读数据的时候,地理参考信息跟着文件走,是不花钱的;等你想把数组写回tif时,再想找这些信息就得回头重读文件。更糟的是,如果你只拿到了数组,没有地理参考,那这个数组就是“裸数据”,写出去就是一张没有坐标的图片,没法叠加到GIS地图里。

所以我的习惯是:读tif时用三个变量同时接收,数组、仿射变换、投影,一次全拿走:

data, gt, prj = read_single_band(r"D:\test_data\dem.tif")

关于Geotransform六参数的含义,这里展开讲一下,因为它决定了你写回tif时坐标是否准:

GT(0) 左上角x坐标(东向) GT(1) x方向分辨率(像元宽度) GT(2) x方向旋转项,通常为0 GT(3) 左上角y坐标(北向) GT(4) y方向旋转项,通常为0 GT(5) y方向分辨率(像元高度,通常为负数)

最后一项GT(5)是负数,很多新手不理解。原因是栅格的行号是从上往下数的,但地理坐标系的y轴是向北增加的,所以从左上角往下走,y坐标是递减的。这个负数如果写错了,比如写成正数,影像会上下颠倒,坐标全乱。

4. 数组转tif:带齐“地理三件套”才叫遥感文件

4.1 WriteArray不是全部,核心是“三件套”

数组转tif的核心API是driver.Create()+band.WriteArray(),但只做这两步,写出来的文件就是一张“裸图”。真正让它变成能被GIS软件正确识别的遥感文件,靠的是“地理三件套”:仿射变换(GeoTransform)、投影(Projection)、NoData值。

仿射变换告诉软件“数组的每一格对应地面多大地范围、放在什么位置”,投影告诉软件“这套坐标是基于什么坐标系”。缺了这两个,相当于你拿着一个有刻度但没有零点和比例尺的尺子,量出的数据谁都不知道怎么用。NoData值则告诉软件“哪些像元是无效数据,不该参与渲染和计算”。

4.2 完整写回流程

下面是我实际项目中一直在用的完整流程:

import numpy as np from osgeo import gdal def array_to_tif(array, output_path, geotransform, projection, no_data=-9999, data_type=gdal.GDT_Float32, compress=True): """ array: numpy数组,形状为 (rows, cols) 或 (bands, rows, cols) geotransform: 仿射变换六元组 projection: 投影字符串(WKT格式) no_data: 无效值 compress: 是否启用LZW无损压缩 """ # 统一规格化到 (bands, rows, cols) if array.ndim == 2: array = array[np.newaxis, :, :] bands, rows, cols = array.shape # 1. 创建驱动 driver = gdal.GetDriverByName("GTiff") # 2. 准备创建参数 options = [] if compress: options.append("COMPRESS=LZW") options.append("TILED=YES") if cols * rows > 5000 * 5000: options.append("BIGTIFF=YES") # 3. 创建空文件(注意参数顺序:列数在前,行数在后) out_ds = driver.Create(output_path, cols, rows, bands, data_type, options=options) # 4. 写入地理三件套 out_ds.SetGeoTransform(geotransform) out_ds.SetProjection(projection) # 5. 逐波段写数组 for i in range(bands): out_band = out_ds.GetRasterBand(i + 1) out_band.WriteArray(array[i, :, :]) if no_data is not None: out_band.SetNoDataValue(no_data) # 6. 强制写入磁盘并释放句柄 out_ds.FlushCache() out_ds = None print(f"已写出: {output_path}")

这段代码看着长,其实每一块都有明确作用,拆开讲:

  • driver = gdal.GetDriverByName("GTiff"):先获取GTiff格式的驱动,相当于告诉GDAL“我要生成哪种格式的文件”。
  • driver.Create()的第二个和第三个参数是列数和行数,这个顺序前面强调过,和numpy的shape正好是反的,写错就是一场灾难。
  • out_ds.SetGeoTransform(geotransform):把从原文件读出来的仿射变换原样写回。注意,数组本身不携带任何坐标信息,这一步是给数组“定位”的。
  • out_ds.SetProjection(projection):写入投影字符串。如果projection为空字符串,那就真的输出一个没坐标的图。
  • SetNoDataValue(no_data):告诉后续软件,像元值等于-9999的都是无效值。比如多数软件的统计直方图就不会把这个值算进去。

4.3 多波段和压缩的实操细节

如果是多波段数组,改动点只有一个,就是driver.Create里的波段数参数:

# 数组形状: (4, rows, cols),4个波段 bands = array.shape[0] out_ds = driver.Create(output_path, cols, rows, bands, gdal.GDT_Byte, options=options)

循环写入各波段正常写就行。

关于压缩,我多说一句。很多人对COMPRESS=LZW有顾虑,怕压缩影响读取速度。实际上LZW是无损压缩,而且在大多数场景下读取速度几乎没影响,文件体积却能小一半以上。特别是DEM、分类图这种相邻像元值变化平缓的数据,压缩率相当可观。如果你的tif要用于Web地图发布,建议再加一个TILED=YES,这一步把数据按块组织,对于后续按需读取非常有用。

还要注意一点:如果写入的数组是float32类型,而创建时指定的是GDT_Byte(8位整型),WriteArray会把数据强制截断。当你发现输出tif打开是一片漆黑,或者数值全变成了255、0这种极端整数,大概率就是数据类型不对。判断数据类型的口诀:短整型用GDT_Int16、无符号短整型用GDT_UInt16、浮点单精度用GDT_Float32。DEM和NDVI结果通常用Float32,分类结果用Byte或Int16。

5. 真实项目中反复踩到的六个坑

5.1 像元类型错位导致“花屏”

有一次我处理某图像处理Demo的中间结果,数组是float类型,里面存着带小数的高程值,我却以GDT_Byte创建tif。结果所有小数被截断,大于255的值溢出,整幅影像打开后一片模糊,像被劣质压缩过一样。排查了半天才发现是类型错位。

从此我养成一个习惯:写tif之前先看一眼array.dtype,再决定用哪个GDT常量。两者对应关系如下:

numpy dtypeGDAL 数据类型
int8gdal.GDT_Int16
uint8gdal.GDT_Byte
int16gdal.GDT_Int16
uint16gdal.GDT_UInt16
float32gdal.GDT_Float32
float64gdal.GDT_Float64

如果你的数组是float64,输出文件用Float32,一般精度足够了,文件还能小一半。但反过来,拿float数组去写Byte文件,我劝你别试。

5.2 无效值-9999污染统计结果

DEM数据里经常用-9999表示无数据区域,但这个值如果没在读取时处理掉,后续所有计算都会被污染。最典型的场景:你转成数组后直接np.mean(data),如果影像有10%的区域是-9999,均值会被拉低一大截,算出来的坡向、坡度、剖面曲率全是错的。

正确做法是在读取后马上处理:

data, gt, prj = read_single_band(r"D:\test\dem.tif") data = data.astype(np.float32) data[data == -9999] = np.nan # 先转成NaN,后续计算忽略

计算完成后再把NaN转回-9999写回tif。另外要注意,tif文件本身不支持NaN,所以写回前必须做转换,否则GDAL会警告或者写入失败。

5.3 中文路径让GDAL直接罢工

GDAL对中文路径的支持一直是玄学。在Windows下,gdal.Open一个路径包含中文的tif时,经常直接返回None,甚至程序不报错就那么静默失败。很多人第一次遇到会一脸懵,仔细看代码没问题、文件确定存在,就是读不出来。

我的建议很简单粗暴:项目文件和中间产物一律放英文路径,内部文件夹用D:\work\data、D:\work\output这种结构。如果你手里已经有一批中文路径的tif,可以用shutil.copy先把文件复制到英文临时目录,处理后结果也输出到英文路径,最后再搬回中文目录。别在这一步上耗时间,纯属浪费生命。

5.4 大数组内存暴涨

栅格数据最容易被低估的风险就是内存。一张10000×10000的float32影像,数组占了400MB;如果是4波段的遥感影像,轻轻松松超过1.6GB。机器内存不够时,程序会在ReadAsArray那一步卡死甚至崩溃。

解决办法是分块读写。读取时按窗口循环,处理完一个窗口释放一块:

def read_by_blocks(path, block_size=2048): ds = gdal.Open(path, gdal.GA_ReadOnly) band = ds.GetRasterBand(1) rows, cols = band.YSize, band.XSize # 按窗口遍历 for y in range(0, rows, block_size): for x in range(0, cols, block_size): win_xsize = min(block_size, cols - x) win_ysize = min(block_size, rows - y) block = band.ReadAsArray(x, y, win_xsize, win_ysize) # 对block做处理 yield block, x, y

写入时同样按块写,每次WriteArray(block, x, y),这样即使单块数据再大,内存占用也稳定在一个块的大小。这一招在处理几百MB以上的大tif时几乎是必须的。

5.5 文件句柄泄露导致“文件被占用”

连续处理几十个tif文件时,如果每次打开Dataset后不释放资源,哪怕再小的文件,最后也会报出Permission denied或者“文件正由另一进程使用,因此该进程无法访问此文件”。GDAL打开的文件句柄不会自动回收,必须显式关闭。

释放句柄的标准姿势很简单:

ds = gdal.Open(path) # ... 处理 ... ds = None

把Dataset对象置为None,或者用del ds,文件句柄就会被释放。我建议在批量处理循环里,每处理完一个文件,立刻ds = None。写文件时同样,写完要out_ds.FlushCache()和out_ds = None,否则下一轮循环创建同名文件时会报错。

5.6 数组方向搞反后的坐标偏移

分块处理或者做行列变换时,最容易出现的隐性错误是:数组操作后行列顺序变了,但写回tif时还用原来的Geotransform,结果坐标整体错位。

我之前做邻域滤波时对数组做了边界填充,填充后数组比原图大了一圈,但忘了同步更新Geotransform的左上角坐标,结果输出的tif整体往左上偏移了一个像元距离,跟矢量数据叠加时全部错位。

正确做法是:只要数组的尺寸或起点变了,就要重新计算仿射变换的GT(0)和GT(3)。比如原图GT(0)=500000(左上角x),分辨率GT(1)=10,如果数组往左多填充了1列,新的GT(0)应该是500000 - 10*1 = 499990。向上填充同理,因为y方向分辨率为负,GT(3)要加10*填充行数。

6. 我现在的通用解法:一个封装函数吃遍所有tif

踩过这么多坑之后,我把读写流程沉淀成了一个工具模块,每次新项目直接import,不用再重复写。

# raster_utils.py import numpy as np from osgeo import gdal gdal.UseExceptions() def read_tif(path, bands=None, window=None): """通用读tif函数,返回numpy数组、仿射变换、投影""" ds = gdal.Open(path, gdal.GA_ReadOnly) if ds is None: raise ValueError(f"无法打开 {path}") gt = ds.GetGeoTransform() prj = ds.GetProjection() if window: xoff, yoff, xsize, ysize = window arr = ds.ReadAsArray(xoff, yoff, xsize, ysize) else: arr = ds.ReadAsArray() ds = None return arr, gt, prj def write_tif(path, array, gt, prj, no_data=-9999, dtype=None, compress='LZW'): """通用写tif函数,自动处理波段数、类型、压缩""" if dtype is None: dtype = gdal.GDT_Float32 if array.dtype == np.float64 or array.dtype == np.float32 else gdal.GDT_Byte arr = np.asarray(array) if arr.ndim == 2: arr = arr[np.newaxis, :, :] bands, rows, cols = arr.shape driver = gdal.GetDriverByName("GTiff") options = [] if compress: options.append("COMPRESS=" + compress) options.append("TILED=YES") if cols * rows * 4 > 2**31: options.append("BIGTIFF=YES") ds = driver.Create(path, cols, rows, bands, dtype, options=options) ds.SetGeoTransform(gt) ds.SetProjection(prj) for i in range(bands): out_band = ds.GetRasterBand(i + 1) out_band.WriteArray(arr[i, :, :]) if no_data is not None: out_band.SetNoDataValue(no_data) ds.FlushCache() ds = None

实际用起来就像这样:

import numpy as np from raster_utils import read_tif, write_tif # 读 dem, gt, prj = read_tif(r"D:\work\dem.tif") # 处理(比如算高程分位数) height = dem[dem != -9999] p90 = np.percentile(height, 90) slope_mask = (dem >= p90).astype(np.float32) # 写 write_tif(r"D:\work\high_area.tif", slope_mask, gt, prj, no_data=0)

这个封装看起来简单,但它把最容易出错的几个点都兜住了:波段数自动适应、类型自动推断、NoData自动写入、四字节约等于自动启用BIGTIFF。从DEM统计、影像插值、到批量数据预处理,一套函数通吃。

最近一次处理某流域高程数据时,我就是靠这两个函数把几十个tif文件批量转成统计报表,又生成了新的高程分带tif。整个过程没有打开过一次桌面GIS软件,出图的坐标、投影和原始数据完全一致。这种“数组在手、天下我有”的感觉,才是Python处理栅格数据最舒服的状态。

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

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

立即咨询