☰
VS2008+MFC+GDAL:TIFF影像显示、值域拉伸与分块读取实践
2026/10/28 15:11:21 网站建设 项目流程

简介:面向 GIS 初学者与 C++ 开发者的 GDAL 入门示例工程,在 VS2008 环境下演示如何读取并显示 TIFF 遥感影像,解决地理空间栅格数据加载与屏幕呈现的起步问题。压缩包共 45 个文件,约 14.31MB,含 7 个 .h 头文件与 5 个 .cpp 源文件构成的主体代码、.sln 与 .vcproj 工程配置、.exe 可执行程序,以及 .obj、.pdb、.ilk 等编译中间文件,另有 ico、bmp 图标位图资源与 ReadMe.txt 使用说明,目录保留了完整的 MFC 单文档工程结构。已有 800 余人学习。代码围绕 GDALDriverManager 与 GTiff 驱动器打开数据集,通过 GetRasterBand、ReadRaster 读取波段像素,再结合色彩解释转换显示,并演示 FlushCache 与 GDALClose 的资源释放;读者可据此理解数据集、波段、驱动器三类核心对象的用法,并在此基础上扩展裁剪、重采样与坐标转换等功能。

1. 接手一套还在 VS2008 里维护的老工程时,甲方递过来几个 G 的 TIFF 正射影像,要求在程序窗口里双击就能看

第一反应往往是“解码 TIFF 而已,拿个库把像素读出来不就完了”,真动手才发现卡住的根本不是解码。GDAL 一个 GDALOpen 就把像素交到你手上了,真正耗时间的是位宽转换、值域拉伸、窗口坐标映射,以及 32 位 DLL 的加载顺序。这套基于 C++ 与 GDAL 的 TIFF 影像显示示例,把这几个环节串成了一条能复现的最小链路:VS2008 的 MFC 工程里打开 TIFF,读出波段,拉伸成 8 位,再交给 GDI 画到视图上。它适合手里有老工程、需要在地图窗口里叠影像的开发者,也适合第一次给 GDAL 配 VS2008 环境、被一屏编译错误劝退的人。

2. 工程骨架:VS2008 里给 GDAL 腾出一个位置

VS2008 这个工具链放到今天属于“考古现场”,但它有个绕不开的现实:不少现场交付的 MFC 程序、嵌入式上位机界面、老的测绘数据处理工具,都锁死在这个 IDE 上,升级成本远大于维护成本。所以在这样一个工程里接 GDAL,第一件事不是写代码,而是把第三方库的位置、头文件的搜索路径、链接时的库名、运行期的 DLL 全部安排明白。只要这些理顺了,后面读 TIFF 的代码其实不到一百行。

我一般把 GDAL 当成一个独立的 thirdparty 目录丢在解决方案同级,而不是塞进工程目录里。原因是这类老项目的源码目录经常被 SVN 或者共享目录反复覆盖,第三方库跟着一起被覆盖一次,重新配一遍属性表能耗掉半天。独立目录的好处是版本固定、可复制、可以整包丢进版本库,换一台机器只要拷贝这个目录就能编译。

2.1 目录结构与工程分层

先说目录。这套示例的骨架我用下来比较顺手,MFC 单文档的外壳负责窗口和命令,图像相关的逻辑全部收在一个独立的类里,跟界面解耦。这样以后要从单文档换成对话框程序,或者把显示逻辑挪到另一个工程里复用,都不用重写。

GdalImageViewer/ ├── GdalImageViewer.sln ├── GdalImageViewer/ │ ├── GdalImageViewer.vcproj VS2008 工程文件 │ ├── stdafx.h / stdafx.cpp 预编译头,放 windows.h 和 gdal 头 │ ├── MainFrm.h / .cpp 主框架窗口 │ ├── GdalImageViewerDoc.h/.cpp 文档类,持有当前影像对象 │ ├── GdalImageViewerView.h/.cpp 视图类,OnDraw 里负责贴图 │ ├── GdalImage.h / .cpp 影像封装:打开、读波段、拉伸、出 DIB │ └── res/ 图标资源 ├── thirdparty/ │ └── gdal/ │ ├── include/ gdal.h、gdal_priv.h、cpl_conv.h 等 │ ├── lib/ gdal_i.lib(Release)、gdal_i_d.lib(Debug) │ └── bin/ gdal 主 DLL 及其依赖的编解码 DLL └── data/ └── sample.tif 测试用的小幅影像

这里最关键的划分是 GdalImage 和 View 的边界。GdalImage 只认 GDAL 的数据结构,对外暴露的是宽、高、波段数、地理变换参数,以及一块已经拉伸好的 24 位 DIB 缓冲区;View 只认 Windows 的绘图 API,拿到缓冲区就 StretchDIBits。两边通过一个结构体交互,谁也不用 include 对方的私有头文件。

文件职责依赖
GdalImage.h / .cpp打开数据集、读波段、统计值域、拉伸、生成 DIBgdal_priv.h、cpl_conv.h
GdalImageViewerDoc管理当前打开的影像路径与 GdalImage 实例GdalImage.h
GdalImageViewerViewOnDraw 绘制、OnEraseBkgnd 防闪烁、鼠标消息MFC、GdalImage 导出的缓冲区
stdafx.hNOMINMAX、windows.h、gdal 头文件的统一入口无

