☰
OpenCV图像算数与位运算:像素饱和、掩膜提取与ROI实战
2026/10/17 15:23:52 网站建设 项目流程

图像中的像素点说白了就是一堆 0 到 255 的数字,所以图像本质上就是一张数字矩阵。上一篇文章我们聊了图像的基础操作,这次讲的算数与位运算,就是把两张图(或者图和标量)按像素做数学运算,实现加亮、减暗、融合、遮罩提取这些效果。很多刚接触 OpenCV 的朋友会把cv2.add和numpy的+搞混,或者不知道bitwise_and配合mask到底怎么用,这篇文章就围绕这两块展开,把原理、代码、坑点一次说清楚。

1. 图像的“加法”和普通数学加法不是一回事:饱和运算 vs 取模运算

1.1 先搞懂cv2.add和numpy加法在行为上的本质区别

图像加法最常见的需求是提高亮度。很多初学者上来就是img + 50,结果发现图片出现一堆诡异的雪花点或者大块白色,这就是因为不懂溢出处理机制。

OpenCV 提供了cv2.add和cv2.addWeighted,而numpy也支持直接用+操作符。两者的核心区别在于:

  • cv2.add采用饱和运算(saturate),像素值相加超过 255 就直接截断为 255,低于 0 则截断为 0。
  • numpy的+采用取模运算(modulo),比如 200 + 100 = 300,超出部分会被% 256处理,变成 44。

取模运算会导致一个本来是 250 的高亮区域,加上一个稍微亮的像素之后反而变成了很暗的数值,这在图像上就是一块块不连续的噪点,完全破坏了画面的渐变关系。比如一张天空从亮到暗的渐变图,亮部一加就变黑,这显然是错误的结果。

看个直观的例子:

import cv2 import numpy as np # 构造一个简单的灰度图 a = np.array([[200, 150], [100, 50]], dtype=np.uint8) b = np.array([[100, 100], [100, 100]], dtype=np.uint8) add_cv = cv2.add(a, b) add_np = a + b print("cv2.add 结果:\n", add_cv) # [[255 250] # [200 150]] print("numpy + 结果:\n", add_np) # [[44 250] # [200 150]]

注意看左上角200 + 100,cv2.add得到 255,而numpy得到 44。在真实图像上这个差异就是“画面对比度被破坏”和“正常变亮”的区别。

1.2cv2.subtract、cv2.multiply、cv2.divide的同类逻辑

减法和加法同理。cv2.subtract(src1, src2)会把小于 0 的结果截断为 0,而numpy的-会取模,比如 50 - 100 = -50,再% 256变成 206,于是本应该变暗的区域反而变成灰色甚至高亮。

乘除运算也有自己的用途。cv2.multiply常用于图像整体增益,本质是对比度和亮度调整;cv2.divide则可以做归一化、消除光照不均等操作。值得注意的是,cv2.divide有scale参数,比如cv2.divide(src1, src2, scale=255),它计算的是src1 * scale / src2,在白色背景的文档图像校正中非常好用。

下面用一个表格把各个运算在溢出时的手段总结清楚,方便以后排查问题:

运算OpenCV函数溢出处理numpy操作符溢出处理
加法cv2.add饱和截断+取模回绕
减法cv2.subtract饱和截断-取模回绕
乘法cv2.multiply饱和截断*取模回绕
除法cv2.divide饱和截断/浮点或报错

提示:实际做图像处理时,凡是要保持像素值物理意义(亮度、透明度)的运算,一律使用 OpenCV 的饱和函数。如果用了numpy的操作符,除非你刻意要那种 8-bit 回绕的艺术效果,否则就是引入 bug。

1.3 为什么要尽量用整型图而不是浮点图做算数运算

很多人在菜鸟阶段喜欢把图片转成float32再算,觉得这样不会溢出,操作自由。这思路本身没问题,但需要注意转化后的数值范围。

cv2.imread读进来的图默认是uint8,范围 0~255。如果你np.float32(img)之后直接除以 255,变成 0~1 的范围,那就必须配套使用 OpenCV 里那些专门处理 0~1 浮点图的逻辑,例如cv2.normalize、cv2.convertScaleAbs之类的。更常见的一个坑是看一眼图片全黑,因为浮点数值在imshow中不会自动做范围归一化,0~1 的浮点数据几乎全被显示成黑色。

