资讯动态

OpenCV图像算术操作详解:从像素加减到掩膜融合实战

发布时间:2026/10/16 5:07:58 来源:尧图企业网站定制
1. 算术操作图像处理的“加减乘除”这是《OpenCV开发笔记》系列的第五篇。前四篇我们从环境搭建、图像读写、像素遍历走到了颜色空间转换整体上还是在“认识图像”和“搬运数据”的阶段。这一篇要开始玩真的了——把图像当作一组数值矩阵用最朴素的加减乘除做文章。别小看这章内容亮度修正、对比度拉伸、图片融合、水印叠加、ROI区域替换、甚至是动态检测里的帧差法底层全都是这套算术操作在撑腰。我自己的体会是学了这套操作之后很多以前觉得“要上深度学习才能干的事”其实用几十行C就能快速做出效果来。尤其是配合掩膜mask和权重融合能组合出的玩法非常多。这篇会把 OpenCV 里图像算术操作的C接口全部过一遍包括 add、subtract、multiply、divide、bitwise_and 这一族函数再重点讲两个高频场景addWeighted 图像融合和 mask 局部处理最后会带一个完整的亮度/对比度调整实战Demo代码可以直接拿去跑。适合谁看如果你已经会用 imread 读图、知道 Mat 是什么玩意儿但还不太清楚图像是怎么在数学层面被处理的这篇正合适。纯新手也可以看我会把一些基础概念顺带解释清楚遇到不熟悉的函数看一眼也就明白了。2. 图像算术操作的本质与关键设计2.1 图像在内存里其实就是一张数字表格在做任何图像算术之前必须先把一个概念刻在脑子里图像在 OpenCV 里就是 Mat 对象而 Mat 的本质是一块连续内存上的多维数组。灰度图是单通道矩阵每个像素是 0 到 255 之间的整数彩色图是三个通道的矩阵每个像素由 B、G、R 三个分量构成。比如一张 640 像素宽、480 像素高的灰度图它在内存里就是一个 480 行乘 640 列的矩阵一张同尺寸的 BGR 彩色图则是 480 行乘 640 列乘 3 通道的三维数组。cv::Mat 的 type比如 CV_8UC1、CV_8UC3就说明了它的数据类型8 位无符号整数、通道数是 1 还是 3。8U 对应的取值范围就是 0 到 255这个范围直接决定了后续算术操作里最核心的“溢出处理”问题。为什么强调这一点因为很多看起来“不该出错”的代码最后结果一团糟根源就是没搞懂图像是矩阵、像素是有限取值范围的整数。一旦把这一点刻在脑子里后面所有算术操作的行为就都顺理成章了。2.2 为什么不能直接用 C 运算符处理像素这是新手最容易踩的第一个大坑。假设你想把两张图像素级相加第一反应是不是写两层循环然后src1.atuchar(i,j) src2.atuchar(i,j)在 C 里两个 unsigned char 相加如果结果超过 255会直接发生整数回绕比如 200 100 会变成 44300 对 256 取模。这种回绕反映在图像上就是原本应该更亮的区域突然变成了黑色斑块俗称“花屏”。更麻烦的是整个图像处理领域约定俗成的规则是像素值超过上限就截断到上限低于下限就截断到下限这个规则叫“饱和操作”。200 100 应该饱和到 255而不是回绕到 44。C 原生运算符给不了你这个语义你需要自己判断、自己 clamp又慢又容易写错。OpenCV 的算术函数在设计时就用了一个内部机制来解决这个问题saturate_cast。这个模板函数专门做“带饱和的数值转换”它会先按公式计算出浮点或更大整数类型的结果然后用紧凑类型能表示的边界值去截断。所以当你调用cv::add(src1, src2, dst)时内部每个像素都等价于dst.atuchar(i,j) saturate_castuchar(src1.atuchar(i,j) src2.atuchar(i,j))安全、高效、语义正确。理解这一点之后你就会明白一个铁律图像算术操作请始终使用 OpenCV 提供的函数不要自己手写循环去做加减乘除。既是性能问题更是正确性问题。2.3 图像算术操作家族一览OpenCV 的算术操作家族可以分成三组基本四则运算add、subtract、multiply、divide位运算bitwise_and、bitwise_or、bitwise_xor、bitwise_not融合算法addWeighted线性加权混合本质是加法和乘法的组合再加上掩膜参数 mask几乎每个函数都支持“只对指定区域生效”的局部处理能力。这一整章的核心就是把这三大类讲透让你知道什么场景该用谁。3. 加减乘除四则运算的API详解3.1 add 和 subtract最朴素的亮度调整add和subtract的常用形式如下// src1 和 src2 可以是 Mat、MatScalar 或 ScalarMat cv::add(src1, src2, dst, mask, dtype); cv::subtract(src1, src2, dst, mask, dtype);src1 和 src2 要求是相同尺寸、相同通道数、相同位深度的 Mat但其中一方可以是 Scalar也就是一个标量值。这正是做亮度调整最顺手的用法把每个像素的B、G、R分量整体加一个固定偏移量图像会整体变亮或变暗。cv::Mat image cv::imread(test.jpg, cv::IMREAD_COLOR); cv::Mat brightened; cv::Scalar delta(50, 50, 50); // B、G、R 每个通道都加 50 cv::add(image, delta, brightened);注意一个细节add支持 Scalar 与 Mat 相加但 Scalar 的构造顺序是 B、G、R因为 OpenCV 彩色图像默认是 BGR 排列别搞反了。如果你只是想让绿色通道加亮其它通道不动那就写成cv::Scalar(0, 50, 0)。有人会问直接用image delta行不行在 C 里 Mat 重载了运算符确实可以写cv::Mat result image delta;。这个运算符内部走的也是带饱和的 add 逻辑所以语法上没问题。但我的建议是刚开始练习时尽量用明确的函数调用代码可读性更好后面你再决定要不要追求语法糖。3.2 multiply 和 divide缩放与比值multiply和divide的签名类似cv::multiply(src1, src2, dst, scale, dtype); cv::divide(src1, src2, dst, scale, dtype);默认情况下dst 的每个像素等于 src1 对应像素乘以 src2 对应像素再乘 scale。当 src2 是一个 Scalar 时multiply 就是给整张图乘上一个缩放系数。这个操作常用来降低图像亮度比如乘上 0.5 让整体变暗同时不会改变相对明暗关系。divide的一个典型用法是做“图像归一化”或“通道比值”。比如在多光谱图像处理中有时需要计算两个波段之间的比值来增强某些特征这时 divide 就是最直接的工具。注意 div 的规则如果 src2 中某个像素值为 0OpenCV 会按文档约定把它当作极小值处理结果变为一个很大的值某些版本可能直接输出 0 或 255 饱和值所以在做除法前心里要有数最好保证分母不会出现 0。很多新手会把multiply和矩阵乘法Mat::mul()搞混。multiply是点乘也就是逐元素相乘矩阵乘法需要调用Mat的*运算符或gemm。在图像处理里绝大多数情况用的是逐元素乘法矩阵乘法更多出现在几何变换和机器学习里。搞清楚这个区别能避免非常尴尬的 bug。3.3 位运算bitwise_and、bitwise_or、bitwise_xor、bitwise_not位运算这对灰阶图或二值图特别有用它的接口也很简单cv::bitwise_not(src, dst); // 按位取反 cv::bitwise_and(src1, src2, dst, mask); // 按位与 cv::bitwise_or(src1, src2, dst, mask); // 按位或 cv::bitwise_xor(src1, src2, dst, mask); // 按位异或对灰度图像bitwise_not 的效果等价于255 - pixel也就是负片效果比逐像素循环快得多。而对二进制掩膜图像bitwise_and 和 bitwise_or 是做掩膜合并、ROI裁剪的利器。比如你有两张表示前景区域的二值掩膜想求它们的交集就是bitwise_and(mask1, mask2, mergedMask)想求并集就是bitwise_or。我做个简单对比表格方便你记忆位运算的行为运算像素组合规则典型用途bitwise_and两者都是1结果才为1掩膜交集、提取ROI内容bitwise_or任一为1结果为1掩膜并集、区域合并bitwise_xor两者不同结果为1差异检测、切换区域bitwise_not1变0、0变1反色、生成互补掩膜需要注意的是位运算对所有通道独立执行。对彩色图像bitwise_not 得到的是每个通道独立取反后的合成彩色图像效果会像“梦幻胶片”通常不是我们想要的反色。想要真正的彩色负片建议先把三个通道分别取反再合并。3.4 隐式形状与 dtype 问题当我第一次用 add 时在“图像尺寸不一致”这行面前发愣了很久。两个 Mat 做 add 时要求完全同尺寸同类型彩色图和灰度图混加会直接抛出异常。解决方式无非几种先把灰度图转成彩色图cvtColorCOLOR_GRAY2BGR或者用split/merge把单通道数据合并成三通道或者用 mask 掩膜指定参与运算的通道区域。dtype 参数相对冷门但偶尔会有奇效。它仅当输入是低精度如 CV_8U时才能指定输出矩阵的数据类型例如让输出变成 CV_32F。这给了你一种“不改变输入、把结果自动转换为浮点”的手段在做后续精确计算时比较有用。如果输入是浮点图或者你已经在外部创建好了输出矩阵dtype 通常可以省略。4. addWeighted图像融合与加权透明度4.1 线性混合公式与源码语义addWeighted 是“算术操作中最有艺术细胞”的一个函数它做两件事先按权重相加再加一个常数偏移。公式如下dst saturate_cast...(src1 * alpha src2 * beta gamma)和add的纯加不同addWeighted 让两张图以各自权重参与混合。最经典的用法是 alpha beta 1此时两张图以透明度比例混合输出保持着和输入一致的总体亮度水平。cv::addWeighted(src1, 0.7, src2, 0.3, 0.0, dst);这个函数内部仍然是逐像素的乘加运算只是封装好了权重系数和饱和处理。你用 CV_8UC3 的彩色图调用它结果依然是 CV_8UC3每个通道都独立按照公式计算。如果 alpha、beta 的和超过 1输出会明显变亮如果小于 1输出会整体偏暗。这个特性在制作淡入淡出动画时特别方便alpha 从 1 渐变到 0beta 从 0 渐变到 1画面就能从一个素材平滑过渡到另一个素材。4.2 实战场景一给图像加个半透明水印给图片叠加 logo 是很多开发者的真实需求。水印讲究的是“半透明、不遮主体”addWeighted 正好是干这个的cv::Mat base cv::imread(content.jpg, cv::IMREAD_COLOR); cv::Mat logo cv::imread(logo.png, cv::IMREAD_COLOR); // 尺寸最好先resize // 把 logo 缩放到目标区域 cv::Mat roi base(cv::Rect(50, 50, logo.cols, logo.rows)); cv::addWeighted(roi, 0.8, logo, 0.2, 0.0, roi);注意这里的关键技巧roi不是一份新矩阵而是base的一个视图对roi的操作会直接写入原图的对应区域。把 base 的一个子区域和 logo 用权重混合结果就是那个区域变成了半透明水印效果其它区域保持原样。不过这种方式只适用于矩形区域如果 logo 本身自带形状比如圆形标志它周围不想出现黑色或白色底就需要配合掩膜来做下一章细说。我在实际项目中用 addWeighted 做过一个“老照片怀旧滤镜”把原图和一个纯褐色底图按 7:3 混合再用 reduce 做饱和度微调效果比很多人想象的快得多。这种滤镜逻辑本质就是不依赖深度学习的像素级混合很多美颜、怀旧、胶片风格的 App 早期版本就是这么玩的。4.3 实战场景二左右渐变融合addWeighted 的权重也可以是逐像素变化的。如果你想做一个“左图向右图渐变过渡”的效果方法很简单构造一个单通道灰度图作为权重图左半边接近 0右半边接近 255然后用 split/merge 或者 convertTo 把权重图变形为三通道再利用乘法和加法完成混合。当然更直接的做法是写循环对每一列按位置计算权重再把每列像素填进结果矩阵for (int col 0; col width; col) { double alpha (double)col / (width - 1); for (int row 0; row height; row) { dst.atcv::Vec3b(row, col) src1.atcv::Vec3b(row, col) * alpha src2.atcv::Vec3b(row, col) * (1 - alpha) 0.5; } }这里我故意略去了 saturate_cast 的细节你可以看到手工处理要考虑的问题。实际上这种逐像素循环虽然直观性能却远不如用addWeighted配合权重矩阵一步搞定。推荐的做法是先用cv::Mat::zeros生成一张与图像等大小的浮点权重图把左半部分权重填充为 0 到 1 之间的渐变值然后用merge变成三通道最后cv::multiply加cv::add完成混合。这个思路一旦理解以后做任何渐变过渡都会很快。4.4 权重选择的原则用 addWeighted 时权重不是拍脑袋定的。如果是做不同曝光图像的 HDR 合并alpha/beta 要参考曝光时间比如果是叠水印alpha 建议在 0.8 到 0.95 之间太低导致水印不清晰太高则遮挡内容如果是做转场过渡权重是时间 t 的函数。基本原则是先想清楚“混合后希望哪个主体占主导”再决定权重否则最后调参能调到你怀疑人生。5. 掩膜Mask局部算术操作的灵魂5.1 掩膜是什么和ROI有什么区别掩膜mask是 OpenCV 中一个至关重要的概念。一个 mask 本身也是 Mat通常是 CV_8UC1 单通道图像。当算术操作带 mask 参数时OpenCV 只对 mask 中像素值非零的区域执行运算mask 中为 0 的位置直接原样保留。举一个最直观的例子用add把两张图叠加时如果只想让左半部分执行加法、右半部分保持 src1 原样就可以构造一个左半为 255、右半为 0 的 mask传入cv::add(src1, src2, dst, mask)。这个能力是 ROI 做不到的——ROI 只能是矩形而 mask 可以是任意形状圆形、任意多边形甚至是从轮廓生成的。5.2 无底水印叠加的开销与实现回到上一节水印场景。如果 logo 是一个背景透明的圆形而你直接用一个矩形区域做 addWeightedlogo 周围就会出现矩形底色很难看。正确做法是把 logo 配合它的 alpha 通道透明度通道转成 mask再把 mask 缩放成目标大小最后在目标区域做加权或者纯复制叠加。cv::Mat logo cv::imread(logo.png, cv::IMREAD_UNCHANGED); // 读入含透明通道 cv::Mat logoBGR; cv::Mat logoMask; if (logo.channels() 4) { cv::Mat channels[4]; cv::split(logo, channels); logoBGR cv::Mat(logo.size(), CV_8UC3); cv::merge(std::vectorcv::Mat{channels[0], channels[1], channels[2]}, logoBGR); logoMask channels[3]; // alpha 通道直接作为 mask } // 目标矩形区域 cv::Rect dstRect(100, 100, logo.cols, logo.rows); cv::Mat dstRoi base(dstRect); logoBGR.copyTo(dstRoi, logoMask); // 只有 mask 非零区域才复制这里copyTo带 mask 的用法非常经典它把 logo 的 BGR 内容复制到 base 的指定矩形区域只有 alpha 不为 0 的位置才覆盖原图。效果就是圆形 Logo 边缘干干净净不会出现矩形黑边。这种技能在做界面贴图、证件照合成、游戏资源叠加时非常常用。5.3 如何用 cv::compare 和 cv::inRange 快速生成掩膜mask 不一定是现成的很多时候需要自己生成。生成掩膜最方便的函数有两个cv::compare和cv::inRange。compare按指定比较规则把像素变成 0 或 255例如你有一张灰度图想知道哪些像素大于 100可以这样cv::Mat mask; cv::compare(gray, 100, mask, cv::CMP_GT); // 大于100的位置变255inRange更加强大它可以同时设定上下界把落在区间内的像素置为 255、区间外的置为 0。比如在一张饱和度很低的图片中提取某个颜色范围cv::Mat mask; cv::Scalar lower(0, 50, 50); cv::Scalar upper(30, 255, 255); cv::inRange(hsvImage, lower, upper, mask);这个 mask 一路可以直接喂给 add、subtract、copyTo 等所有支持掩膜的函数实现“只有指定颜色区域才参与运算”。这个组合拳是 OpenCV 里性价比最高的技能之一颜色分割、特效区域处理全都会用到。6. 实操案例C实现亮度与对比度调整6.1 需求描述与效果目标作为算术操作的综合练习我用这套函数实现一个完整的图像亮度与对比度调整 Demo。目标是把一张彩色图片用两个滑杆控制亮度0 到 200和对比度0.5 到 2.0实时生成调整结果并显示。这个功能几乎是所有图像处理软件的标配而它的核心逻辑只需要 add 和 multiply 就能实现。6.2 算法原理亮度加常数、对比度乘系数亮度的本质是在每个通道上加一个常量偏移偏移大图像亮、偏移小图像暗。对应代码如下cv::Scalar brightnessOffset(brightness, brightness, brightness); cv::add(img, brightnessOffset, adjusted);对比度的本质是乘一个因子大于 1 的因子会让亮的地方更亮、暗的地方更暗从而拉大差异小于 1 则让图像整体趋向于平均灰显得发灰。单独乘一个系数的实现非常直接double contrastFactor contrast; // 例如1.5或0.7 cv::multiply(img, contrastFactor, adjusted);不过更常用的对比度算法是把像素围绕均值变换避免调整对比度时引起整体亮度偏移dst (src - mean) * factor mean围绕均值做乘法的好处很明显数值高于均值的像素被拉伸得更亮低于均值的被压得更暗但整体均值不变所以不会让人感觉“整体变亮了”。实现这段逻辑时可以先把图像转换为浮点型convertTo CV_32F或者干脆利用mean函数计算平均值然后用 add/subtract/multiply 逐步完成。6.3 完整可运行代码与参数分析下面给出一段可以直接拷贝编译运行的示例代码基于 OpenCV 3.4 以上版本C11 编译#include opencv2/opencv.hpp #include iostream int main() { cv::Mat src cv::imread(sample.jpg, cv::IMREAD_COLOR); if (src.empty()) { std::cout Failed to load image std::endl; return -1; } int brightness 30; // -100 ~ 100 double contrast 1.2; // 0.5 ~ 2.0 // 亮度调整逐通道加偏移 cv::Mat bright; cv::add(src, cv::Scalar(brightness, brightness, brightness), bright); // 对比度调整围绕均值做乘法避免亮度漂移 cv::Scalar meanVal cv::mean(src); cv::Mat adjusted; cv::subtract(bright, meanVal, adjusted); // (x - mean) cv::multiply(adjusted, contrast, adjusted); // * factor cv::add(adjusted, meanVal, adjusted); // mean // 显示原图与调整结果 cv::imshow(Original, src); cv::imshow(Adjusted, adjusted); cv::waitKey(0); return 0; }这段代码里有一个隐藏很深但很易踩的坑mean返回的是 Scalar但src是三通道而subtract要求 src2 与 src1 通道数兼容。Scalar在这里因为有 4 个分量与三通道图是匹配的所以能跑。实际项目中这个用法非常安全。另一个值得注意的参数细节是如果你直接用cv::multiply(adjusted, cv::Scalar(contrast, contrast, contrast), adjusted)效果也是一样的但挑multiply和add分开写可以让你在中间插入其它处理比如锐化、模糊、gamma 校正扩展性更好。对于实时交互你还可以把 brightness 和 contrast 绑定到鼠标或滑动条配合cv::createTrackbar就能做成一个简易调色工具。6.4 效果评估与处理建议亮度调整的视觉效果直接取决于偏移量偏移 50 会让中间调明显变亮阴影部分也会升高偏移 -50 则会让整体变暗阴影部分最先变成 0 纯黑。对比度调整乘以大于 1 的系数时高光可能提前撞到 255 饱和阴影可能撞到 0 纯黑这种“撞墙”效果有时被叫做“硬对比”如果不想让它发生可以先把图像转成浮点处理最后再输入到饱和转换函数。我实际测试下来用上述代码对普通室内照片进行处理时亮度 30、对比度 1.2 的搭配比较自然亮度更高容易过曝对比度更高会显得生硬。如果你想追求柔和风格对比度取 0.8 到 1.1 更稳妥。经验值在图像处理里非常重要参数不是数学推导出来的而是你反复调试后形成的手感。7. 常见问题与避坑实录7.1 数据类型不匹配导致的异常这是算术操作里出现频率最高的问题。cv::add在做CV_8UC1和CV_8UC3混加时会直接抛出异常。我的建议是在调用任何算术函数之前先打印两个矩阵的 type 和 size确保它们一致。批量处理大量文件时可以用cv::resize统一尺寸用cvtColor统一通道再做运算。千万别指望 OpenCV 会自动广播。7.2 溢出与花屏现象排查结果出现大面积花斑、条纹时第一反应是自己写代码做了像素级 uint8 运算而没有用 OpenCV 的人性化 saturate_cast。比如// 错误示范 uchar val src1.atuchar(i,j) src2.atuchar(i,j); dst.atuchar(i,j) val; // 超过255会自动回绕即使你用的是某个重载运算符比如直接dst src1 src2也要确认这个重载用的是饱和还是模 256 回绕。在 OpenCV 的 Mat 运算符实现里实际上用的也是饱和加所以用 Mat 重载是安全的怕的是你自己写 for 循环。7.3 性能陷阱Make Sure 预分配与临时对象add这类函数如果 dst 为空函数内部会自动为 dst 申请内存。如果你在视频循环里每帧调用 add反复申请释放内存会造成明显的性能开销。正确做法是在循环外先dst.create(src.size(), src.type())预分配好输出矩阵循环里传进去复用。另外图像处理里尽量避免在循环体内创建大对象这句话值得贴在工位上。7.4 使用 mask 参数时容易出现的大小不匹配mask 必须与 src1、src2 有相同的尺寸通道数通常为 1类型为 CV_8UC1。如果你把一个三通道图当 mask 传给 addOpenCV 一般会报类型错误。如果 mask 尺寸比图像小也会在运行时被拒绝或更糟在旧版本里静默地得出错误结果。所以无论是用 inRange 还是 compare 生成 mask最后用mask.size()和src.size()比对一次养成习惯。7.5 原地操作与结果对象冲突某些操作可以原地执行比如cv::add(src1, src1, src1)或者src1 src2OpenCV 内部会做临时拷贝保护。但如果你同时把 src1 既作为输入又作为输出又作为 mask 使用就可能出现不可预知的覆盖问题。更稳妥的做法是使用独立的输出 Mat这样防止逻辑错误也方便回溯调试。我把这几个常见坑整理成一个速查表方便你排查现象可能原因处理建议程序报错退出尺寸/通道/类型不匹配打印 type 和 size统一后再算图像出现雪花或斑块uint8 溢出回绕改用 OpenCV 算术函数避免手写循环处理速度很慢每次调用动态分配内存循环外 create 预分配输出结果整体黑掉/白掉权重和不为1或 mask 覆盖错误检查 alphabeta、mask 尺寸和数值mask 不生效mask 类型错误或尺寸不匹配用 CV_8UC1 同尺寸 maskdebug 时 imshow(mask)7.6 我自己的一个调试技巧调试算术操作时最忌讳直接看最终效果图因为你看不出来是哪个环节出了问题。我的习惯是一边处理一边把中间结果用小图并排显示出来比如把 source、mask、weighted result 分别放在三个窗口或者用 hconcat 拼成一张大图保存。这样定位问题非常快。另外处理高分辨率图片时先用一个小尺寸副本试参数参数确定后再对原图操作省出来的调试时间够你喝很多杯咖啡。这套算术操作最迷人的地方在于它的组合潜力。很多人觉得这四个函数太简单一眼就看穿了但真正遇到实际问题时灵活组合它们的能力才是核心差异。帧差法、背景建模、图像融合、混合滤镜这些看似要“高级算法”的任务底层不过是加减乘除和位运算的循环往复。搞清楚这一章你的图像处理工具箱才算真正有了地基。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