首页 / 资讯中心 / 文章详情

OpenCV图像算数与位运算:像素饱和、掩膜提取与ROI实战

OpenCV图像算数与位运算:像素饱和、掩膜提取与ROI实战 ★ FEATURED ARTICLE
图像中的像素点说白了就是一堆 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]], dtypenp.uint8) b np.array([[100, 100], [100, 100]], dtypenp.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 100cv2.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, scale255)它计算的是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.85Logo 权重 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 变 255255 变 0。用表格呈现更清楚运算A0, B0A0, B255A255, B0A255, B255与000255或0255255255异或025525503.2 用bitwise_and配合 mask 提取指定区域位运算最核心的应用是 mask掩膜。掩膜本质就是一张单通道的 8-bit 图像像素值为 0 或 255。当cv2.bitwise_and(src1, src2, maskmask)时OpenCV 会先判断 mask 对应位置是否为 255如果是 255 才执行与运算否则结果直接为 0。最常见的形态是cv2.bitwise_and(img, img, maskmask)也就是让img和自身做与运算mask 外部全变成 0mask 内部维持原像素值。这就实现了一个非常干净的 ROI感兴趣区域提取。import cv2 import numpy as np img cv2.imread(face.jpg) mask np.zeros(img.shape[:2], dtypenp.uint8) # 在 mask 上画一个白色实心圆 cv2.circle(mask, (160, 160), 100, 255, -1) # 保留圆形区域其余置黑 result cv2.bitwise_and(img, img, maskmask) 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 合成流程是这样的用阈值把 Logo 转成二值 mask。用cv2.bitwise_not翻转 mask得到背景区域。用原图bitwise_andmask得到 Logo 前景。用原图bitwise_and翻转 mask得到目标图背景区域。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, maskmask_inv) # 取出 logo 前景 logo_fg cv2.bitwise_and(logo, logo, maskmask) # 相加合成 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], dtypenp.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, maskmask) # 如果要单独抠出每个 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:yh, x:xw] cv2.imwrite(froi_{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, maskmask_bright) dark_part cv2.bitwise_and(img, img, maskmask_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), dtypenp.uint8) * 255 # 水印区域设为 0 cv2.rectangle(mask, (w - 260, h - 60), (w - 10, h - 10), 0, -1) result cv2.bitwise_and(img, img, maskmask)注意这里我把 mask 初始化为全白255再把目标区域画成黑色 0而不是像前面那样初始化为全黑。因为我们的目的是“保留大部分区域”所以 mask 大部分位置要为 255只有不要的区域才设为 0。这个初始值的正反逻辑是新手最容易搞混的地方我见过太多人把 mask 初始化为 0结果一bitwise_and整张图全黑然后在那排查半天不知道问题出在哪。4.4 位运算去除文档背景和反色叠加实际办公场景里有一个非常典型的需求扫描件是白底黑字但背景有阴影或偏色。我们可以用位运算加阈值把它变成干净的黑底白字或白底黑字。假设你有一张带灰底的扫描图scan.jpgimport 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做浮点乘法结果还是float64imshow对浮点图期望范围是 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, maskmask_blur)注意 mask 模糊之后边缘像素值不再是纯 0 和 255而是中间灰度。此时bitwise_and的结果会在边缘处产生半透明的过渡效果虽然牺牲了硬边界的精确性但视觉上自然很多适合用在抠图合成、海报制作这类偏向美感的场景。5.5 分享一个我常用的调试技巧当你调试一套多步骤的算数/位运算流水线时我习惯于把每一步的中间结果都保存下来或者用imshow单独显示这种“过程可视化”比读代码排查效率高得多。具体做法是先单独跑cv2.imshow(mask, mask)确认 mask 形状和黑白分布符合预期。再对每个步骤的中间图做 dtype 和数值范围检查。最后再上完整流水线。有一次我做动态 mask 抠亮部怎么调结果都不对后来单独显示 mask 才发现因为threshold的阈值设得太低mask 几乎全白等于没抠。单独看 mask 一眼就能发现问题比盯着代码看半小时管用多了。从最开始的cv2.add和numpy加法差异到addWeighted的透明融合再到bitwise_and配合 mask 的 ROI 提取你会发现这些运算本质上都是在操作一张二值化的“开关地图”。记住饱和运算和取模运算的区别记住 mask 永远是单通道 8-bit 图记住每个函数都要保证尺寸、类型一致这三点吃透了图像的算数与位运算基本就不会再翻车。后面如果有机会我再写一篇实际项目里的综合案例把这些操作串成一个完整的图像处理流水线。
阅读完成 · 觉得有帮助?
咨询建站