如果你确实要用浮点计算做中间过程,我建议这样操作:先转float32,按 0~255 范围直接做运算,算完之后再用np.clip(result, 0, 255).astype(np.uint8)转回。这样既能避免溢出,又不破坏显示范围,实测下来是最稳的一套流程。

2. 加权融合:addWeighted的透明度控制与实用姿势

2.1 线性加权公式到底在算什么

cv2.addWeighted(src1, alpha, src2, beta, gamma)的公式是:

dst = src1 * alpha + src2 * beta + gamma

这里alpha是第一张图的权重,beta是第二张图的权重,gamma是一个常数偏置,通常设为 0。

为什么需要权重?因为两张图直接相加会很快饱和,比如两张亮度正常的图各加一遍,很大区域会直接变白。加权融合相当于给每张图先打折再加,总和通常控制在 1 左右,这样结果图亮度接近原图,只是内容上两张图各自“透出”了一部分。

我举个例子:你要在主画面右上角叠加一个半透明的 Logo。主图权重 0.85,Logo 权重 0.15,两者加起来 1.0,背景整体没有变亮太多,但 Logo 内容又能透过主图显现出来。

import cv2 bg = cv2.imread("background.jpg") # 主图 logo = cv2.imread("logo.png") # 叠加图 # 注意两张图尺寸要一致,不一致先 resize logo = cv2.resize(logo, (bg.shape[1], bg.shape[0])) blended = cv2.addWeighted(bg, 0.85, logo, 0.15, 0) cv2.imshow("blended", blended)

2.2 透明度渐变:用循环权重做“淡入淡出”

addWeighted的 alpha 和 beta 不要求每次固定,我们可以在视频帧处理或者幻灯片过渡里让 alpha 从 1 逐渐降到 0,效果就是第一张图渐隐、第二张图渐显。

import cv2 import numpy as np img1 = cv2.imread("pic1.jpg") img2 = cv2.imread("pic2.jpg") for i in range(11): alpha = i / 10.0 beta = 1 - alpha merged = cv2.addWeighted(img1, alpha, img2, beta, 0) cv2.imshow("transition", merged) cv2.waitKey(100)

这个循环的巧妙之处在于 alpha 和 beta 始终满足alpha + beta = 1,所以在整个转场过程中画面亮度不会突然闪烁,视觉上非常平滑。做视频转场、相册幻灯片,这个套路基本是标配。

如果你觉得线性渐变不够有感觉,可以换非线性的权重曲线,比如alpha = i*i / 100,画面后段变化更明显,更像一种“先慢后快”的节奏,实测用在片头很有质感。

2.3 和numpy手动加权对比:效率差距在哪里

addWeighted本质上就是src1 * alpha + src2 * beta + gamma,你当然可以用numpy写:

merged_np = (img1.astype(np.float32) * 0.85 + img2.astype(np.float32) * 0.15).astype(np.uint8)

效果几乎一样,但两个细节需要留意:

  • numpy版本需要先转float32,否则uint8乘以浮点会报错或丢失精度。
  • OpenCV 底层对图像 SIMD 做了优化,addWeighted在实时视频流中通常比 Python 层numpy运算快一点;大图循环使用cv2自带函数体感更明显。

所以我的建议:能直接调 OpenCV 的函数就不要自己拼numpy写,代码短、性能稳、不容易溢出,这三个优势足够让你在日常项目中少踩很多坑。

3. 位运算:掩膜(Mask)操作的核心武器

3.1 按位与、或、异或、非,到底是如何作用在图像上的

位运算不是把图片当普通矩阵做加减,而是把每个像素的二进制数拿来逐位运算。图像位运算中最重要的三个函数是cv2.bitwise_and、cv2.bitwise_or、cv2.bitwise_xor和cv2.bitwise_not。

对两张二值图(只有 0 和 255)做位运算时逻辑非常直观:

  • 按位与&:两者都为 255 的地方才是 255,否则为 0。
  • 按位或|:两者有一个为 255 就是 255。
  • 按位异或^:两者不同则为 255,相同则为 0。
  • 按位非~:0 变 255,255 变 0。