把 GDAL 的头文件全部收进 stdafx.h 也是有意为之。老工程的编译单元多,如果每个 cpp 单独 include gdal,哪天改了路径就得改十几个文件;统一走预编译头,改一处就行,顺带编译速度也好看。

有一个细节值得单独说:stdafx.h 里必须定义 NOMINMAX。MFC 的 afxwin.h 会带进 windows.h,而 windows.h 里 min/max 是两个宏。GDAL 的内部模板和 STL 的 std::min/std::max 一旦被这两个宏展开,编译器会吐出一大堆“找不到标识符”的错误,报错位置还在 GDAL 的头文件里,看上去像是库坏了,实际上是宏污染。

// stdafx.h 顶部,顺序不能乱 #ifndef NOMINMAX #define NOMINMAX #endif #define WIN32_LEAN_AND_MEAN #include <afxwin.h> #include <afxext.h> // GDAL 头文件统一入口 #include "gdal_priv.h" #include "cpl_conv.h" #include "cpl_string.h" #include "cpl_error.h"

WIN32_LEAN_AND_MEAN 属于顺手的事,能砍掉一部分用不上的 Windows 头,减少和 GDAL 里某些同名结构的冲突概率。这两行加上之后,那类“GDAL 头文件里报语法错误”的玄学问题基本就没了。

2.2 头文件、库依赖与项目属性配置

路径和库名要在两个配置里各写一遍:Debug 和 Release。VS2008 的属性对话框默认是“所有配置”,但很多人习惯先改 Debug 再切 Release,切过去忘了改,于是 Release 编译报一堆 LNK2019。我一般直接选“所有配置”统一改包含目录和库目录,附加依赖项再按配置分开填,因为 Debug 版和 Release 版的库名不一样。

具体位置在项目属性里:配置属性 → C/C++ → 常规 → 附加包含目录,填$(SolutionDir)thirdparty\gdal\include;配置属性 → 链接器 → 常规 → 附加库目录,填$(SolutionDir)thirdparty\gdal\lib;配置属性 → 链接器 → 输入 → 附加依赖项,Debug 填gdal_i_d.lib,Release 填gdal_i.lib。

用$(SolutionDir)而不是硬编码绝对路径,是为了让工程能被别人直接拉下来编译。这一点在老项目里经常被忽略,结果一个工程只能在原作者那台机器上编过。

还有一个必须对齐的选项:C/C++ → 代码生成 → 运行库。GDAL 的 Windows 预编译包通常是用/MD(多线程 DLL 版运行库)编译的,如果你的工程用/MT,链接时会撞上 LNK2005“xxx 已经在 libcmt.lib 中定义”,或者勉强链过了,运行时在CPLMalloc那层分配的内存和你的free对不上,直接堆损坏崩溃。这种崩溃的堆栈非常深,看上去像是 GDAL 内部出了问题,实际上是运行库混用了两套堆。

// 链接期的典型冲突:运行库不一致 // 报错形如: // LIBCMTD.lib(dbgheap.obj) : error LNK2005: _malloc 已经在 MSVCRTD.lib 中定义 // 解决:项目属性 -> C/C++ -> 代码生成 -> 运行库 // Debug 选 "多线程调试 DLL (/MDd)" // Release 选 "多线程 DLL (/MD)"

判断的依据很简单:打开 GDAL 官方给的 lib 文件所在目录,一般会附带一个说明,写明是用哪个运行库编的。如果没有说明,就用/MD试,因为绝大多数公开预编译包都是这个。自己从源码编的话,在 nmake 的 win32.mak 里加/MD,跟工程保持一致,一劳永逸。

2.3 运行期依赖:DLL 放在哪、为什么调不起来

编译过了不代表能跑。GDAL 在 Windows 下不是一个孤零零的 DLL,它背后挂着一串编解码库:TIFF、GeoTIFF、JPEG、PNG、PROJ 之类。这些东西如果不在进程的搜索路径里,程序启动或者第一次 GDALOpen 的时候会直接失败,错误信息还很含糊,有时候只是“打不开文件”,跟 DLL 缺失一点关系都看不出来。

最省事的办法是把 thirdparty/gdal/bin 下所有 DLL 拷到 exe 输出目录,跟 exe 放一起。VS2008 的默认输出目录是工程目录下的 Debug 或 Release,可以在项目属性 → 配置属性 → 常规 → 输出目录里改成$(SolutionDir)bin\$(ConfigurationName),然后加一个生成后事件,把 DLL 自动拷过去。

rem 生成后事件,命令行里填这一段 xcopy /Y /D "$(SolutionDir)thirdparty\gdal\bin\*.dll" "$(OutDir)"

/B 参数不用加,这是 DLL 不是文本。/D 表示只拷贝源比目标新的文件,反复编译不会每次都全量复制。

还有一个容易漏的:GDAL 需要一个数据目录,里面有坐标系定义、椭球参数之类的文本文件。找不到这个目录时,打开普通 TIFF 可能没事,但一旦涉及坐标转换就会返回错误。工程里可以在初始化阶段把这个路径写死到配置项,或者干脆设成相对 exe 的路径。

