1. 图像算术操作:先搞清楚它到底在算什么
OpenCV 写到第五篇,正好把图像算术操作单独拎出来聊聊。先说个结论:这东西看起来就是“两张图加减乘除”,实际上一头扎进去会遇到类型溢出、通道不匹配、结果发白发黑等各种问题。我自己刚用 C++ 写 OpenCV 的时候,在模拟项目X里做图像融合,直接用像素值相加把两张 8 位图叠在一起,结果输出一片惨白,后来才知道像素算术和数学加法完全是两件事。这篇文章适合正用 C++ 做图像处理、被 add/subtract/multiply/divide 折磨过的朋友,也适合刚入门、想一次搞懂底层规则的读者。标题里虽然写着“第五”,但内容完全独立,照着前后翻页看就行。
1.1 图像在内存里到底是什么?算术操作又是在操作什么?
许多新手把图像当作“一张图”,但在 OpenCV 中,cv::Mat本质是一个多维矩阵。灰度图是单通道矩阵,即每个像素一个数值;彩色图则是三通道矩阵,比如 BGR 顺序,每个像素包含三个独立数值。图像算术操作不是线性代数里的矩阵乘法,而是把两个矩阵中对应位置上的像素值逐一拿来运算。打个比方:两个班同学按座位两两结对,每个人只和自己同一座位的人打交道,不会串到隔壁位置。
这种逐像素操作的细节很关键。比如对一个 640x480 的灰度图执行加法,OpenCV 内部相当于遍历 640480=307200 个像素,每个像素做 src1[i] + src2[i]。换成彩色图,次数还要乘以通道数,也就是 3072003。如果你在处理三通道图像时误用了单通道的方法,最后报错或者结果错位都是正常的。cv::Mat里的通道排列通常是三通道交错存储,但 OpenCV 已经帮你处理好了内存步长问题,你不需要自己关心每个字节在内存第几位,但必须清楚:算术操作是逐像素、逐通道进行的。
1.2 8 位无符号图像的最大问题:溢出与截断
大部分图像都是 CV_8UC1 或 CV_8UC3,像素值范围被限制在 0 到 255。这意味着任何算术操作的结果如果超过 255,都是“装不下”的。裸的 C++ uchar 变量做加法,比如 200 + 100,会得到一个无符号数溢出后的 44,而不是 300。这种环形溢出体现在图像上,就是本该很亮的地方突然变黑或者出现奇怪的条纹。不少新手看到这种结果,第一反应是代码逻辑写错了,其实问题出在数据类型的承载能力上。
OpenCV 提供的算术函数默认不做环绕溢出,而是做饱和截断,即saturate_cast<uchar>()。200 + 100 会被钳制到 255,结果仍然是白色,至少不会出现“亮斑变黑”的反直觉现象。这个设计对无符号图像非常重要。类似地,减法若出现负值会被截断为 0,乘法若超过 255 会被钳到 255,除法若除数为 0 也会被单独处理。换句话说,OpenCV 的算术函数自带一层安全保护,这也是我后来坚持使用 cv::add 而不是手工写循环的重要原因。
1.3 明明自己几行循环就能算,为什么还要用 OpenCV 函数?
直接遍历像素看起来也不难:
cv::Mat dst = src.clone(); for (int r = 0; r < src.rows; r++) { for (int c = 0; c < src.cols; c++) { dst.at<uchar>(r, c) = cv::saturate_cast<uchar>( src1.at<uchar>(r, c) + src2.at<uchar>(r, c)); } }这段代码功能上没错,但至少有三个问题。第一,.at<uchar>()每访问一个像素都要做一次类型和边界检查,三层循环跑起来很慢;第二,没有考虑多通道图像,如果换成 CV_8UC3,要改成 Vec3b,并且每通道分别算;第三,你自己调用 saturate_cast 保证不会溢出,但稍不注意就会漏写这一层保护,结果就是环形溢出。而cv::add(src1, src2, dst)一行就能解决饱和、多通道、掩码、SIMD 加速这些问题。OpenCV 内部还会调用优化过的底层指令,甚至能利用 IPP 加速,性能明显优于手写循环。所以常规开发里能用封装函数就别自己造轮子,自己造轮子只适合教学演示或极端定制场景。
2. C++ 环境下四类基础算术 API 的实操拆解
看 API 文档很容易,但真正写代码时会遇到很多“文档里没写”的细节。这一章我把 add、subtract、multiply、divide 逐个拆开,重点不是官方签名,而是签名背后那些容易让人挠头的点。
2.1 加法:cv::add 的完整参数与亮度调整
直接看声明:
void cv::add(InputArray src1, InputArray src2, OutputArray dst, InputArray mask = cv::noArray(), int dtype = -1);src1 和 src2 是输入,可以都是 Mat,也可以一个是 Mat,另一个是 Scalar。当第二参数是 Scalar 时,OpenCV 会自动把标量扩展到所有通道,相当于给每个像素加一个固定值。这是最常用的提亮/减亮操作。比如我现在想给一张夜景图均匀加亮 30 个灰度级:
cv::Mat image = cv::imread("night_scene.jpg"); if (image.empty()) { return -1; } cv::Mat brighter; cv::add(image, cv::Scalar(30, 30, 30), brighter); cv::imwrite("brighter.jpg", brighter);注意这里 Scalar 的顺序是 BGR,所以写成 30,30,30 表示三个通道都加 30,图像会均匀变亮。如果只给蓝通道加 30,写成cv::Scalar(30, 0, 0),图像会明显偏蓝。dtype 参数默认 -1,表示输出类型与输入一致;当两个输入 Mat 类型不一致时,必须显式指定 dtype,否则操作会失败。比如一张 CV_8U 和一张 CV_32F 相加,最好写成cv::add(a, b, dst, cv::noArray(), CV_32F),否则结果可能被隐式截断成整数,精度损失非常可惜。
2.2 减法:cv::subtract 与 absdiff 的差别在哪里
减法声明和 add 很像,都是 src1 - src2。它最典型的应用是帧差法:视频中把当前帧和上一帧相减,非零区域就是变化区域。但这里有一个坑:无符号图像的减法,如果 src2 比 src1 大,负值会被 saturate 到 0,而不是得到负值。比如 50 - 200,结果是 0,不是 -150。如果你需要的是“任意方向的变化都要检测出来”,那就得用cv::absdiff(src1, src2, dst),它对两个方向都取绝对值,这样运动检测才能同时响应变亮和变暗。
cv::Mat prevGray, curGray; // 假设 prevGray、curGray 已由灰度帧转换得到 cv::Mat diff; cv::absdiff(curGray, prevGray, diff); cv::Mat binary; cv::threshold(diff, binary, 25, 255, cv::THRESH_BINARY);这段代码就是移动目标检测的常见预处理流程。absdiff 的结果一定是非负,阈值后就能得到前景区域。如果你真正想要的是普通减法,比如分离背景中的高光,那就用 subtract。还有一点要提醒:subtract 支持 in-place 操作,比如cv::subtract(src1, src2, src1)是可以的,OpenCV 内部会处理好缓冲问题。但在不确认文档支持 in-place 的情况下,我建议还是用一个临时 Mat 来接结果,避免某些边缘情况。
2.3 乘法除法:小心浮点精度与溢出
乘法cv::multiply的声明同样带 scale 参数:
void cv::multiply(InputArray src1, InputArray src2, OutputArray dst, double scale = 1.0, int dtype = -1);内部计算公式是dst(I) = saturate_cast<uchar>(scale * src1(I) * src2(I))。注意 scale 会在乘法之后、截断之前参与运算,所以如果你知道自己要乘的两个值可能超过 255,就传一个小于 1 的 scale,相当于把结果压低,防止饱和。实际操作中经常用乘法来调节对比度:对整张图乘一个大于 1 的系数会提亮,乘一个小于 1 的系数会压暗。但直接用cv::multiply(src, cv::Scalar(1.2), dst)时,很多原本 220 以上的像素会直接变成 255,高光细节完全丢失。更稳妥的做法是先把图转成浮点,等运算结束后再归一化回 8U。
除法cv::divide的公式是dst(I) = saturate_cast<uchar>(scale * src1(I) / src2(I))。它可以在图像处理中做比例计算,比如对不同光照下的同一场景求比值,从而估算反射率。然而整数除法会把小数部分直接丢到 0,结果可能非常离散。我建议如果要除就先src.convertTo(srcFloat, CV_32F)转成浮点,再用 divide,最后再转回 8U。另外除数为 0 的问题必须重视:两幅图中只要 src2 某个像素是 0,除法结果就会异常,虽然 OpenCV 内部通常不会崩溃,但会产生毫无意义的数值。稳妥的方法是对除数图先写一个 mask,或者对 src2 加一个极小 epsilon,再用 divide。
2.4 掩码 mask 和 dtype:一套函数覆盖全部场景
许多 OpenCV 算术函数都接受 mask 和 dtype,这两个参数看起来可有可无,但用好了能省不少事。mask 的作用是限定“只在某些像素上做运算,其他像素直接保持 dst 原值”。因此如果你想只处理图像中的某个区域,可以先画一个单通道 mask,感兴趣区域为白色,其余为黑色,然后调用:
cv::Mat dst = src.clone(); cv::add(src, cv::Scalar(50, 50, 50), dst, mask);这段代码只有 mask 非零区域变亮,其他区域保持原样。注意 mask 必须是 CV_8U,且大小与 src 相同,并且是单通道。这个特性在做局部增强、ROI 处理时非常有用。dtype 上面提到过,主要用于控制输出类型。如果两幅图类型不一致,或者你想得到浮点结果,就必须显式指定 dtype。实际开发中我见过不少同事直接忽略 dtype,在 CV_8U 与 CV_32F 相加时得到奇怪的结果,最后排查半天才发现是类型没有对齐。所以遇到类型不匹配的第一反应,不是硬转 Mat,而是先想清楚这个算术操作到底想输出什么类型结果。
到这里,四类基础算术操作的参数和作用都过了一遍。接下来看位运算——它和算术操作长得像,应用场景却完全是另一套思路。
3. 位运算:算术操作里的另一套工具
很多初学者容易忽略位运算,但它确实是图像算术操作的“近亲”。OpenCV 提供了 bitwise_and、bitwise_or、bitwise_xor、bitwise_not 四个函数,它们对像素的二进制位逐位操作。在处理掩码、抠图、图像合成时,位运算往往比 add、multiply 更可靠。
3.1 四个位运算函数的 C++ 接口与语义
接口和算术运算几乎一致:
void cv::bitwise_and(InputArray src1, InputArray src2, OutputArray dst, InputArray mask = cv::noArray()); void cv::bitwise_or(InputArray src1, InputArray src2, OutputArray dst, InputArray mask = cv::noArray()); void cv::bitwise_xor(InputArray src1, InputArray src2, OutputArray dst, InputArray mask = cv::noArray()); void cv::bitwise_not(InputArray src, OutputArray dst, InputArray mask = cv::noArray());它们对每个像素的每个通道做二进制运算。以常见的 8 位灰度图为例,像素值 0xFF 与 0x0F 做 bitwise_and 会得到 0x0F,相当于把高四位清零。用途之一是提取位平面,比如把一张图像的某一位抽取出来,观察图像中隐藏的信息。另一种常见用法是利用 bitwise_not 把一张黑白 mask 反转,原是白色的 ROI 变成黑色,背景变成白色,配合后续算术操作就能完成抠图合成。
3.2 做图像合成时,为什么位运算比加法更可靠
假设我现在想把小图 B 贴到大图 A 的某个区域,如果直接用A(roi) = A(roi) + B,两个图的像素数值累加,很容易超过 255,导致重叠区发白。更合理的流程是用 mask 先把目标区域清零,再把 B 和 mask 进行位与,最后把两幅图相加。这样做的好处是:每个像素的最终亮度只来自一幅图,而不是两幅图的叠加。
// rect 是目标区域,small 是要贴的小图,mask 是 small 对应的单通道掩码 cv::Mat canvas = bg.clone(); cv::Mat roi = canvas(cv::Rect(x, y, small.cols, small.rows)); cv::Mat invertedMask; cv::bitwise_not(mask, invertedMask); // 背景区域保留,目标区域先清空 cv::Mat bgOnly = cv::Mat::zeros(roi.size(), roi.type()); cv::bitwise_and(roi, roi, bgOnly, invertedMask); // 小图只保留 ROI 区域,其余为 0 cv::Mat fgOnly = cv::Mat::zeros(roi.size(), roi.type()); cv::bitwise_and(small, small, fgOnly, mask); cv::Mat merged; cv::add(bgOnly, fgOnly, merged); merged.copyTo(roi);这段代码是项目中裁剪贴图的通用套路。bitwise_and(roi, roi, bgOnly, invertedMask)的作用是把 roi 内背景区域保留,目标区域清零;bitwise_and(small, small, fgOnly, mask)则让小图只保留 ROI 区域。最后cv::add把两个互不重叠的图拼起来,因为每个位置最多只有一个非零来源,所以不会产生相加过曝。如果在这里写成简单加法,很可能出现亮部爆炸。需要强调的是,这里的 mask 必须是 CV_8UC1,并且最好是一张边缘干净的二值图,否则会出现半透明的边界叠加。
4. 完整实操案例:模拟项目X的光照补偿与图像融合
前面拆了 API,现在用一个综合案例把 add、subtract、multiply、divide 串起来。这个案例来自模拟项目X,需求是把两张同一场景但在不同曝光条件下拍摄的图像,融合成一张亮部不过曝、暗部不欠曝的图。直接做简单加权融合往往效果一般,因为暗部本来就暗、亮部本来就亮。思路是先对暗图做光照补偿,再用带权加法融合。
4.1 场景、数据准备与整体思路
假设现有两幅输入图:dark.jpg是欠曝图,整体很暗;bright.jpg是过曝图,高光区域接近白色。两幅图尺寸相同、通道数相同,都是 CV_8UC3。目标输出一张视觉效果均衡的图像。直接cv::addWeighted(dark, 0.5, bright, 0.5, 0, result)的思路是对的,但问题在于暗图中的细节依然很暗,融合后仍是暗的。所以需要先用cv::multiply对暗图增益,把中间调提上来,再用浮点运算保留高亮细节。
具体做法分三步:
- 把 dark 转为浮点,观察其平均亮度;
- 用
multiply给暗图乘一个增益系数,并做饱和控制; - 用
addWeighted将补偿后的暗图与亮图融合,权重视场景调整。
这套流程虽然简单,但每一步都需要明确参数,下面直接看核心代码。
4.2 核心代码实现与参数解释
cv::Mat dark = cv::imread("dark.jpg", cv::IMREAD_COLOR); cv::Mat bright = cv::imread("bright.jpg", cv::IMREAD_COLOR); if (dark.empty() || bright.empty()) return -1; // 转浮点,避免后续乘除出现截断 cv::Mat darkF, brightF; dark.convertTo(darkF, CV_32FC3); bright.convertTo(brightF, CV_32FC3); // 计算暗图平均亮度,用于决定增益系数 cv::Scalar meanVal = cv::mean(dark); double grayMean = (meanVal[0] + meanVal[1] + meanVal[2]) / 3.0; double gain = 1.6; // 根据灰度均值调整,通常选 1.2~2.0 // 对暗图做增益补偿 cv::Mat compF; cv::multiply(darkF, cv::Scalar(gain), compF, 1.0, CV_32FC3); // 为避免补偿后暗部噪声被放大,做一个轻微的降噪 cv::GaussianBlur(compF, compF, cv::Size(3, 3), 0); // 融合权重:暗图 0.55,亮图 0.45,偏移 0 cv::Mat blendF; cv::addWeighted(compF, 0.55, brightF, 0.45, 0, blendF, CV_32FC3); // 转回 8U,越界值会被安全截断 cv::Mat result; blendF.convertTo(result, CV_8UC3, 1.0, 0); cv::imwrite("fused.jpg", result);有几个参数值得解释。gain 不是越大越好,因为乘到 2.0 会把暗部的噪声一起放大,后面就不得不加去噪。过大时高光区域也会先饱和,即使转到浮点后能保存大于 255 的值,最后 convertTo 时仍会被截断。我通常先cv::minMaxIdx看暗图的像素分布,再选增益。addWeighted 的权重本质是两张图按比例相加,alpha 与 beta 不需要相加等于 1,但等于 1 会让整体亮度大致不变;不等时相当于同时做了亮度偏移。代码中 convertTo 最后一个参数是 gamma,这里用 0,也就是不做额外偏移。
4.3 效果验证与数据检查
结果好不好不能只凭眼睛看,至少要做两类检查:像素范围检查和区域亮度统计。对 float 类型的 blendF 可以用minMaxIdx查看是否存在极端值。如果存在很多超过 255 的点,说明补偿增益过高;如果整体方差很小,说明对比度不足。统计代码如下:
cv::Mat hsv; cv::cvtColor(result, hsv, cv::COLOR_BGR2HSV); std::vector<cv::Mat> channels; cv::split(hsv, channels); double vMin, vMax; cv::minMaxIdx(channels[2], &vMin, &vMax); std::cout << "V channel range: [" << vMin << ", " << vMax << "]" << std::endl;V 通道代表亮度,范围应该接近 0 到 255,并且不能有大量像素堆在 255。此外还可以把结果与两幅原图做cv::norm看差异,但这里只需要确认融合后高光不过曝、暗部有细节即可。我在实际项目里还会把补偿后的 compF 单独存一张,观察增益是否把暗部噪声放大,如果放大了,就先高斯模糊,或者改用分块局部增益。这个流程看起来不长,但每一步的取舍都会直接影响最终效果。
5. 常见问题与排查技巧实录
最后整理几个我在实践中反复遇到的坑,每个都可以独立成“避坑指南”。如果按顺序排查,通常能省下不少调试时间。
5.1 Mat 类型不统一导致的异常
最典型的现象是程序运行到 add/subtract 时报错:Sizes of input arguments do not match或类型不符合。原因是两个输入 Mat 的尺寸、通道数或深度不一致。比如你从摄像头读的是 CV_8UC3,从磁盘读的灰度图是 CV_8UC1,直接相加肯定失败。解决思路非常统一:先检查再转换。
std::cout << src1.size() << " " << src1.type() << " " << src1.channels() << std::endl; std::cout << src2.size() << " " << src2.type() << " " << src2.channels() << std::endl;如果尺寸不同,用cv::resize统一;如果通道数不同,用cvtColor或Mat::convertTo统一;如果深度不同,就用 convertTo 转成同一深度。不要试图让函数自动做隐式转换,OpenCV 在这方面的策略是“宁可报错,也不瞎算”。我现在写代码时会习惯性地在算术操作前加一行CV_Assert(src1.size() == src2.size() && src1.type() == src2.type()),虽然啰嗦,但能很快暴露上下游问题。
5.2 结果全黑或全白的排查顺序
图像全白,一般是结果饱和,即很多像素都到了 255。需要区分是语义上正确(两张高光图相加本来就该白)还是参数不对(权重过大、增益过高)。全黑则大多是数据方向反了,或者被 mask 屏蔽了。一个非常好用的排查手段是把中间结果逐个imshow出来,尤其是 mask 和补偿图。比如用 add 提亮后结果全黑,先单独查看原图本身是不是黑的;如果原图正常,再用minMaxIdx查看输出通道的最小最大像素值。若最小最大值都是 0,说明 mask 参与后没有匹配,或者减法顺序写反了。若结果是局部黑块,则要考虑是否因为使用了saturate_cast把所有负数截成 0。这种问题不复杂,但特别容易让人忽略,所以我习惯把步骤拆得足够细。
5.3 乘法噪声、除法除零的预防
乘法后出现噪声点,通常是把 8 位图像用 multiply 乘了一个大于 1 的标量,导致原本 220 以上的像素直接全部变成 255,原本 0 附近的噪声却被放大。我的习惯是:只要涉及乘法,就先把 8 位图转成 32F,用浮点乘法,最后再归一化或转换回 8U,这样既能保留动态范围,也给后续处理留有余地。除法除零方面,虽然 OpenCV 的 divide 一般不会让程序崩溃,但结果会变成无效值,后续用于统计时就会污染数据。处理方案很简单,对除数图先调用cv::threshold(src2, mask, 0, 255, cv::THRESH_BINARY)得到非零区域,然后只对 mask 区域做除法,或者在除法后把 mask 外的区域清零。
5.4 性能优化与 in-place 操作的一点经验
算术操作在视频处理里会被高频调用,性能优化很关键。第一,尽量使用 OpenCV 内置函数而不是手写双循环,理由前面已经说过。第二,能 in-place 就 in-place,比如cv::add(frame, cv::Scalar(10, 10, 10), frame)会节省一块目标内存的分配和拷贝。但注意,并不是所有函数都安全支持 in-place,常见算术函数大多没问题,可一旦遇到异常,最稳妥的办法是先存临时 Mat,再frame = tmp.clone(),不要强行原地操作。第三,如果处理的是视频流,可以把多个算术步骤合并成一次convertTo或 LUT 查表操作,减少图像内存访问次数。OpenCV 的cv::setUseOptimized(true)默认就是开启的,不用刻意去管。
汇总一下日常最容易踩的坑,可以做成一个速查表:
| 异常现象 | 可能原因 | 快速定位方法 |
|---|---|---|
| 报错 size/type 不匹配 | 输入 Mat 尺寸、通道或深度不一致 | 打印 src1.size() 和 src1.type() |
| 结果全白 | 多个大于 255 的值饱和,权重或增益过大 | 查看中间结果的 min/max |
| 结果全黑 | 减法方向反了,或 mask 未命中 | 单独显示 mask 和差分图 |
| 局部出现噪点 | 8 位乘法放大暗部噪声 | 转浮点后再乘,或增加去噪 |
| 除法后数据异常 | src2 出现 0 像素 | 构造非零 mask 或加 epsilon |
写到这,图像算术操作的基本点差不多都覆盖了。我个人最大的体会是:所有奇怪结果的根源基本都逃不开“类型”和“饱和”这两个词。现在每次写完一步,我都会习惯性地用 minMaxIdx 扫一眼结果,看到溢出区域就赶紧调整权重或转浮点,而不是等最后合成完再找原因。如果你也在 C++ 环境里踩到 add/subtract 出来的怪图,先别急着怀疑 OpenCV,返回去检查输入 Mat 的类型和数值范围,大概率能找到答案。