用表格呈现更清楚:

运算A=0, B=0A=0, B=255A=255, B=0A=255, B=255
与000255
或0255255255
异或02552550

3.2 用bitwise_and配合 mask 提取指定区域

位运算最核心的应用是 mask(掩膜)。掩膜本质就是一张单通道的 8-bit 图像,像素值为 0 或 255。当cv2.bitwise_and(src1, src2, mask=mask)时,OpenCV 会先判断 mask 对应位置是否为 255,如果是 255 才执行与运算,否则结果直接为 0。

最常见的形态是cv2.bitwise_and(img, img, mask=mask),也就是让img和自身做与运算,mask 外部全变成 0,mask 内部维持原像素值。这就实现了一个非常干净的 ROI(感兴趣区域)提取。

import cv2 import numpy as np img = cv2.imread("face.jpg") mask = np.zeros(img.shape[:2], dtype=np.uint8) # 在 mask 上画一个白色实心圆 cv2.circle(mask, (160, 160), 100, 255, -1) # 保留圆形区域,其余置黑 result = cv2.bitwise_and(img, img, mask=mask) cv2.imshow("original", img) cv2.imshow("mask", mask) cv2.imshow("result", result)

这里有一个新手经常问的点:为什么不是cv2.bitwise_and(img, mask)?因为mask是单通道,而img是 BGR 三通道,直接按位与会报错或者结果不对。正确做法是让img和自己做与运算,再用mask当参数限制参与计算的像素范围,这个套路务必记住。

3.3bitwise_not翻转 mask 后配合add做图像合成

拿到了前景区域,我们往往需要把它贴回另一张图上。经典 Logo 合成流程是这样的:

  1. 用阈值把 Logo 转成二值 mask。
  2. 用cv2.bitwise_not翻转 mask,得到背景区域。
  3. 用原图bitwise_andmask,得到 Logo 前景。
  4. 用原图bitwise_and翻转 mask,得到目标图背景区域。
  5. cv2.add两者,完成合成。

举个实际例子,把一张白色背景的 Logo 贴到照片左上角:

import cv2 import numpy as np img = cv2.imread("background.jpg") logo = cv2.imread("logo_white_bg.jpg") rows, cols = logo.shape[:2] roi = img[0:rows, 0:cols] # 取左上角区域 # 灰度化 + 阈值拿到 logo 的二值 mask logo_gray = cv2.cvtColor(logo, cv2.COLOR_BGR2GRAY) _, mask = cv2.threshold(logo_gray, 10, 255, cv2.THRESH_BINARY) mask_inv = cv2.bitwise_not(mask) # 背景区域清空(把 ROI 中对应 logo 位置的像素置黑) img_bg = cv2.bitwise_and(roi, roi, mask=mask_inv) # 取出 logo 前景 logo_fg = cv2.bitwise_and(logo, logo, mask=mask) # 相加合成 dst = cv2.add(img_bg, logo_fg) img[0:rows, 0:cols] = dst

这段代码几乎是所有图像拼接、贴图、水印合成的模板,实战中多到数不清。注意roi修改后要赋值回原图对应坐标区域,否则改了等于白改。

4. 实战组合拳:ROI 提取、动态遮罩与区域清除

4.1 多区域 ROI 同时提取并生成拼接预览

在工业检测、人脸裁剪这类场景里,经常要一次性提取多个圆形或矩形 ROI 并拼成一张预览图。我们可以先用zeros生成全黑 mask,再用cv2.rectangle或cv2.circle在 mask 上画多个白色区域,最后一次性做bitwise_and。

import cv2 import numpy as np image = cv2.imread("board.jpg") mask = np.zeros(image.shape[:2], dtype=np.uint8) # 画两个矩形 ROI 和一个圆形 ROI cv2.rectangle(mask, (50, 50), (250, 250), 255, -1) cv2.rectangle(mask, (300, 300), (500, 400), 255, -1) cv2.circle(mask, (600, 300), 100, 255, -1) extracted = cv2.bitwise_and(image, image, mask=mask) # 如果要单独抠出每个 ROI,可以循环遍历轮廓 contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) for i, cnt in enumerate(contours): x, y, w, h = cv2.boundingRect(cnt) crop = image[y:y+h, x:x+w] cv2.imwrite(f"roi_{i}.jpg", crop)