// 初始化阶段指定 GDAL 数据目录 // 路径按实际部署结构调整,这里用相对 exe 的写法 char szModule[MAX_PATH] = {0}; GetModuleFileNameA(NULL, szModule, MAX_PATH); std::string strDir(szModule); strDir = strDir.substr(0, strDir.find_last_of('\\')); std::string strData = strDir + "\\gdaldata"; CPLSetConfigOption("GDAL_DATA", strData.c_str());

CPLSetConfigOption 设的是进程级的配置,越早调用越好,放在 InitInstance 或者 CWinApp 构造函数里都行。这里的 GDAL_DATA 指向的是从 GDAL 发行包里拷出来的 data 目录,部署时跟 DLL 一起带上,不要指望目标机器上装过什么。

3. 打开到出图:一条从 TIFF 到 DIB 缓冲区的完整链路

到这一步环境通了,可以进入正题。一条完整的链路是:注册驱动 → 打开数据集 → 读元信息 → 判断波段类型 → 读像素 → 值域拉伸 → 组装 DIB → 贴到窗口。每一环都有坑,而且大部分坑不会在编译期暴露,只会在运行期给你一张全黑或者全白的图。

我把它拆成四段来看,每段对应一个可以单独测试的函数。这样调试的时候不用一遍遍跑整个流程,哪个环节出问题就盯哪个函数。

3.1 注册驱动与打开数据集

GDAL 的驱动注册是全局的,进程里调一次就够。重复调用不会崩,但会拖慢启动,因为每次都要把驱动表重建一遍。所以放在应用初始化里,用一个静态标志挡一下。

打开的时候有两个选择:GDALOpen和GDALOpenShared。前者是独占式打开,同一个文件打开两次会拿到两个独立的句柄;后者带引用计数,同一个文件第二次打开返回同一个指针。如果程序里可能同时有多个视图看同一张图,用 Shared 更省内存;如果只开一个,用普通版就行,句柄语义更清楚。

// GdalImage.cpp #include "stdafx.h" #include "GdalImage.h" static bool g_bGdalInited = false; bool CGdalImage::InitGdal() { if (g_bGdalInited) return true; // 老版本 GDAL 在 Windows 下按本地编码解析文件名, // 新版本默认 UTF-8,中文路径会打不开,这个开关用来兜底 CPLSetConfigOption("GDAL_FILENAME_IS_UTF8", "NO"); // 关掉 PAM,避免在影像旁边生成 .aux.xml 辅助文件 CPLSetConfigOption("GDAL_PAM_ENABLED", "NO"); GDALAllRegister(); g_bGdalInited = true; return true; } bool CGdalImage::Open(const CString& strPath) { InitGdal(); Close(); // GA_ReadOnly 只读打开,不写回原文件 m_pDataset = (GDALDataset*)GDALOpen(strPath, GA_ReadOnly); if (m_pDataset == NULL) { // 把 GDAL 的错误栈取出来,比自己的猜测靠谱得多 CString strMsg; strMsg.Format(_T("打开影像失败:\r\n%s\r\n%s"), strPath, CString(CPLGetLastErrorMsg())); AfxMessageBox(strMsg); return false; } m_strPath = strPath; return ReadMeta(); }

GDALOpen 的第二个参数只有 GA_ReadOnly 和 GA_Update 两种。哪怕你觉得后面可能要改像素,也别一上来就用 GA_Update,它会绕过一些只读优化,而且打开失败的概率高一截。真要写,再单独走一条路径。

异常处理上,GDAL 用的是 C 风格的错误栈,不是 C++ 异常。CPLGetLastErrorMsg()取到的是最近一条错误描述,信息量通常够定位问题:文件不存在、格式不认识、权限不足,都会在里头体现。养成打开失败就把它打出来的习惯,能省掉很多“为什么打不开”的猜测。

还有一个常被忽视的点:GDALOpen 支持的不只是本地文件,/vsizip/、/vsicurl/这类虚拟文件系统前缀也能用。老项目里如果影像被打包成 zip 分发,直接传D:\data\a.zip\image.tif是不行的,得写成/vsizip/D:/data/a.zip/image.tif。这个用法在交付现场特别有用,能避免解压出来占一大片磁盘。

3.2 元信息读取与波段类型判定

数据集打开之后,第一件事是把它的基本情况摸清楚,而不是急着读像素。宽高决定缓冲区大小,波段数决定是灰度还是彩色,数据类型决定后面要做多大幅度的拉伸,块大小决定分块读取的策略,地理变换决定能不能在窗口上做坐标拾取。

