简介:围绕C++实现图像重采样与影像金字塔构建的一套小型工程源码,面向具备一定C++基础、从事遥感图像处理、计算机视觉或图形学方向的开发者与学生。它针对8位索引色与24位真彩色两种Windows位图,演示如何用2×2双线性内插放大原图、逐层下采样生成多分辨率金字塔,可服务于图像分析、目标检测、图像融合等场景的入门理解与二次改造。
压缩包共12个文件,约12KB,以4个.h头文件与3个.cpp源文件为主体:CDIB封装设备无关位图的读写与显示,Pyramid承载重采样与金字塔构建的核心算法,另配clw、dsp、dsw工程文件和rc资源脚本、ReadMe说明,可直接在旧版Visual Studio中打开编译调试。
已有714人学习。代码量不大但结构完整,适合对照阅读以理清位图内存布局、插值公式与逐层缩放的实现细节,也可作为课程设计或算法验证的起点。
1. 从一张三万像素宽的影像说起:为什么金字塔得自己写
某测绘类项目里有一张 30000×28000 的 16 位多光谱影像,单波段按行铺满内存就是 1.6 GB,整幅读进来,普通工作站的风扇立刻开始吼。可业务侧要的是多尺度浏览、跨尺度模板匹配和一批几十像素见方的快速预览——没人真的需要每次都碰全分辨率。这类场景的标准解法就是构造影像金字塔:给同一幅遥感影像预先算好 1/2、1/4、1/8 直到缩略图的一系列降采样层,谁用哪一级就取哪一级。
这份资源是一套用 C++ 写成的重采样与影像金字塔构造实现,降采样核、分块流式读写、层级元数据同步和落盘命名都包含在内。它适合正在做遥感影像处理的开发者,尤其是需要自己掌控重采样语义(nodata 怎么算、整型怎么累加、边界怎么取整)的人;也适合刚接触金字塔、想知道每一层像素到底是怎么算出来的新手。下面按“核怎么选 → 内存怎么排 → 工程怎么落地 → 坑在哪 → 怎么验证”的顺序拆。
2. 重采样核怎么选:最近邻、双线性与面积平均在金字塔里的分工
金字塔的第一件事不是写代码,是决定每一层的目标像素值从源影像的哪些像素、以什么权重算出来。这一步选错,后面所有优化都是白费。
2.1 三种核的数学形式与代价
最近邻取源坐标 (x·s, y·s) 处那一个像素,代价是 O(1) 次内存访问,实现最简单。它的副作用是块状锯齿:2 倍降采样时,一个 2×2 源块里只有左上角那个像素被采信,另外三个直接被丢掉。对于分类图、掩膜、标签栅格这类“值必须原样保留”的数据,这恰恰是正确行为;但对于连续型辐亮度影像,丢掉的三个像素携带的能量就永久消失了。
双线性用 4 邻域加权,权重由目标点到源点的距离决定。它的问题是方向搞反了:双线性是“插值”核,设计目标是在放大时填补空隙;拿它做缩小,一个 s×s 的源块只被采样到 4 个点,剩下的 s²−4 个像素完全没参与,混叠照样存在,只是被平滑掩盖了,看起来“没那么糟”。
面积平均(box filter,也叫均值降采样)是缩小场景下数学上正确的做法:目标像素等于其覆盖的 s×s 源像素的算术平均。源块里每一个像素都等权参与,高频信息被真正平均掉而不是随机丢弃。代价是每个目标像素要读 s² 次,s=2 时是 4 次,s=4 时是 16 次。
Lanczos 和三次卷积在降采样里也会被用到,但它们的负权重会带来振铃,在影像边缘两侧产生原本不存在的亮暗条带。做定量分析(植被指数、水体提取)时这类伪影会污染统计量,我一般不碰。
| 核 | 参与像素数(s=2) | 抗混叠 | 值域保持 | 适用数据 |
|---|---|---|---|---|
| 最近邻 | 1 | 无 | 严格保持 | 分类图、掩膜、标签 |
| 双线性 | 4 | 弱 | 可能越界 | 快速预览、非定量显示 |
| 面积平均 | s² = 4 | 强 | 落在原值域内 | 辐亮度、反射率、NDVI |
| Lanczos3 | 36 | 强 | 可能越界、有振铃 | 视觉展示 |
值域保持这一列值得多说一句。面积平均的结果一定落在参与平均的那批源像素的最小值和最大值之间,所以对 16 位无符号整型来说永远不会溢出到负值或超上限;双线性和 Lanczos 在极值附近会产生超出原始值域的结果,必须做 clamp,而 clamp 又会引入新的偏差。这是我在做定量遥感时坚持面积平均的直接原因。
2.2 层级关系与尺度因子
金字塔的第 0 层是全分辨率,第 k 层相对原图的尺度因子是 s = 2^k(也可以取 3、4,取决于业务)。层与层之间是递推关系:第 k 层由第 k−1 层再降采样 s 倍得到,而不是每层都从第 0 层直接算。这个选择影响很大——递推能把每层的读取量控制在上一层的大小,总 I/O 约等于第 0 层的 4/3 倍;如果每层都回到原图,总读取量会随着层数线性膨胀。
尺寸计算是新手最容易翻车的地方。规则只有一条:向上取整,而不是向下。
// level k 的宽高:向上取整,保证最右列/最下行不被静默丢弃 int levelW = (baseW + s - 1) / s; int levelH = (baseH + s - 1) / s;用向下取整(直接整除)时,30001 宽的影像在 s=2 下会变成 15000,右边那一列在后续所有层里都不存在。单看某一层可能只差一列,但做多尺度配准时,这一列会让第 3 层以后的特征点整体偏移,表现为“匹配总是差半个像素”这种极难定位的玄学问题。向上取整后,最后一列的覆盖范围不足 s 个源像素,需要在取样时做边界钳制,这是必须付的代价。
2.3 分块流式处理的内存账
金字塔的计算天然适合流式:要产出目标影像的某几行,只需要源影像对应的若干行。所以正确的结构是条带(strip)级处理,一次只驻留几行,而不是整幅载入。
先算一笔账。一张 30000×28000 的 16 位单波段影像,整幅载入需要 30000×28000×2 = 1.68 GB;如果 4 个波段,6.7 GB。而按条带处理,条带高度取 512 行,源缓冲是 512×30000×2 = 30 MB,加上输出缓冲不到 35 MB,一个线程一套,8 线程也就 280 MB,普通工作站完全扛得住。
| 处理方式 | 峰值内存(4 波段 16 位,30000×28000) | 可并行 | 适用规模 |
|---|---|---|---|
| 整幅载入 | 6.7 GB | 是 | 小于 5000×5000 |
| 条带流式(512 行) | 约 280 MB(8 线程) | 是 | 十万级边长 |
| 瓦片流式(512×512) | 约 60 MB(8 线程) | 是 | 需要随机访问时 |
条带和瓦片的选择取决于下游怎么用。如果金字塔只服务于顺序扫描(比如批量算统计量),条带足够且实现最简单;如果下游要做随机位置的快速取窗(比如切片服务),那就得按瓦片组织,且金字塔每层都要按同样的瓦片网格切分,否则取窗时跨块拼接会非常难受。资源里的实现走的是条带主干 + 可选瓦片落盘,两条路都留了口子。
还有一点容易忽略:条带高度必须是尺度因子 s 的整数倍。否则最后一条带的源行数不足 s 的倍数,要么少算一行输出,要么得单独写一套部分块的处理逻辑。我一般固定条带输出高度为 256,源条带高度就是 256×s,最后一条带用 min 钳一下,边界那一条单独走慢路径。
3. 内存布局与分块流式:C++ 里把降采样写对的第一道关
选完核,接下来是内存怎么排、循环怎么写。这一段决定了程序是跑十分钟还是跑一小时,也决定了边界上会不会悄悄少一行。
3.1 波段内存布局与容器设计
遥感影像常见的三种布局是 BSQ(波段顺序)、BIL(波段交叉行)、BIP(波段交叉像素)。做单波段重采样时,BSQ 最友好:同一波段的所有行连续存放,一次读一个波段的一条带就是一段连续内存,可以直接 memcpy。BIP 则要把每个像素的 4 个 16 位值跳着取,缓存命中率会掉一大截。
如果源数据是 BIP 而只做金字塔,我会在读取阶段做一次重排,转成 BSQ 再算。重排本身要花一次全量 I/O,但后续每一层都受益,层数越多越划算。容器选择上,用std::vector<uint16_t>平铺加手动算下标,比嵌套 vector 快得多,也比裸 new/delete 安全:
// 单波段的条带容器:行优先平铺,宽 stride 显式传入 struct StripBuf { std::vector<uint16_t> data; int width = 0; // 一行多少个像素(含边界填充) int height = 0; int validW = 0; // 实际有效列数,右边界不足时不越界读 void Resize(int w, int h) { width = w; height = h; data.assign((size_t)w * h, 0); } uint16_t* Row(int y) { return data.data() + (size_t)y * width; } };width和validW分开是有意的。右边界那一列在向上取整后会覆盖不满 s 个源像素,如果直接把width当有效宽度去读,就会读到缓冲区外。常见做法是把源条带的宽度也向上取整到 s 的倍数,多出来的列用边界像素复制填充,这样内层循环里完全不需要分支判断——分支预测失败的代价在每像素几纳秒的量级上很致命。
3.2 面积平均降采样的核心实现
单块的计算函数是所有优化的落脚点,先把它写对,再谈快。
// 面积平均:对源块 [x0, x0+s) × [y0, y0+s) 求均值 // 用 64 位累加,避免 uint16 在 s 较大时溢出(s=16 时理论上限 65535*256 已超 uint32 安全区) uint16_t AreaAvg(const uint16_t* src, int stride, int x0, int y0, int s, uint16_t nodata, bool& valid) { uint64_t sum = 0; int cnt = 0; for (int y = 0; y < s; ++y) { const uint16_t* row = src + (size_t)(y0 + y) * stride + x0; for (int x = 0; x < s; ++x) { uint16_t v = row[x]; if (v == nodata) continue; // nodata 不参与平均,也不计入分母 sum += v; ++cnt; } } if (cnt == 0) { valid = false; return nodata; } // 整块全无效,输出仍标 nodata valid = true; return (uint16_t)((sum + cnt / 2) / cnt); // 四舍五入而非截断 }三个细节值得单独说。第一,累加器用uint64_t而不是uint32_t:看上去 s=2 时最大值只有 4×65535,uint32 绰绰有余,但一旦有人把尺度因子改成 16 或者做多级递推的累加,uint32 就有溢出风险,而溢出的表现是影像上出现随机黑斑,排查起来非常费劲。统一用 64 位,代价可以忽略。
第二,nodata的处理必须是“不参与平均、也不计入分母”,而不是“当成 0 参与平均”。后者会把云、阴影、无效边缘整体拉暗,在拼接影像上表现为明显的暗带。同时cnt == 0的情形必须有独立分支,否则除零直接崩。
第三,四舍五入(sum + cnt/2) / cnt。截断会让整幅影像的均值系统性偏低约 0.5 个 DN,单层看不出来,但递推 5 层后偏差会累积到 2 个 DN 以上,做辐射归一化时会被当成真实信号。
3.3 主循环与条带调度
把单块函数套进双层循环,就是完整的一层降采样:
// 对一层做 s 倍面积平均降采样 bool DownsampleLevel(const StripBuf& src, StripBuf& dst, int s, uint16_t nodata) { const int dw = dst.validW; const int dh = dst.height; for (int dy = 0; dy < dh; ++dy) { uint16_t* outRow = dst.Row(dy); const int sy0 = dy * s; // 源块起始行 for (int dx = 0; dx < dw; ++dx) { bool ok = false; outRow[dx] = AreaAvg(src.data.data(), src.width, dx * s, sy0, s, nodata, ok); if (!ok) outRow[dx] = nodata; } // 右边界不足 s 列时,复制最后一个有效输出像素,避免出现 0 值 for (int dx = dw; dx < dst.width; ++dx) outRow[dx] = outRow[dw - 1]; } return true; }内层循环里dx*s每轮算一次乘法,编译器一般能优化掉,但显式改成累加sx += s更稳妥。更关键的是访问模式:对固定的dy,内层沿 x 方向扫过去,每次读源影像里连续的 2 个(或 s 个)像素,然后跳到下一行。s=2 时,一个目标行要触碰源影像的 2 行,行内步进是 2 个元素——步长 2 的访问对缓存不太友好,会浪费掉一半的缓存行。
解决办法是换个循环顺序:先沿 x 做水平方向的一维平均,把结果写到一个中间缓冲,再沿 y 做垂直方向的一维平均。这样两次遍历都是步长 1 的连续访问,缓存利用率接近 100%,在 s=2 时实测能快 1.5 到 2 倍。这种“可分离核”的思路对 box filter 完全成立,因为 2D 均值等于先对行求均值再对列求均值。
// 可分离实现:先水平一维平均,再垂直一维平均 // 中间缓冲 hbuf 尺寸为 (srcH, dstW),两次遍历都步长 1 void BoxSeparable(const StripBuf& src, StripBuf& dst, int s) { std::vector<uint32_t> hbuf((size_t)src.height * dst.validW, 0); for (int y = 0; y < src.height; ++y) { // 水平 const uint16_t* in = src.Row(y); uint32_t* out = hbuf.data() + (size_t)y * dst.validW; for (int dx = 0; dx < dst.validW; ++dx) { uint32_t acc = 0; for (int k = 0; k < s; ++k) acc += in[dx * s + k]; out[dx] = acc; // 先不除,保留精度 } } for (int dy = 0; dy < dst.height; ++dy) { // 垂直 uint16_t* out = dst.Row(dy); for (int dx = 0; dx < dst.validW; ++dx) { uint32_t acc = 0; for (int k = 0; k < s; ++k) acc += hbuf[(size_t)(dy * s + k) * dst.validW + dx]; out[dx] = (uint16_t)((acc + (s * s) / 2) / (s * s)); } } }中间缓冲用uint32_t存水平方向的累加和(最大 s×65535,s≤64 都安全),除法挪到最后一次做,省掉 s² 次整数除法。这是我自己代码里默认的写法,比直接 2D 循环稳而且快。
4. 完整工程实现:从条带读取到层级文件落盘
核和循环都有了,接下来是把它拼成能跑的东西:文件怎么读、层与层之间怎么衔接、元数据怎么跟着变。
4.1 层级元数据同步
金字塔最容易出错的不是像素,是元数据。每降采样一次,地理变换参数里的分辨率分量必须同步除以 s:
// geoTransform 为 6 元组 {originX, pixelW, rotX, originY, rotY, pixelH} // 降采样 s 倍后:分辨率乘 s,原点不变,旋转项按同样比例缩放 void ScaleGeoTransform(double gt[6], int s) { gt[0] = gt[0]; // 原点不随尺度变化(左上角对齐) gt[1] = gt[1] * s; // 像素宽度变大 gt[2] = gt[2] * s; // 旋转项同步 gt[3] = gt[3]; gt[4] = gt[4] * s; gt[5] = gt[5] * s; }原点保持不变的前提是“左上角对齐”,也就是第 k 层的第一个像素的左边界和第 0 层一致。这个约定必须写进文档,因为另一派做法是“中心对齐”,两种约定混用会导致跨层取窗系统偏移半个降采样后的像素。如果下游做的是影像匹配,这半个像素就是血泪经验的来源。
顺带把每层的宽高、波段数、数据类型、nodata 值一并写进层级文件头或旁挂的 JSON。掉这一条的下场是:像素算得完全正确,但所有软件打开后位置偏了,看上去像重采样写错了,实际是元数据没跟上。
4.2 完整主循环
把读取、降采样、写出串起来:
// 逐层递推构建金字塔,maxLevel 为最大层级(不含 level 0) bool BuildPyramid(const std::string& srcPath, int maxLevel, int factor) { ImageReader reader(srcPath); StripBuf cur, next; cur.Resize(reader.Width(), reader.Height()); reader.ReadAll(cur.data.data()); // 只有 level 0 需要全量,之后按条带 for (int lv = 1; lv <= maxLevel; ++lv) { int dw = (cur.validW + factor - 1) / factor; // 向上取整 int dh = (cur.height + factor - 1) / factor; next.Resize(AlignUp(dw, 16), dh); // 行宽对齐到 16,便于 SIMD next.validW = dw; DownsampleLevel(cur, next, factor, reader.NoData()); std::string outPath = MakeLevelPath(srcPath, lv); // 如 xxx_L2.tif ImageWriter writer(outPath, dw, dh, reader.Bands(), reader.Depth()); writer.SetGeoTransform(ScaledGT(reader.GeoTransform(), 1 << lv)); writer.SetNoData(reader.NoData()); writer.WriteAll(next.data.data(), next.width); std::swap(cur, next); // 递推:当前层作为下一层的输入 } return true; }AlignUp(dw, 16)把每行宽度对齐到 16 个元素,主要是为了后面加 SIMD 时能整段处理,顺便避免了跨缓存行的非对齐读。对齐多出来的列填什么无所谓,因为validW限定了解析范围,但千万不要在对齐区放随机值——如果某处代码误用了width而不是validW,随机值会变成影像边缘的雪花噪点。
std::swap(cur, next)是这段里最省事的一行。它复用了两个缓冲区,整个流程的内存峰值就是两个StripBuf的大小,不会随着层数增加。如果每层都 new 一个新 vector,内存分配器会在层级切换时产生大量碎片,跑几百景影像后进程 RSS 会缓慢上涨。
4.3 并行化与写盘
条带之间天然独立,用 OpenMP 的parallel for加schedule(dynamic)就能并行。但有个前提:每个线程必须写到文件的独立区域。
// 每个线程算完自己那条带后,按字节偏移直接写,避免共享文件指针 #pragma omp parallel for schedule(dynamic, 1) for (int by = 0; by < dh; by += stripOut) { int rows = std::min(stripOut, dh - by); StripBuf local; local.Resize(next.width, rows); // ... 只算 [by, by+rows) 这一段 size_t offset = (size_t)by * next.width * sizeof(uint16_t); writer.WriteAt(offset, local.data.data(), (size_t)rows * next.width); }共享文件指针(FILE*或ofstream)在多线程下会互相踩,表现为输出文件里某些条带内容错乱、或者干脆丢一段。用pwrite或者内存映射按偏移写才是安全的。资源里给的是WriteAt(offset, ...)这层封装,底层走pwrite,Windows 上走WriteFile带OVERLAPPED。
线程数不要开满。金字塔是 I/O 密集型,8 核机器上开 4 到 6 个线程通常就到拐点了,再往上加只会有更多线程抢同一块磁盘带宽。我一般的做法是omp_set_num_threads(std::min(6, std::max(1, cores - 2))),留两个核给系统和其他任务。
5. 避坑与排查:五个真实翻车现场
这一节全是代价换来的,每条按“现象 → 原因 → 解决”写。
5.1 输出影像边缘出现一条黑线
现象:每层金字塔的右侧和最下方各有一条 1 像素宽的纯 0 线,且只在原图宽高不是 s 的整数倍时出现。原因:尺寸计算用了向下取整,最后一列/行的源像素没有对应的目标像素;更隐蔽的一种是用了向上取整,但源条带读取时只读了min(stripIn, h - y)行,最后一条带行数不足,AreaAvg读到缓冲区外,读到的恰好是 vector 初始化时的 0。解决:尺寸一律向上取整;源条带读取时把不足的部分用最后一行的像素复制填充到 s 的整数倍;AreaAvg里对y0+y >= srcH的情况直接判无效而不是读内存。这条我踩过两次,第二次是因为换了读取模块,填充逻辑没跟着搬。
5.2 累加结果对不上,均值系统性偏低
现象:用脚本单独算一遍某块的均值,和程序输出差 1 到 2 个 DN,且偏差方向一致。原因:两个可能。一是整数除法用了截断而不是四舍五入,每层偏低约 0.5 DN,多层递推后放大;二是 nodata 被当成 0 计入了分母。排查方法很直接:造一张全常数值(比如全部 1000)的测试图,跑一遍金字塔,看每层是否还是 1000。如果变成 999 或 998,就是舍入问题;如果变成 800 多,就是 nodata 分母问题。解决:累加器统一 64 位,除法加cnt/2,nodata 单独计数。
5.3 多线程下输出文件内容错乱
现象:单线程结果正确,开 4 线程后某些条带的数据跑到别的行去了,或者出现重复内容。原因:多个线程共享同一个ofstream或FILE*,写入时文件指针互相覆盖;另一种是每个线程各自打开一次文件但没指定追加模式,后打开的截断了先写的。解决:主线程预分配文件大小(ftruncate或先写一个全零文件),每个线程用pwrite按字节偏移写自己的条带。注意偏移量要用size_t算,int在超过 2 GB 的文件上会溢出成负数。
5.4 第 3 层以后匹配总是差半个像素
现象:单层看没问题,跨层做模板匹配时残差始终有一个固定偏移量,调参调不掉。原因:金字塔有两种对齐约定——左上角对齐和中心对齐。如果构建时按左上角对齐(原点不变),而下游按中心对齐的公式去换算坐标,每层就会引入半个降采样后像素的偏移,层数越深偏移越大。解决:把对齐约定写死在一个公共头文件里,构建端和使用端引用同一个常量;验证方法是造一张有已知角点标记的测试图,检查每层角点像素坐标是否满足x_k = x_0 / 2^k。
5.5 内存占用随层数线性增长
现象:跑单景影像正常,批量跑几十景后进程内存持续上涨,最终 OOM。原因:每层都新建std::vector且没有复用,或者每层算完的中间缓冲挂在某个缓存 map 里忘了清。另一个隐蔽原因是 OpenMP 线程私有的缓冲区在parallel for内部定义,每轮都重新分配,分配器扛不住。解决:用两个StripBuf做双缓冲 +std::swap递推;线程私有缓冲提到并行区外面,用#pragma omp parallel包住整个层循环,parallel for放里面,让每个线程只分配一次。加个getrusage打印峰值 RSS,跑之前和跑之后对比,能很快定位。
6. 精度验证与进阶技巧:用统计量给金字塔做体检
金字塔算完不能只看“图能打开”,得有一组能自动跑的检查。我常用三个,按成本从低到高排:
第一是统计量守恒。面积平均是线性算子,理论上每层的均值应该和全分辨率均值一致(nodata 排除后)。实际跑出来差在 1e-3 量级内算正常,差到 1e-1 说明有系统偏差。第二是常量图测试,前面提过,全 1000 的图跑完每层还应该是 1000,这一条能一次性抓出舍入、nodata 分母、边界填充三类问题。第三是和参考实现逐像素比对,算最大绝对误差和 RMSE,正常实现下最大绝对误差应该不超过 1 个 DN(纯粹来自舍入顺序差异),超过 2 就有问题。
// 逐像素比对两个同尺寸层,输出最大误差、RMSE 和超阈值像素比例 struct DiffStat { int maxErr; double rmse; double overRatio; }; DiffStat CompareLevel(const uint16_t* a, const uint16_t* b, size_t n, int thresh) { DiffStat s{0, 0.0, 0.0}; uint64_t sq = 0; size_t over = 0; for (size_t i = 0; i < n; ++i) { int d = std::abs((int)a[i] - (int)b[i]); s.maxErr = std::max(s.maxErr, d); sq += (uint64_t)d * d; if (d > thresh) ++over; } s.rmse = std::sqrt((double)sq / n); s.overRatio = (double)over / n; return s; }这里thresh取 1 比较合适:面积平均和参考实现的差异理论上只来自累加顺序,不会超过 1。overRatio超过 0.1% 就值得看一眼差在哪些位置,通常是边界。
进阶上有一个我后来一直保留的优化:如果同一层需要从多个不同尺度重复降采样(比如业务上既要 2 倍也要 3 倍的层),不要各算各的,先做一次水平方向的滑动窗口前缀和,之后任意尺度的水平均值都能 O(1) 取到;垂直方向同理。对 s 较大的情况(s≥8),这比朴素的双层循环快一个数量级,因为重复计算被彻底消掉了。代价是多一份uint32_t的行前缀和缓冲,宽度是源宽加一。
还有一个习惯:每层落盘后立刻用读取接口把文件读回来,重算一次尺寸和元数据,和内存里的期望值比对。听上去是多余的 I/O,但磁盘写满、路径拼接错误、头信息字段漏写这几类问题,都只有读回来才暴露。从那以后我每次改完重采样代码,都强制走一遍“常量图 + 统计量守恒 + 回读校验”三段检查,再放进批量流程里跑。希望帮到你。
本文还有配套的精品资源,点击获取