这套组合的妙处在于:mask 可以先离线生成好,甚至用深度学习分割结果直接填充,后续提取 ROI 就是一行bitwise_and的事,速度和内存都很友好。

4.2 动态 mask:按像素阈值自适应生成遮罩

mask 不总是预先画好的,很多时候要按图像内容动态生成。比如把画面中的亮部区域单独提出来:

import cv2 import numpy as np img = cv2.imread("scene.jpg") gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) # 亮度大于 200 的作为亮部 mask _, mask_bright = cv2.threshold(gray, 200, 255, cv2.THRESH_BINARY) # 亮度小于 50 的作为暗部 mask _, mask_dark = cv2.threshold(gray, 50, 255, cv2.THRESH_BINARY_INV) bright_part = cv2.bitwise_and(img, img, mask=mask_bright) dark_part = cv2.bitwise_and(img, img, mask=mask_dark)

这里的关键是THRESH_BINARY_INV配合THRESH_BINARY一起用,可以同时拿到“高于阈值”和“低于阈值”的两组区域。这两个 mask 在后续做亮度调整、对比度拉伸时非常方便。

动态 mask 的好处是不需要人工框选目标,只要阈值逻辑合理,算法就能自适应处理一整批图片。比如一堆白底文档照片,反过来用 mask 把白底去掉,再叠加到新的背景上,这就是非常实用的自动抠白底方案。

4.3 区域清除:把照片里某个物体“抹掉”

位运算的另一个常见用法是把图像中某个区域直接置为纯黑或纯白,达到“抹除”效果。例如有一张带时间戳水印的截图,你知道水印位于固定坐标区间,就可以快速生成一个黑色矩形覆盖它。

import cv2 import numpy as np img = cv2.imread("screenshot.png") h, w = img.shape[:2] mask = np.ones((h, w), dtype=np.uint8) * 255 # 水印区域设为 0 cv2.rectangle(mask, (w - 260, h - 60), (w - 10, h - 10), 0, -1) result = cv2.bitwise_and(img, img, mask=mask)

注意这里我把 mask 初始化为全白(255),再把目标区域画成黑色 0,而不是像前面那样初始化为全黑。因为我们的目的是“保留大部分区域”,所以 mask 大部分位置要为 255,只有不要的区域才设为 0。这个初始值的正反逻辑是新手最容易搞混的地方,我见过太多人把 mask 初始化为 0,结果一bitwise_and整张图全黑,然后在那排查半天不知道问题出在哪。

4.4 位运算去除文档背景和反色叠加

实际办公场景里有一个非常典型的需求:扫描件是白底黑字,但背景有阴影或偏色。我们可以用位运算加阈值把它变成干净的黑底白字或白底黑字。

假设你有一张带灰底的扫描图scan.jpg:

import cv2 import numpy as np img = cv2.imread("scan.jpg", cv2.IMREAD_GRAYSCALE) # 二值化:亮部变白,暗部变黑 _, binary = cv2.threshold(img, 150, 255, cv2.THRESH_BINARY) # 白色文字 = 反色后的黑色区域 text_mask = cv2.bitwise_not(binary)

这里THRESH_BINARY将灰度图变成了纯粹的黑白二值图,bitwise_not又把黑白互换。通过这样的组合,我们可以自由控制前景色和背景色。如果背景光照不均,直接设置固定阈值 150 效果会差,可以先做cv2.adaptiveThreshold自适应二值化,再用位运算提取文字层,效果稳定得多,这是我在实际文档预处理中反复验证过的经验。

5. 边界、性能与调试:这批操作的真实“坑”

5.1 尺寸不一致和通道数不匹配

cv2.add、cv2.addWeighted、cv2.bitwise_and都要求两张输入图的尺寸、通道数完全一致,否则直接报错:

Sizes of input arguments do not match

我经常在调试时遇到这类问题,根源通常是用了cv2.imread读不同来源的图片,一张是1920x1080,另一张是800x600。解决办法很简单:统一改成目标尺寸再做运算。

