我要提问
ARTICLE DETAIL

资讯详情

前沿编程新知与开发实战干货的深度解读。

OpenCV隔行降采样手写实现:从Mat指针到像素级缩放原理

OpenCV隔行降采样手写实现:从Mat指针到像素级缩放原理 简介图像降采样是图像处理中的重要操作用于减少数据量、降低计算开销同时尽量保持视觉质量。这份面向C与OpenCV开发者的工程资源围绕隔行降采样和高斯金字塔降采样两种方案展开源码基于VS2015编写并对比了前者在速度上的优势据称比传统金字塔快约4倍。压缩包共40个文件约9.02MB以VS2015工程文件、main.cpp源代码、编译生成的可执行文件与调试信息以及示例图像为主打开即可查看代码和运行效果。目前已有557人浏览学习适合希望深入图像缩放、降采样算法实现与性能优化的学习者参考。通过工程目录与源码可掌握隔行取样的具体写法、OpenCV图像处理接口的调用方式并对比不同下采样策略在边缘保留和耗时上的实际差异。1. 隔行降采样一个过滤器实现的像素取舍逻辑先看结论这个工程不是用OpenCV现成的resize或pyrDown而是自己手写了每隔一行、每隔一列取一个像素的隔行降采样逻辑按VS2015工程组织。一张4000×3000的图跑完输出2000×1500耗时比pyrDown低不少代价是斜边会留下轻微锯齿——这是不滤波直接抽样的必然结果。它不是万能的缩图方案但非常适合两件事批量出预览图以及想搞懂OpenCV里Mat的每行字节是怎么排的人。核心代码只有几十行做完这个Demo你会真正理解ptr、step、elemSize这几个常见的OpenCV概念之后再看resize这类封装函数心里就不发虚了。2. 技术选型为什么不直接调resize或pyrDown2.1 隔行降采样到底做了什么隔行降采样听起来抽象翻译成代码就是一句话输出像素(i,j)直接取输入像素(2i,2j)的值。换句话说原图里所有的偶数行、偶数列被保留奇数行、奇数列整个被丢掉。假设输入是2000×1500输出就是1000×750像素总量变成原来的四分之一。这里有个很多人第一次看会误解的地方它不是取2×2区域的均值也不是看邻域取最大值而是硬跳。所以它的数据访问量天然只有原图的四分之一这是它比pyrDown快的根源。缺点是跳过的像素里可能有高频信息比如细小的斜线和密集纹理输出就会出现阶梯状锯齿。如果按信号处理的说法正确的抽样流程是先做低通滤波把高频抹掉再隔点抽样。隔行降采样把第一步省了锯齿就是这个“省”字换来的。这个工程没有试图掩盖这个缺点反而把它做得非常透明核心函数就叫downSampleHalf谁调用谁心里有数。2.2 和resize、pyrDown的区别很多初学者一上来就写cv::resize(src, dst, cv::Size(w/2, h/2), 0, 0, cv::INTER_NEAREST)发现结果和隔行降采样很像就来问这个工程存在的意义。这两种做法确实等价INTER_NEAREST本质就是取最近的那个像素在整倍缩小时候结果就是隔行取点。区别在于resize把边界、插值、任意尺寸的缩放逻辑都包进了一个黑匣子而这个工程把最原始的采样逻辑摊开在代码里。pyrDown则是另一种思路先对原图做5×5高斯卷积再删除偶数行和偶数列。多了一次滤波所以边缘更干净但耗时也会上去对预览图场景往往没必要。方案是否预滤波输出尺寸相对耗时边缘表现隔行跳点本工程否原图1/4像素约1倍斜边锯齿明显resize INTER_NEAREST否任意目标尺寸约1倍同样锯齿pyrDown是5×5高斯原图1/4像素约24倍平滑但略模糊这个项目选隔行而不是pyrDown不是说pyrDown不好而是要把“采样”本身讲清楚。对只想快速看图、对边缘质量不敏感的需求隔行是性价比最高的选择。还有一个实践里的区别resize可以输出任意尺寸隔行降采样输出尺寸是绑定的——输入w×h输出大约(w/2)×(h/2)具体取整方式看实现。这个工程采用向上取整也就是(src.rows1)/2和(src.cols1)/2这样当输入是奇数尺寸时最后一行偶数列也不会被丢掉。如果你直接写(rows/2)一张1001×701的图会输出500×350最后一行被丢弃连续处理一批不同尺寸的图片时这个小差异会造成结果尺寸统计的偏差。这个细节后面避坑章节会单独展开。2.3 为什么是VS2015 OpenCV的组合从工程环境看VS2015对应的编译器工具集是vc14OpenCV 3.x时代里vc14的预编译包是当时很常见的学习配置。用VS2015打开.sln配置好包含目录和库目录直接F5就能跑不需要额外装一堆依赖。如果是较新版本的OpenCV预编译包默认面向更新的VS工具集硬塞给VS2015用会在编译阶段报出一堆标准库相关错误。所以这份工程的定位非常老派但也非常稳C11级别的代码一张BMP输入一个函数输出。需要注意一点工程头文件里只引用了opencv2/opencv.hpp这一个总头文件没有细分模块。对性能没影响编译时多花几秒而已。读者如果想减少编译依赖可以改成opencv2/core.hpp、opencv2/imgproc.hpp和opencv2/highgui.hpp三个同样的功能。后面二次开发时我一般会这么拆开引。另外我把运行方式固化成命令行两个参数输入路径和输出路径不用改代码就能处理任意图片。调试的时候也可以在main里写死路径但提交代码时我会把硬编码路径换回参数免得别人拿到一个只能跑特定文件的工程。3. 工程结构与数据流从BMP到半分辨率图的完整链路3.1 解压之后看到的文件布局拿到这个zip包解压后是一个完整的VS2015解决方案典型目录结构长这样ImageDownSample/ ├── ImageDownSample.sln ├── ImageDownSample/ │ ├── ImageDownSample.vcxproj │ ├── src/ │ │ ├── main.cpp │ │ ├── downsample.cpp │ │ └── downsample.h │ ├── data/ │ │ ├── input.bmp │ │ └── output.bmp │ └── README.md └── packages/ └── opencv/ ├── include/ └── x64/vc14/lib/这个布局里.sln和.vcxproj是VS2015工程入口双击sln打开就能编译。src下三个文件main.cpp负责读图和写图downsample.h/cpp是核心算法。data里放输入输出图片BMP格式因为BMP解码简单适合入门调试换成jpg也完全支持OpenCV的imread/imwrite对格式是透明的。packages目录是OpenCV相关的头文件和库实际使用时路径按本机安装位置调整。如果读者拿到的包没有带OpenCV的库文件就需要自己安装OpenCV并配置环境具体流程在避坑章节有说明。从数据流的角度看这张图的旅程是磁盘BMP文件 → imread解码成Mat内存里是一块连续堆内存带行对齐→ downSampleHalf逐个像素搬运 → imwrite编码回BMP。很多初学者以为读图就是一块连续像素就完了忽略了一个关键事实Mat的每行字节数可能大于宽度乘以通道数这个“可能”取决于图像尺寸和OpenCV内部对齐规则。理解这条数据流之后再看后面ptr的实现就不会觉得step是玄学。3.2 主程序就三行main.cpp的职责非常单纯读一张图片调用采样函数写出一张新图。核心代码#include opencv2/opencv.hpp #include downsample.h #include string int main(int argc, char** argv) { // 从命令行接收图片路径 std::string inPath argc 1 ? argv[1] : data/input.bmp; std::string outPath argc 2 ? argv[2] : data/output.bmp; // 读取图像, 按BGR三通道解码 cv::Mat src cv::imread(inPath, cv::IMREAD_COLOR); if (src.empty()) { return -1; // 读不到图直接退出 } // 隔行降采样, 输出长宽各减半 cv::Mat dst; downSampleHalf(src, dst); // 写回磁盘 cv::imwrite(outPath, dst); return 0; }这里有几个参数说明argv[1]是输入图片路径argv[2]是输出图片路径。如果不传参数就默认走data目录下的input.bmp和output.bmp方便在IDE里直接F5调试。cv::imread第二个参数IMREAD_COLOR表示强制解码成三通道BGR这样后续代码可以直接用Vec3b类型访问像素不用判断灰度图分支。如果你要处理灰度图更常见的做法还是保留IMREAD_UNCHANGED让它按原格式读。cv::Mat是OpenCV的图片容器src.empty()判断读取是否失败。路径写错时imread不会抛异常而是返回空Mat这个坑几乎人人都会踩。代码之后为什么不把循环写在main里因为后续如果要做任意倍率、先滤波再采样、批处理多个文件这些扩展都会围绕这个函数展开把它独立成模块main里的代码就不用跟着改。3.3 接口约定与输出尺寸规则downsample.h暴露的接口只有一个这样设计调用方不需要关心内部是at还是ptr实现的方便后面替换算法实现。接口定义// 对BGR图像做隔行降采样, 输出尺寸由src决定 void downSampleHalf(const cv::Mat src, cv::Mat dst);这里的参数规则参数输入/输出说明src输入三通道BGR图行列数任意dst输出为空Mat时函数内部分配内存调用前无需预分配返回值无出错时dst保持空Mat输出的行列数规则是dst.rows (src.rows 1) / 2dst.cols (src.cols 1) / 2。这意味着原图2000×1500会得到1000×750原图1001×701会得到501×351。奇数尺寸时最后一行余数被保留奇数行被丢弃。一个安全的习惯是函数开头先dst.release()再重新分配避免外部传入的旧Mat占用内存不清。4. 核心实现拆解ptr指针遍历与行列步长控制4.1 第一版at方式的直观实现刚开始学OpenCV时最容易写出的版本是双重循环加at访问void downSampleHalf(const cv::Mat src, cv::Mat dst) { int dstRows (src.rows 1) / 2; int dstCols (src.cols 1) / 2; dst cv::Mat(dstRows, dstCols, src.type()); for (int r 0; r dstRows; r) { for (int c 0; c dstCols; c) { // src的第 r*2 行, 第 c*2 列 dst.atcv::Vec3b(r, c) src.atcv::Vec3b(r * 2, c * 2); } } }这段代码的逻辑完全正确输出也没问题dst.at (r,c)访问的是输出图第r行第c列的BGR像素src.at (r2,c2)对应源图的偶数行偶数列。它最大的问题是性能——at内部会做坐标范围检查相当于每次访问像素都有一次if判断在千万像素级别的循环里这些检查累积起来的开销很可观。如果只看代码可读性这个版本确实最好懂尤其是第一次接触图像遍历的人。网上很多教程就用这种写法讲隔行降采样但实际做批量处理时会发现速度不满意。替换成ptr版本逻辑没变速度提升明显。4.2 第二版ptr 字节偏移的高效写法生产环境我一般用ptr版本。代码如下void downSampleHalf(const cv::Mat src, cv::Mat dst) { int dstRows (src.rows 1) / 2; // 输出行数 int dstCols (src.cols 1) / 2; // 输出列数 dst cv::Mat(dstRows, dstCols, src.type()); int elemSize (int)src.elemSize(); // BGR三通道时为3 for (int r 0; r dstRows; r) { // 指向源图像第 r*2 行的首地址 const uchar* srcRow src.ptruchar(r * 2); // 指向输出图像第 r 行的首地址 uchar* dstRow dst.ptruchar(r); for (int c 0; c dstCols; c) { // 输出第c个像素 源第c*2个像素 memcpy(dstRow c * elemSize, srcRow c * 2 * elemSize, elemSize); } } }逻辑说明外层r控制输出行同时把源行索引映射成r*2这就是“隔行”的行方向含义。输出第0行来自源第0行输出第1行来自源第2行输出第2行来自源第4行。内层c控制输出列列方向的步长同样为2所以字节偏移是c2elemSize。c*elemSize是输出列的字节偏移。memcpy一次搬elemSize个字节对BGR图就是3字节B、G、R顺序对灰度图是1字节。这比用Vec3b赋值更通用换图像类型时不用改循环体。注意srcRow的类型是const uchar*因为源Mat只读dstRow是uchar*可写。这个const写对之后编译器能帮你发现误写源图的bug。参数说明函数内部通过src.type()把矩阵类型传给dst所以无论输入是CV_8UC3还是CV_8UC1输出类型自动保持一致。这里的elemSize来自src不是写死的3哪怕将来处理四通道RGBA图这段代码也能直接工作。为什么不直接按4.1的at版本发布因为在VS2015默认配置不开全优化时at版本的循环体里每次都要走Mat::at的检查分支跨过千万像素之后耗时会明显拉开。这个工程的核心卖点就是“快”自然要用指针版本。4.3 怎么验证采样结果对不对代码写完先别急着跑真实图片。我习惯先用一块格子图验证#include opencv2/opencv.hpp #include downsample.h void testSample() { // 构造4x4的单通道图, 值分别为0..15 cv::Mat test(4, 4, CV_8UC1); for (int r 0; r 4; r) for (int c 0; c 4; c) test.atuchar(r, c) (uchar)(r * 4 c); cv::Mat dst; downSampleHalf(test, dst); // 输出2x2, 值应为 {0,2,8,10} // dst(0,0)src(0,0)0 // dst(0,1)src(0,2)2 // dst(1,0)src(2,0)8 // dst(1,1)src(2,2)10 }这个验证方法特别适合入门阶段因为只用眼睛看数值不用引入PSNR之类指标。如果跑出来的不是0、2、8、10说明行步长或者列步长写错了。常见错误是把列偏移写成c2而忘了乘以elemSize在灰度图上c2恰好等于字节偏移一换到三通道BMP就错位——这也是我前面强调elemSize的原因。4.4 内存连续性与进一步优化一个自然的问题是能不能用更高效的方式做整块拷贝答案是取决于Mat是否连续。正常情况下imread得到的Mat数据是连续分配的但不保证step等于cols*elemSize因为OpenCV为了提高内存对齐性能会让每行从某个边界开始。src.isContinuous()返回true时可以把整张图当成一维数组访问但隔行取点仍然不是线性的访问模式所以无法直接memcpy整块只能保持像素级复制。我一般会做的优化是在函数开头把src.step和dst.step取出来内层循环前把两个行起始地址算好避免每次访问rowRange这类视图。如果还想更快可以用OpenMP#pragma omp parallel for for (int r 0; r dstRows; r) { // 循环体不变, 每行独立, 天然适合并行 }注意前提是dst在并行前就要分配好内存每行只写自己的区域没有竞争。VS2015里要记得在C/C → 命令行里加/openmp编译选项否则这个pragma会被静默忽略。这个改动在多核机器上能把4000×3000的图压缩到几毫秒级别不过对学习项目来说先保证正确再谈并行更稳。5. 避坑实录版本错配、奇数尺寸与内存对齐这几条按出现频率排序前三条与环境配置有关后两条是算法实现本身的边界和效果问题。5.1 编译期报错找不到opencv2/opencv.hpp现象用VS2015打开.sln后代码文件里第一行#include opencv2/opencv.hpp就画了红波浪线编译同样报错找不到文件。原因主要是包含目录没配上或者本机装的是较新版本的OpenCV其预编译库面向的是更新的VS工具集头文件路径本身没变但直接配给VS2015会在后续引出链接错误。解决安装老一代OpenCV 3.x的release包装好后在工程属性 → VC目录 → 包含目录填入opencv的include目录库目录填入x64/vc14的lib目录。注意位数要和编译平台一致Debug x64对应x64的库。如果嫌属性页麻烦也可以在vcxproj里写相对路径但这个工程默认要求手动配置一次。提示VS2015对应vc14装OpenCV时先看预编译包里有没有对应vc14的文件夹。5.2 输出尺寸总是比预期少一行现象输入1001×701预期501×351实际拿到的输出却是500×350少了一行一列。原因很多人把尺寸计算写成src.rows/2整数除法直接截断奇数行被丢掉。更隐蔽的情况是函数内部误写成(src.rows - 1) / 2结果完全一样。解决统一用(src.rows 1) / 2向上取整。这样1001→501701→351原理是最后一行输出索引(dstRows-1)*2 700、1000都小于各自的原始尺寸访问不会越界。如果确实想要向下取整的行为也要在注释里写清楚免得后续维护的人改错语义。5.3 Debug能编译运行崩溃在Mat构造或imread内部现象程序编译和链接都过了一运行就在cv::Mat::create或imread内部崩溃弹窗外表看是0xC0000005之类的访问冲突。原因Debug配置链接了release版本的OpenCV库运行时把Debug版CRT和release版堆混在一起两边的内部状态不一致几乎必然崩。解决Debug只连带d后缀的库不带d的是Release使用的。工程属性里“附加依赖项”按配置分开填或者用编译宏#ifdef _DEBUG // 链接Debug库, 具体文件名以本机安装的OpenCV为准 #else // 链接Release库 #endif很多人在这条上反复翻车解决办法就是把Debug和Release的附加依赖项各配一份不要手动改来改去。5.4 输出图案出现整行错位现象隔行采样后每行内容像是向右错了一个像素或者每隔一行出现一条斜向错位带。原因典型错误是在内层循环里用c2作为字节偏移而忘了乘以elemSize。灰度图时elemSize1恰好对换成三通道BMPc2只移动了2字节可一个像素占3字节于是取到的数据横跨了半个像素行内出现色彩错乱。另一个相似错误是跨行时只用ptr(0)加rcolselemSize去定位没有考虑行对齐。当step比cols*elemSize大时每一行的起点会慢慢漂移最终变成斜线错位。解决每行重新用src.ptr(r2)取行首地址列偏移写成c2*elemSize行偏移不手动累加。这两个写法守住错位问题基本绝迹。5.5 隔行降采样后图片锯齿太重现象斜线边缘像楼梯文字小笔画明显发虚放大了看全是方块。原因这不是代码bug是这个方案本身的局限。没滤波就抽样大于新分辨率一半频率的成分折叠回低频形成混叠。解决如果应用不允许锯齿就在采样前先做一次GaussianBlur再调用隔行采样函数cv::Mat blurred; cv::GaussianBlur(src, blurred, cv::Size(3, 3), 0); downSampleHalf(blurred, dst);这一行的成本是采样本身的几倍但视觉效果会明显好于裸跳点。更省事的做法是直接用pyrDown它内置了高斯滤波。隔行降采样适合快速预览、缩略图以及需要自己控制滤波效果的二次开发场景。6. 二次开发把固定半倍改成任意倍率采样6.1 通用scale函数downSampleHalf写死缩小一半改成任意整数倍缩放的函数很简单把行步长和列步长从2换成参数scalevoid downSampleScale(const cv::Mat src, cv::Mat dst, int scale) { CV_Assert(scale 1); // 非法参数直接抛异常 int dstRows (src.rows scale - 1) / scale; // 向上取整 int dstCols (src.cols scale - 1) / scale; dst cv::Mat(dstRows, dstCols, src.type()); int elemSize (int)src.elemSize(); for (int r 0; r dstRows; r) { const uchar* srcRow src.ptruchar(r * scale); uchar* dstRow dst.ptruchar(r); for (int c 0; c dstCols; c) { memcpy(dstRow c * elemSize, srcRow c * scale * elemSize, elemSize); } } }这段代码和之前的差别就两个地方行索引从r2变成rscale列偏移从c2elemSize变成cscaleelemSize。scale2时行为完全一致scale3时每隔三行三列取点输出面积变成原来的九分之一。src.ptr (rscale)要求rscale小于src.rows这里的dstRows取整公式正好保证这一点。验证方法把同一张输入分别用downSampleScale(src, dst1, 2)和downSampleHalf(src, dst2)跑一遍再逐像素比较dst1和dst2应该完全相等。再加一个边界用例1001×701的图scale2输出501×351检查最后一行索引1000和700都合法。6.2 工程改造的下一步有了任意倍率scale之后可以往三个方向扩展一是把scale2时的函数直接改成wrapper保持对外接口不变二是想做更平滑的效果在采样前加GaussianBlur把滤波强度做成可调参数三是改造成批处理工具循环读一个文件夹里的图片。做批处理时要注意Mat和文件名同名覆盖我一般会在输出路径里拼上分辨率后缀比如input_500x350.bmp。性能优先的话用OpenCV自带的parallel_for_对行循环做并行比手写线程池简单得多cv::parallel_for_(cv::Range(0, dstRows), [](const cv::Range range) { // 对range内的每一行执行采样, dst已提前分配 });行与行之间没有依赖并行后不会出现数据竞争这是这类图像缩放算法最容易吃到的性能红利。从那以后我每次写新的图像处理函数都会准备一张4×4或6×6的手工数值图先把行步长和边界条件验一遍再上真实图片。这份隔行降采样工程虽然代码量很小却把OpenCV的Mat内存模型、指针遍历、环境版本这些最磨人的点完整过了一遍看懂它之后再看resize的各种插值算法就不至于一头雾水。希望帮到你。本文还有配套的精品资源点击获取
返回列表