bool CGdalImage::ReadMeta() { if (m_pDataset == NULL) return false; m_nWidth = m_pDataset->GetRasterXSize(); m_nHeight = m_pDataset->GetRasterYSize(); m_nBands = m_pDataset->GetRasterCount(); if (m_nWidth <= 0 || m_nHeight <= 0 || m_nBands <= 0) return false; GDALRasterBand* pBand1 = m_pDataset->GetRasterBand(1); m_eDataType = pBand1->GetRasterDataType(); // 第 1 波段的块大小,分块读取时按这个粒度来 pBand1->GetBlockSize(&m_nBlockX, &m_nBlockY); // 地理变换:0 和 3 是左上角坐标,1 和 5 是像素尺寸,2 和 4 是旋转项 m_bHasGeo = (m_pDataset->GetGeoTransform(m_adfGeoTransform) == CE_None); // 投影 WKT,可能为空 const char* pszProj = m_pDataset->GetProjectionRef(); m_strProjection = (pszProj != NULL) ? pszProj : ""; return true; }

字段含义值得展开说一下。GetRasterXSize 和 GetRasterYSize 返回的是列数和行数,不是字节数,别跟缓冲区大小搞混。GetRasterCount 返回波段数,1 是单波段灰度,3 是 RGB,4 常见是 RGBA 或者 CMYK,具体要看数据的色彩解释。GetRasterDataType 返回的是GDT_Byte、GDT_UInt16、GDT_Int16、GDT_Float32这些枚举值,直接决定了值域范围。

地理变换的六个参数用一张表说清楚:

索引含义典型值
adfGeoTransform[0]左上角像素的 X 坐标投影坐标,单位米或度
adfGeoTransform[1]像素宽度(X 方向分辨率)0.5、30 等
adfGeoTransform[2]X 方向旋转项一般为 0
adfGeoTransform[3]左上角像素的 Y 坐标投影坐标
adfGeoTransform[4]Y 方向旋转项一般为 0
adfGeoTransform[5]像素高度,北向上时为负值-0.5、-30

第 5 项是负数这件事劝退过不少人。影像的行号往下增长,而地理坐标的 Y 往上增长,所以从像素行列反算地理坐标的公式是geoY = adfGeoTransform[3] + col * adfGeoTransform[4] + row * adfGeoTransform[5],负号就体现在这里。如果不做坐标拾取,这一项可以先不管;一旦要在状态栏显示鼠标位置的经纬度,这个公式就跑不掉。

波段类型判定之后还要看一件事:这个波段有没有调色板。有些老 TIFF 是 8 位索引色,像素值只是调色板的索引,直接当灰度显示会得到一张莫名其妙的图。判断方法是pBand->GetColorTable() != NULL,有调色板就得先展开成 RGB。

3.3 RasterIO 读像素与 8 位拉伸

这是整条链路里最容易出问题的一段。很多人的写法是:RasterIO直接把GDT_UInt16的数据读进一个GDT_Byte的缓冲区,指望 GDAL 帮忙缩放,结果得到一张全白或者全黑的图。原因是 GDAL 在类型转换时只做截断和钳制,不做值域映射。16 位影像的像素值动辄几千上万,钳到 0 到 255 之后,除了最暗的部分,其他全变成 255。

正确做法分两步:先按原始类型读出来,统计值域,再线性映射到 0 到 255。

bool CGdalImage::LoadWithStretch() { if (m_pDataset == NULL) return false; GDALRasterBand* pBand = m_pDataset->GetRasterBand(1); // 第一步:按原始类型读整幅影像 int nPixCount = m_nWidth * m_nHeight; int nBytesPerSample = GDALGetDataTypeSize(m_eDataType) / 8; std::vector<GByte> rawBuf((size_t)nPixCount * nBytesPerSample); CPLErr eErr = pBand->RasterIO( GF_Read, // 读 0, 0, // 起始列、起始行 m_nWidth, m_nHeight,// 读取的宽、高 &rawBuf[0], // 目标缓冲区 m_nWidth, m_nHeight,// 缓冲区宽、高,跟读取尺寸一致即不缩放 m_eDataType, // 缓冲区类型,跟源保持一致 0, 0); // 0 表示由 GDAL 自动计算像素间距和行间距 if (eErr != CE_None) return false; // 第二步:统计最小最大值,近似统计够用且快 double adfMinMax[2] = {0.0, 255.0}; pBand->ComputeRasterMinMax(TRUE, adfMinMax); double dfMin = adfMinMax[0]; double dfMax = adfMinMax[1]; if (dfMax <= dfMin) dfMax = dfMin + 1.0; // 第三步:线性映射到 0-255,生成 24 位 BGR 数据 m_nDibStride = ((m_nWidth * 24 + 31) / 32) * 4; m_vecDib.resize((size_t)m_nDibStride * m_nHeight); double dfScale = 255.0 / (dfMax - dfMin); for (int y = 0; y < m_nHeight; ++y) { GByte* pDstLine = &m_vecDib[(size_t)y * m_nDibStride]; const GByte* pSrcLine = &rawBuf[(size_t)y * m_nWidth * nBytesPerSample]; for (int x = 0; x < m_nWidth; ++x) { double dfVal = 0.0; if (m_eDataType == GDT_Byte) dfVal = pSrcLine[x]; else if (m_eDataType == GDT_UInt16) dfVal = ((const GUInt16*)pSrcLine)[x]; else if (m_eDataType == GDT_Float32) dfVal = ((const float*)pSrcLine)[x]; int nGray = (int)((dfVal - dfMin) * dfScale); if (nGray < 0) nGray = 0; if (nGray > 255) nGray = 255; // DIB 是 BGR 顺序,三个分量写同一个值就是灰度 pDstLine[x * 3 + 0] = (GByte)nGray; pDstLine[x * 3 + 1] = (GByte)nGray; pDstLine[x * 3 + 2] = (GByte)nGray; } } return true; }

RasterIO 的参数看着多,其实就三组。第一组GF_Read, nXOff, nYOff, nXSize, nYSize描述“从源图的哪个矩形读”;第二组pData, nBufXSize, nBufYSize, eBufType描述“读到哪、读成多大、读成什么类型”;第三组nPixelSpace, nLineSpace描述缓冲区的内存排布,传 0 表示用默认的紧密排列。

中间那组是最有用的开关。如果nBufXSize和nBufYSize跟读取尺寸不一致,GDAL 会自动做重采样,最近邻、双线性这些算法在内部就完成了,不用自己写。做缩略图或者缩放到窗口大小时,直接把缓冲区尺寸设成窗口尺寸,一次 RasterIO 就搞定,比先读全图再自己缩放快得多。

ComputeRasterMinMax的第一个参数是“是否允许近似”。传 TRUE 时 GDAL 会抽样统计,速度快得多,对于显示用途完全够。传 FALSE 会全量扫一遍,几千万像素的图能卡好几秒。做预览用近似,做正式出图再考虑全量。

3.4 GDI 绘制:StretchDIBits 的坐标与朝向

有了 DIB 缓冲区,剩下的就是贴到窗口上。MFC 里可以用SetDIBitsToDevice,也可以用StretchDIBits。前者要求一比一,后者支持缩放,做视图适配更方便,所以选后者。

这里有个经典的翻车点:DIB 的行序。Windows 的 DIB 默认是自下而上的,第一行数据对应图像的最后一行。而 GDAL 的 RasterIO 读出来是自上而下的。如果直接用正的 biHeight,图会上下颠倒。解决办法是把biHeight设成负值,表示这是一张自上而下的 DIB。

void CGdalImageViewerView::OnDraw(CDC* pDC) { CGdalImageViewerDoc* pDoc = GetDocument(); if (pDoc == NULL || !pDoc->m_image.IsValid()) return; CRect rcClient; GetClientRect(&rcClient); if (rcClient.IsRectEmpty()) return; // 用双缓冲,避免缩放时闪烁 CDC dcMem; dcMem.CreateCompatibleDC(pDC); CBitmap bmpMem; bmpMem.CreateCompatibleBitmap(pDC, rcClient.Width(), rcClient.Height()); CBitmap* pOldBmp = dcMem.SelectObject(&bmpMem); dcMem.FillSolidRect(&rcClient, RGB(64, 64, 64)); BITMAPINFO bmi; ZeroMemory(&bmi, sizeof(bmi)); bmi.bmiHeader.biSize = sizeof(BITMAPINFOHEADER); bmi.bmiHeader.biWidth = pDoc->m_image.GetWidth(); bmi.bmiHeader.biHeight = -pDoc->m_image.GetHeight(); // 负值 = 自上而下 bmi.bmiHeader.biPlanes = 1; bmi.bmiHeader.biBitCount = 24; bmi.bmiHeader.biCompression = BI_RGB; // 按视口比例适配,保持宽高比 int nImgW = pDoc->m_image.GetWidth(); int nImgH = pDoc->m_image.GetHeight(); double dfScale = min((double)rcClient.Width() / nImgW, (double)rcClient.Height() / nImgH); int nDstW = (int)(nImgW * dfScale); int nDstH = (int)(nImgH * dfScale); int nDstX = (rcClient.Width() - nDstW) / 2; int nDstY = (rcClient.Height() - nDstH) / 2; ::StretchDIBits( dcMem.GetSafeHdc(), nDstX, nDstY, nDstW, nDstH, // 目标矩形 0, 0, nImgW, nImgH, // 源矩形 pDoc->m_image.GetDibBits(), &bmi, DIB_RGB_COLORS, SRCCOPY); pDC->BitBlt(0, 0, rcClient.Width(), rcClient.Height(), &dcMem, 0, 0, SRCCOPY); dcMem.SelectObject(pOldBmp); }

StretchDIBits 的源矩形和目标矩形参数顺序是“先目标后源”,很容易记反。参数列表依次是:目标 DC、目标 X、目标 Y、目标宽、目标高、源 X、源 Y、源宽、源高、像素指针、BITMAPINFO 指针、色彩使用方式、光栅操作码。写反了不会崩,只会画出一块位置不对的图,调起来很费时间。

DIB 的每行字节数还有对齐要求。Windows 规定 DIB 每一行的字节数必须是 4 的倍数。对于 24 位色,行字节数是width * 3,宽度不是 4 的倍数时就会差几个字节。上面代码里用((m_nWidth * 24 + 31) / 32) * 4算出来的 stride 就是干这个的,分配缓冲区时用 stride 乘高度,不要用宽度乘 3 乘高度。

4. 大图与交互:分块读取、缩放漫游和句柄回收

小图跑通之后,拿一张真正在生产环境用的影像去试,问题立刻显现:几百兆、上万个像素宽的正射影像,一次性读进内存,程序内存直接飙到几个 G,稍微动一下鼠标就卡死。这一章讲的就是怎么让这套东西在大图上还能用。

4.1 视图坐标到像素坐标的映射

用户看到的永远是窗口,逻辑上关心的是影像。这两者之间需要一层映射:鼠标点在哪一列哪一行,滚轮缩放的时候以哪个点为中心,拖动的时候偏移了多少像素。这些如果算错,表现就是缩放时图像乱跳、点击拾取坐标偏了几十米。

我做的时候会维护两组状态:一个是当前显示窗口在影像上的矩形(以像素为单位),另一个是窗口客户区的矩形(以屏幕像素为单位)。视图变换用这两个矩形做线性映射,比维护一堆缩放因子和偏移量清楚得多。

// 视图坐标 -> 影像像素坐标 bool CImageView::ScreenToImage(CPoint ptScreen, int& nCol, int& nRow) { if (m_rcView.Width() <= 0 || m_rcView.Height() <= 0) return false; double dfScaleX = (double)m_rcImage.Width() / m_rcView.Width(); double dfScaleY = (double)m_rcImage.Height() / m_rcView.Height(); nCol = m_rcImage.left + (int)((ptScreen.x - m_rcView.left) * dfScaleX); nRow = m_rcImage.top + (int)((ptScreen.y - m_rcView.top) * dfScaleY); return nCol >= 0 && nCol < m_nImageWidth && nRow >= 0 && nRow < m_nImageHeight; } // 影像像素坐标 -> 视图坐标 CPoint CImageView::ImageToScreen(int nCol, int nRow) { double dfScaleX = (double)m_rcView.Width() / m_rcImage.Width(); double dfScaleY = (double)m_rcView.Height() / m_rcImage.Height(); CPoint pt; pt.x = m_rcView.left + (int)((nCol - m_rcImage.left) * dfScaleX); pt.y = m_rcView.top + (int)((nRow - m_rcImage.top) * dfScaleY); return pt; }

m_rcImage 表示当前视图窗口对应影像上的哪一块。放大时这个矩形变小,缩小时变大,拖动时整体平移。这样只要维护一个矩形,缩放和漫游就是改矩形的四个数,逻辑非常短。

如果要做坐标拾取显示经纬度,在这一步之后拿 nCol、nRow 套地理变换公式就行:geoX = gt[0] + nCol * gt[1] + nRow * gt[2],geoY = gt[3] + nCol * gt[4] + nRow * gt[5]。别忘了 gt[5] 是负的。

4.2 分块读取与金字塔

大图不能整幅读,但也不能每滚动一像素就读一次,那样 IO 会成为瓶颈。折中的办法是按块读,块的大小取 512×512 或者 256×256,维护一个小的块缓存,滚动时只重读新进入视野的块。

更省事的办法是用 GDAL 自带的金字塔。金字塔是预先算好的多级缩略图,缩放显示时 GDAL 会自动挑最匹配的一级来读,一次 RasterIO 就能拿到缩小后的结果,根本不用自己算。判断有没有金字塔用pBand->GetOverviewCount(),没有的话可以现场构建。

// 构建金字塔:2、4、8、16 四级,平均采样 int anLevels[] = {2, 4, 8, 16}; CPLErr eErr = m_pDataset->BuildOverviews( "AVERAGE", // 采样算法,连续型数据用 AVERAGE 4, anLevels, 0, NULL, // 0 和 NULL 表示对所有波段都建 NULL, NULL); // 进度回调,不用就传 NULL if (eErr != CE_None) { // 只读打开或者格式不支持时会失败,退回自己分块读 }

采样算法要根据数据类型选。连续型的影像比如高程、温度、反射率用 AVERAGE,能得到平滑的缩略图;类别型的影像比如土地利用分类图必须用 NEAREST,用平均值会造出一堆不存在的类别号。

金字塔是写回文件的,占额外磁盘空间,一般多出原文件的三分之一左右。如果原文件是只读挂载的,建不了,这时候可以建一个外部金字塔(.ovr 文件),GDAL 支持这种做法,但需要在配置里打开选项。

分块读的代码骨架大概是这样:

const int TILE = 512; std::vector<GByte> tileBuf(TILE * TILE); for (int y = m_rcImage.top; y < m_rcImage.bottom; y += TILE) { int nRows = min(TILE, m_rcImage.bottom - y); for (int x = m_rcImage.left; x < m_rcImage.right; x += TILE) { int nCols = min(TILE, m_rcImage.right - x); // 只读需要的那一块,越界的部分 GDAL 会补零 pBand->RasterIO(GF_Read, x, y, nCols, nRows, &tileBuf[0], nCols, nRows, m_eDataType, 0, 0); // 拉伸后贴到 DIB 缓冲区对应位置 DrawTileToDib(x - m_rcImage.left, y - m_rcImage.top, nCols, nRows, &tileBuf[0]); } }

注意 RasterIO 在读取范围超出图像边界时的行为:它不会报错,会返回读到的部分,越界区域填空。绘图时如果视图里有一部分是图像外的,会看到一块黑色,这是正常的,界面上通常用背景色盖掉。

块大小不是随便定的。GDAL 内部按条带或瓦片组织数据,块大小跟内部组织对齐时效率最高。GetBlockSize拿到的就是这个内部尺寸。如果数据是条带式存储(TIFF 默认),块高可能是 1 或者几行,这时候按 512 的块读意义不大,反而不如按行读。所以有经验的写法是先看块大小,再决定分块策略。

4.3 句柄生命周期与内存回收

GDAL 的句柄是裸指针,没有 RAII 保护,忘了关就是内存泄漏。老工程跑几天不关,任务管理器里内存一路涨,最后被系统杀掉,这类问题排查起来最耗时间,因为崩溃点往往离泄漏点很远。

我一般把 GdalImage 的生命周期绑在文档类上,文档关闭时析构,析构里保证 GDALClose。同时提供一个 Close 方法,在打开新文件之前先调用一次,避免连开十个文件攒十个句柄。

CGdalImage::~CGdalImage() { Close(); } void CGdalImage::Close() { if (m_pDataset != NULL) { // GDALClose 内部会释放所有波段对象,不需要单独释放 GDALClose((GDALDatasetH)m_pDataset); m_pDataset = NULL; } m_vecDib.clear(); m_nWidth = m_nHeight = 0; m_nBands = 0; }

GDALClose接受的是GDALDatasetH,在 C++ 里就是GDALDataset*强转一下。关掉之后所有GetRasterBand拿到的波段指针全部失效,不能再访问。这一点在多个视图共享同一个数据集时要特别小心,一个视图关了,另一个视图的指针就成野指针了。用GDALOpenShared打开的话,内部有引用计数,GDALClose只是减一,最后一个引用释放时才真正关闭,能缓解这个问题。

内存这块还有一个隐性开销:RasterIO内部会为每次读取分配临时缓冲区,读写频繁时分配释放的次数很多。虽然 GDAL 有缓存机制,但在高频交互场景下还是会成为热点。我的做法是把整幅图的 DIB 缓冲区常驻内存,只重算需要更新的部分,读原始像素的操作只在首次加载和切换波段时发生。

5. 避坑排查:VS2008 配 GDAL 最容易翻车的五件事

这一章基本是血泪经验汇总。下面这几条都是在真实工程里踩过的,现象、原因、解决方式都按我遇到的实际情况写,顺序按出现频率排。

5.1 编译能过,一运行就提示找不到 DLL 或者 0xc000007b

现象:双击 exe,弹窗提示“无法启动此程序,因为计算机中丢失 xxx.dll”,或者更隐蔽的“应用程序正常初始化失败,错误代码 0xc000007b”。

原因:前者是 DLL 根本不在搜索路径里,后者是位数不匹配。0xc000007b 这个错误码看着像系统坏了,实际上绝大多数情况是 64 位的 exe 加载了 32 位的 DLL,或者反过来。VS2008 默认生成 32 位程序,如果拿了一份 64 位的 GDAL 预编译包,链接阶段可能因为库文件格式不对直接失败,也可能因为用了导入库而侥幸链过,运行时才崩。

解决:先确认工程的目标平台。VS2008 的配置管理器里看“活动解决方案平台”,是 Win32 就用 32 位的 GDAL 包。然后把 gdal/bin 下所有 DLL 拷到 exe 同目录,用 Dependency Walker 或者新版 VS 自带的 dumpbin /dependents 查一遍依赖树,缺哪个补哪个。别指望装个环境变量解决,部署到客户机上照样出问题。

5.2 链接报 LNK2005:xxx 已经在 libcmt.lib 中定义

现象:编译到最后一步链接失败,一堆error LNK2005: _malloc 已经在 LIBCMTD.lib 中定义,或者LNK4098: 默认库“MSVCRTD”与其他库的使用冲突。

原因:运行库混用。GDAL 的 lib 是用/MD编的,工程用的是/MT,两套 CRT 各有各的堆管理器,符号重复定义还只是表面,真正的问题是跨库分配的内存用另一套 free 去释放,堆直接被破坏。

解决:项目属性 → C/C++ → 代码生成 → 运行库,Debug 选“多线程调试 DLL (/MDd)”,Release 选“多线程 DLL (/MD)”,跟 GDAL 的编译选项保持一致。如果找不到 GDAL 是用哪个编的,就看 lib 文件所在的目录名或者附带的 readme,通常都会写。自己从源码编的话,在 nmake 参数里加/MD,别用默认的/MT。

5.3 中文路径的 TIFF 死活打不开

现象:英文路径的影像正常打开,换成中文目录或者中文文件名,GDALOpen返回 NULL,CPLGetLastErrorMsg只说文件不存在,但资源管理器里明明看得见。

原因:文件名编码不一致。GDAL 的 Windows 版本对文件名的处理跟版本有关,较新的版本默认按 UTF-8 解析,而 MFC 传进去的 CString 在 Unicode 配置下是 UTF-16,转成 char* 之后是本地代码页(GBK),两边对不上。

解决:在初始化阶段设CPLSetConfigOption("GDAL_FILENAME_IS_UTF8", "NO"),让 GDAL 按本地编码处理。如果这招不灵,就自己做一次显式转换,把 CString 用WideCharToMultiByte转成 UTF-8 再传进去。两条路选一条,别混着用。

// Unicode 工程下,显式转 UTF-8 再交给 GDAL CStringA strUtf8; int nLen = WideCharToMultiByte(CP_UTF8, 0, strPath, -1, NULL, 0, NULL, NULL); WideCharToMultiByte(CP_UTF8, 0, strPath, -1, strUtf8.GetBuffer(nLen), nLen, NULL, NULL); strUtf8.ReleaseBuffer(); GDALDataset* pDS = (GDALDataset*)GDALOpen(strUtf8, GA_ReadOnly);

注意这个转换只对文件名有效,不要拿去做别的字符串处理。另外,如果中间还要经过一次CPLString或者std::string,确保中间环节没有再做一次编码转换。

5.4 图像显示全黑或者全白

现象:打开成功,宽高波段数都对,画到窗口上却是一片黑,或者一片惨白,偶尔能看到极少数像素有灰度变化。

原因:两种可能。一是没做值域拉伸,16 位或者浮点数据直接钳到 8 位;二是做了拉伸但统计的 min/max 不对,比如用了只统计部分区域的近似算法,而这块区域恰好是纯背景。

解决:先打印GetRasterDataType()的返回值,确认到底是哪一种。如果是 Byte,检查是不是有调色板没展开;如果是 UInt16 或者 Float,老老实实按 3.3 节的两步法做。判断拉伸是否正常,可以在拉伸后统计一下直方图,看 0 到 255 是不是分布开了,而不是全挤在某一端。

还有一种少见情况:影像有 alpha 波段,但显示时把 alpha 当成了颜色通道。四波段数据要判断第一个波段的色彩解释是不是GCI_AlphaBand,是的话只取前三个波段。

5.5 缩放拖动几下内存暴涨然后崩溃

现象:小图正常,大图拖动、缩放几次之后程序卡死,任务管理器里内存涨到几个 G,最后弹“内存不足”或者直接被系统结束进程。

原因:每次重绘都重新RasterIO读一遍全图,或者每次new一块缓冲区但忘了delete。也有可能是每次打开文件都新建数据集,旧句柄没关。

解决:把数据读取和绘制分开。数据只读一次,存成 DIB 常驻内存;绘制时只做StretchDIBits,不动数据。缩放时如果只是显示比例变化,DIB 不用重算;只有当缩放级别跨过某个阈值、像素明显糊掉时,才重新采样一次。句柄管理上,在析构函数和打开新文件的入口都调一次GDALClose,双保险。

用任务管理器看内存是最直观的排查手段。打开一张图,关掉,再打开,观察内存是否回到初始水平。如果不回,就是没释放干净;如果每次打开都涨一点,多半是GDALOpen的句柄泄漏。

6. 二次开发:把它改成一个能交付的影像查看器

这套示例的定位是“能跑通的最小链路”,真要交付到现场,还有几块要补。第一块是图层列表,现在只能开一张图,实际项目里经常要叠多张:影像打底,矢量叠加,或者多期影像对比。做法是把 GdalImage 放进一个std::vector,文档类维护一个当前激活索引,绘制时按顺序从下往上贴,简单场景就够用了。第二块是坐标显示,状态栏加三个静态文本,鼠标移动时把 ScreenToImage 转出来的行列坐标套地理变换公式算成投影坐标,如果投影不是经纬度,再挂一个OGRSpatialReference做转换,GDAL 里这套 API 是现成的。

第三块是导出。现场经常要求“把当前视图另存为一张图”,实现上就是拿到已经拉伸好的 DIB 缓冲区,用 GDI+ 的Bitmap::FromHBITMAP或者自己写 24 位 BMP 头,直接落盘。要导出原始像素而不是显示效果,就走GDALCreate加RasterIO的反向路径,注意保持原来的数据类型和地理变换,别辛辛苦苦导出来发现坐标丢了。

改造点涉及模块难度备注
多图层叠加文档类、视图绘制低注意绘制顺序与透明度
坐标拾取视图、GdalImage中需要投影转换时引入 OGRSpatialReference
视图导出GdalImage低分显示效果导出和原始像素导出两种
波段组合切换GdalImage中支持任意三波段映射到 RGB
金字塔预构建打开流程低大图首次打开时异步构建

波段组合切换这块值得单独提一下。多光谱影像动辄七八个波段,现场经常要看不同组合,比如近红外、红、绿做假彩色。实现上就是把GetRasterBand(1/2/3)换成可配置的索引,读取时用GDALDataset::RasterIO的多波段重载,一次性把三个波段读进交错缓冲区,比调三次单波段 RasterIO 快不少。每个波段的值域还要单独统计、单独拉伸,不能共用一个 min/max,否则合成出来的颜色会偏得离谱。

验证方法上,我习惯准备三张固定的测试图:一张 8 位灰度、一张 16 位多光谱、一张带地理坐标的正射影像。每改一次代码,三张都开一遍,看显示是否正常、内存是否回落、坐标是否对得上。这套流程走顺了,后面加功能基本不会翻车。

最后说个习惯。从那以后我每次接这类老工程,都强制先跑一遍 DLL 依赖检查再动手写代码,把dumpbin /dependents的输出存成一份清单贴在项目文档里。一开始觉得多余,被 0xc000007b 耽误过一个下午之后,这个步骤再也没省过。希望帮到你。

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

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

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

立即咨询