img2 = cv2.resize(img2, (img1.shape[1], img1.shape[0]))

通道数不一致也很隐蔽。灰度图是单通道,彩色图是三通道,不能直接做cv2.add。需要把灰度图转成三通道:

gray_bgr = cv2.cvtColor(gray, cv2.COLOR_GRAY2BGR)

或者反过来用cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)统一成灰度。如果你确定只做 mask 操作,记住 mask 永远是单通道 8-bit 图像,这一点比彩色图要求严格得多。

5.2 数据类型和数值范围:黑图、白图的罪魁祸首

很多人做完运算之后imshow,结果发现图是全黑的或者全白的,第一反应是算法错了,实际上往往是数据类型和数值范围的问题。

  • 如果你用numpy做浮点乘法,结果还是float64,imshow对浮点图期望范围是 0~1,你给 0~255 当然全白。
  • 如果你把uint8图和float图混算,结果里全是 0,显示出来就全黑。

我建议在做每一步运算前用print(img.dtype, img.shape, img.min(), img.max())快速检查一下。这个习惯看起来繁琐,但能帮你节省大量的排查时间,尤其是当你把多张图串成一条流水线处理时,中间任何一步出错都会让你怀疑人生。

5.3 大图的性能注意事项

图像位运算和算数运算都是逐像素操作,对 1080P 甚至更高分辨率的大图跑起来会有明显耗时。实测下来,Python 环境下cv2自带函数处理 1080P 图像的单次位运算大约在 10 毫秒以内,而如果用numpy循环遍历像素,比如for i in range(h): for j in range(w),性能会慢到令人发指,一张图能跑十几秒。

另外一个容易被忽略的性能点是:mask 操作时尽量提前把 mask 生成好,不要在每个循环里重新画圆、重新填轮廓。如果是对视频流逐帧处理,建议预先创建好所需尺寸的 mask 数组,每帧只替换坐标和做bitwise_and,别在循环内部反复调用np.zeros和cv2.circle,那些构造开销在长时间运行中会不断累积。

5.4 边缘锯齿和过渡带处理

位运算生成的 mask 边缘往往是硬边界,提取出来的区域边缘会有明显的锯齿感,尤其是在圆形、不规则物体上。如果你追求更柔和的边缘,可以用cv2.GaussianBlur先对 mask 做模糊,或者用cv2.erode、cv2.dilate做轻微膨胀腐蚀,让 mask 的边缘平滑一些。

mask_blur = cv2.GaussianBlur(mask, (5, 5), 0) result = cv2.bitwise_and(img, img, mask=mask_blur)

注意 mask 模糊之后,边缘像素值不再是纯 0 和 255,而是中间灰度。此时bitwise_and的结果会在边缘处产生半透明的过渡效果,虽然牺牲了硬边界的精确性,但视觉上自然很多,适合用在抠图合成、海报制作这类偏向美感的场景。

5.5 分享一个我常用的调试技巧

当你调试一套多步骤的算数/位运算流水线时,我习惯于把每一步的中间结果都保存下来或者用imshow单独显示,这种“过程可视化”比读代码排查效率高得多。

具体做法是:

  1. 先单独跑cv2.imshow("mask", mask)确认 mask 形状和黑白分布符合预期。
  2. 再对每个步骤的中间图做 dtype 和数值范围检查。
  3. 最后再上完整流水线。

有一次我做动态 mask 抠亮部,怎么调结果都不对,后来单独显示 mask 才发现,因为threshold的阈值设得太低,mask 几乎全白,等于没抠。单独看 mask 一眼就能发现问题,比盯着代码看半小时管用多了。

从最开始的cv2.add和numpy加法差异,到addWeighted的透明融合,再到bitwise_and配合 mask 的 ROI 提取,你会发现这些运算本质上都是在操作一张二值化的“开关地图”。记住饱和运算和取模运算的区别,记住 mask 永远是单通道 8-bit 图,记住每个函数都要保证尺寸、类型一致,这三点吃透了,图像的算数与位运算基本就不会再翻车。后面如果有机会,我再写一篇实际项目里的综合案例,把这些操作串成一个完整的图像处理流水线。

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

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

立即咨询