ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

OpenCV核心语法:ROI裁剪、通道操作与mask遮罩全解析

OpenCV核心语法:ROI裁剪、通道操作与mask遮罩全解析

很多同学学到 OpenCV 语法这一块,最容易出现的情况是:函数都认识,CV 视频也刷了不少,但一上手处理自己手里那张图就懵。尤其是到了系列第四课这个位置,前面已经讲完了 imread、imshow、Mat 结构、像素访问、颜色空间、阈值这些基本功,接下来再往后走,碰到的就不再是“单个函数怎么用”的问题,而是“几个函数怎么组合起来解决一个真实需求”。这篇保姆级教学,就是把语法(四)这部分最核心的几个 API 讲透,并且全部围绕实际场景展开,让你看完能直接用在自己的图片处理任务里。

如果你正准备入门计算机视觉,或者正在一条条啃 OpenCV 文档但总觉得知识是散的,那这篇文章非常适合你。今天要重点拆解的语法包括:图像属性与 ROI 区域裁剪、通道拆分与合并、图像算术运算、逻辑运算与 mask 遮罩。这几块单独看都不难,但它们组合起来,就是图像融合、Logo 贴图、感兴趣区域提取、图像增强、数据预处理这些常见任务的基本骨架。

1. 从前面三课到语法(四),我们在走一条什么路线

1.1 前面已经解决了什么问题

回顾一下整个系列第一课的推进路径:第一课从读图开始,重点搞清楚了 imread 读进来的图到底是什么——一个多维数组;第二课开始对 Mat 结构做深入操作,包括像素级的访问和修改,这时候你已经能通过 img[100, 200] 这样拿到某个坐标的 BGR 值;第三课进入了图像处理的正式领地,色彩空间转换、阈值二值化、简单的滤波去噪,都是这时候的基础内容。

这一路下来,你对“图像=数组”这个概念应该已经建立起来了。但细心的人会发现,前面做的所有操作几乎都是针对“整张图”的——要么处理全图,要么逐像素扫。而真实项目里,你关心的往往只是画面里的某个区域,比如人脸检测框出来的那块、车辆车牌那块、或者视觉定位里被标记出来的目标区域。这就引出了语法(四)要解决的第一件事:ROI(Region of Interest,感兴趣区域)。

1.2 语法(四)在整个系列里的位置

为什么这一课称为“语法”而不是“图像处理”?因为今天我们覆盖的内容本质上都是在讲“OpenCV 这个语言里,图像数据可以怎样组合、怎样提取、怎样运算”。就像学一门编程语言要学它的运算符和数据结构一样,OpenCV 的图像数据也有自己的“运算符”:切片裁剪是区域提取,split/merge 是通道级别的拆装,add/addWeighted 是像素级别的数值组合,bitwise 系列是按位的逻辑组合。

这些语法学完,你再看后续系列的边缘检测、轮廓查找、特征匹配,会发现它们很多底层处理步骤都在反复使用今天这些操作的组合。直白点说:今天这篇的内容,是后面所有实战课的“数据搬运工”。你之后写的每一段图像处理代码,基本都离不开区域裁剪、通道操作、mask 遮罩这三板斧。

1.3 今天几组 API 的记忆地图

先给一张语法地图,后面的内容全按这个来展开,建议先有个整体印象:

API 分组代表性函数一句话用途
图像属性shape、dtype、size搞清楚图的尺寸、通道数、数据类型
ROI 区域img[y1:y2, x1:x2]裁剪画面局部区域
通道操作cv2.split、cv2.merge把 BGR 三通道拆开或重新组装
算术运算cv2.add、cv2.subtract、cv2.addWeighted对像素值做满减和加权融合
逻辑运算cv2.bitwise_and、or、xor、not做遮罩提取、区域保留

这五组东西,单个看都简单得不行,但组合起来威力很大。比如你要做一个“把某人脸区域调亮”的功能,步骤就是:先裁剪 ROI,再转成浮点做算术增强,最后用 mask 把增强结果贴回去。每一步都是今天要练的语法点。

2. 先看清一张图的“身份证”:shape、dtype 与 size

2.1 shape 的三个数字分别代表什么

拿到一张图,第一件事就是看它的 shape。很多人一开始会把 shape 记颠倒,这里我用最笨的办法帮助记忆:shape 返回的永远是(行数,列数,通道数),而行数对应的就是图像的高度,列数对应的就是宽度。

import cv2 img = cv2.imread("coffee.jpg") print(img.shape)

输出示例:(480, 640, 3)

意思就是:高 480 像素,宽 640 像素,3 个通道(Blue、Green、Red,OpenCV 默认 BGR 顺序)。千万不要把 shape[0] 当成宽,这是新手最容易踩的坑。后面做 ROI 裁剪时,一旦把宽高搞反,截出来的区域就完全不是你想要的。

还有一个细节值得注意:shape 返回的是类元组结构,你可以直接用 h, w = img.shape[:2] 这种方式取值。很多函数比如 cv2.resize、cv2.circle 需要的参数顺序是(宽,高),但 shape 返回的是(高,宽),这就成了天然的口令混淆点。我每次写 resize 都要在心里默念一遍“shape 给的是高宽,resize 要的是宽高”。

2.2 dtype 为什么是 uint8

第二个必查属性是 dtype。OpenCV 里最常见的图像类型是 uint8,也就是无符号 8 位整数,取值范围 0 到 255。

为什么是 255?因为 8 位二进制能表示 256 个值,从 0 到 255。单通道每个像素就是一个亮度值,三通道合起来就是 BGR。这个值域约束非常重要,直接关系到后面算术运算里“溢出”的问题。你可以把 uint8 想象成一个只有 256 个刻度的量杯,一旦往里倒超过 255 的水,就会从 0 重新开始计——这也就是后面要讲的直接相加和 cv2.add 的结果差异根源。

有些图像处理需要更高的精度,比如深度图、HDR 图像,就会用 uint16、float32 等类型。但不管什么类型,拿到图像第一步养成打印 dtype 的习惯,能避免后面大量类型不匹配的报错。

2.3 size 到底是个什么数

print(img.size)

size 返回的是图像的总像素个数,计算方式是 高 × 宽 × 通道数。以 480×640×3 为例,size 就是 921600。这个数字的意义体现在你需要估算处理耗时和内存占用时,比如要写一个逐像素遍历的算法,先算一下总像素量心里就有底了。

顺带说一个实际排查经验:imread 读图失败时不报错,而是返回 None。如果对 None 调用 shape,会直接抛出 AttributeError。所以严格来说,读图之后应该先加一句判断:

if img is None: print("图片读取失败,请检查路径") exit()

这一点保姆级教学必须强调,因为路径写错、文件名打错、中文路径没处理好,都会导致 img 变成 None,而新手往往在这一步卡很久。

3. ROI:用切片裁剪出你关心的局部区域

3.1 切片的本质是 NumPy 的切片操作

OpenCV 的图像就是一个 NumPy 数组,所以 ROI 提取用的就是 NumPy 的切片语法。语法格式如下:

roi = img[y1:y2, x1:x2]

参数含义需要逐字拆解:

  • y1、y2:行范围对应高度方向,也就是纵坐标;
  • x1、x2:列范围对应宽度方向,也就是横坐标;
  • 区间是左闭右开,意思是包含 y1 和 x1 位置的像素,但不包含 y2 和 x2 位置的像素。

如果你原图是 480×640,想截取画面中央一个 200×200 的正方形区域,中心点大概是(320, 240),那么代码是:

roi = img[140:340, 220:420]

这里的 140:340 是高方向的范围,220:420 是宽方向的范围。闭区间开区间这个细节真的值得圈出来:很多人写 ROI 时发现截出来的图总比预期少一行或者多一行,跟边界没算清楚关系很大。

3.2 小心视图与拷贝的区别

切片提取出来的 ROI 默认是原图的“视图”,不是复制品。也就是说,你修改 roi 里的像素,原图对应位置的像素也会跟着变。

roi = img[140:340, 220:420] roi[:, :, 0] = 0 # 把 ROI 区域蓝色通道清零 cv2.imshow("img", img) # 原图对应区域也变蓝了

这在某些场景下是有用的,比如你想直接改原图的局部区域;但在另一些场景下是致命的,比如你只是想裁剪出来做检测分析,却意外污染了原图数据。所以我的习惯是:只要后续要对 ROI 做修改,就显式调用 copy(),把视图变成独立数据。

roi = img[140:340, 220:420].copy()

3.3 典型应用:把裁剪与后续操作串联

假设下一步要对咖啡杯照片中的杯口区域做锐化或亮度调整,你就可以先提取 ROI,然后做处理,最后再填回去。这个“提取—处理—填回”三步走,是 ROI 相关项目里最高频的套路。

cup_roi = img[100:300, 200:400].copy() # 对 cup_roi 做任意处理 cup_roi = cv2.addWeighted(cup_roi, 1.2, cup_roi, 0, 0) # 填回原图 img[100:300, 200:400] = cup_roi

这段代码里 addWeighted 的作用是给 ROI 增加对比度,后面第 4 节会详细讲。你先记住这个“局部增强后再填回”的套路,很多图像编辑工具的美颜、局部调色功能,底层就是这套逻辑。

4. 通道的拆分与合并:split、merge 与 BGR 陷阱

4.1 为什么要拆通道

一张 BGR 彩色图是三张单通道图的叠加。拆通道就是把这三张“灰度图”单独拿出来。用途非常多:调色时单独改某个通道、做颜色特征分析时统计通道均值、把某个通道置零来观察色彩贡献,或者做颜色空间转换前的预处理。

b, g, r = cv2.split(img)

返回值是三张单通道图,每张都是和原图同样尺寸的二维数组。注意我不是随手写的 b, g, r 顺序,这是 OpenCV 死规定:第一通道是 Blue,第二是 Green,第三是 Red。不少从 RGB 世界观过来的人,拿到 split 结果之后第一反应是 r, g, b,结果就是红蓝颠倒。

4.2 通道合并与通道“调包”

split 的逆操作是 merge:

merged = cv2.merge([b, g, r])

merge 接收一个通道列表,按 B、G、R 顺序组装回三通道图。实际工作中经常用 merge 做通道替换。比如我想把原图的红色通道换成另一个图的红色通道,可以这样:

b1, g1, r1 = cv2.split(img1) b2, g2, r2 = cv2.split(img2) new_img = cv2.merge([b1, g1, r2])

这种通道级别的混合,在风格迁移、图像滤镜、色彩分析类项目里很常见。我曾经做过一个简单的“色彩迁移”Demo,核心就是用不同通道的比例混合再加权,视觉上能做出冷色调、暖色调的切换。

4.3 BGR 顺序带来的经典“红蓝互换”事故

这里必须单独提一下 BGR 和 RGB 的问题。你在屏幕上看到的绝大多数图片格式都是 RGB,但 OpenCV 读进来的是 BGR。如果你把 OpenCV 读的图直接拿给 matplotlib 去显示,会出现整张图红色和蓝色互换的诡异效果:

import matplotlib.pyplot as plt plt.imshow(img) # 颜色失真,红蓝颠倒

正确的做法是先用 cvtColor 转换:

img_rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) plt.imshow(img_rgb)

同样的逻辑也适用于 split/merge 场景。你用 split 拆出来的通道,第一张图是 B 不是 R;你用 merge 组装时,列表第一项也必须放 B 通道。想要红通道单通道显示,应该对第三个通道做处理,而不是第一个。

4.4 性能注意事项:循环里别用 split

split 和 merge 在单次调用时很直观,但它们涉及内存拷贝,速度不是最快的。如果在视频处理或者大量图片的循环里反复调用 split,性能瓶颈会很明显。更高效的做法是直接用数组索引:

b = img[:, :, 0] g = img[:, :, 1] r = img[:, :, 2]

注意:这样取出来的通道也是视图,修改它会影响原图。如果不想影响原图,同样可以 .copy()。

还有个细节值得知道:OpenCV 4.x 里 split 返回的通道是复制数据,而切片返回的是视图。所以严格来说,如果只读不改,切片更快;如果必须拆出来反复修改,split 更省心,但速度上有代价。这个权衡没有绝对答案,根据你的场景选择即可。

5. 图像算术运算:从直接相加到加权融合

5.1 加法:直接“+”和 cv2.add 的致命差异

这是保姆级教学里最值得展开的一个点。两块 uint8 图像直接相加,结果很可能溢出,而溢出的处理方式决定了你看到的是全白还是突然变黑。

先做一个小实验:

import numpy as np a = np.array([200], dtype=np.uint8) b = np.array([100], dtype=np.uint8) print(a + b) # [44] print(cv2.add(a, b)) # [255]

200 加 100 等于 300,但 uint8 最大值只有 255。直接相加时,NumPy 按取模运算,300 对 256 取模,结果是 44;cv2.add 按饱和运算,超过 255 就截断为 255。这个差异在图像上表现非常直观:直接相加的图会出现大量噪点式的黑斑,cv2.add 的图则更接近“变亮”的效果。

对于一张真实图像,代码对比长这样:

img2 = np.full_like(img, 100) out1 = img + img2 out2 = cv2.add(img, img2)

np.full_like(img, 100) 会生成一张和原图同尺寸同类型的纯色图,每个像素值都是 100。把原图每个像素加 100,视觉效果就是画面整体变亮。out1 因为溢出会有很多地方出现诡异的暗色条纹,out2 则是平滑变亮。所以做图像亮度增强时,标准做法是 cv2.add 而不是 numpy 直接加。

5.2 subtract、multiply、divide 的同类规则

减法、乘法、除法也遵循同样的饱和逻辑。cv2.subtract 的典型用途是做背景差分:把当前帧和背景帧做差,露出运动目标区域。cv2.multiply 和 cv2.divide 在图像校正、对比度调整里会用到。

与加法一样,这些函数最常用的是两个输入参数:src1 和 src2,可以由常量或图像充当。比如给图像减 50 降低亮度:

dark = cv2.subtract(img, np.full_like(img, 50))

这里为什么要用 np.full_like(img, 50) 而不是直接传常数 50?因为 add/subtract 要求两个输入图尺寸一致,如果你想用常数,需要把它广播成和原图一样的数组。不少新手在这里遇到 shape mismatch 报错,就是因为传了标量常数而原图是多维数组。

5.3 addWeighted:两张图按权重融合的数学

addWeighted 是算术运算里价值最高的函数,它的公式是:

dst = src1 × alpha + src2 × beta + gamma

其中参数顺序是:

dst = cv2.addWeighted(src1, alpha, src2, beta, gamma)

alpha 和 beta 是两张图的权重,gamma 是额外加的亮度偏移。最常见用法是 alpha = 0.5,beta = 0.5,gamma = 0,这样两张图各占一半透明度叠加,产生“半透明混合”的效果。

img_a = cv2.imread("coffee.jpg") img_b = cv2.imread("milk.jpg") # 尺寸必须一致,否则报错 img_b = cv2.resize(img_b, (img_a.shape[1], img_a.shape[0])) blend = cv2.addWeighted(img_a, 0.5, img_b, 0.5, 0) blend2 = cv2.addWeighted(img_a, 0.7, img_b, 0.3, 30)

blend 就是两张图的五五开融合,blend2 则让第一张图更突出,同时通过 gamma=30 把整体亮度抬高了 30。这种操作在幻灯片过渡、图片相册转场、图像拼接重叠区平滑过渡里都大量使用。

实际项目中需要注意两个很常见的报错点:一是两张图的尺寸不一致,必须先 resize;二是类型不一致,比如一张 uint8 一张 float32,addWeighted 会直接抛异常。判断类型最简单的方法还是习惯性打印 dtype。

5.4 运算结果超出 0-255 怎么办

上面讲的主要针对 uint8 图。但有些算法处理时你可能会把图像转成 float32,比如做归一化、做频域滤波,这时候像素值可能超出 0-255 的范围。如果直接 imshow 一个 float32 图像,OpenCV 的显示逻辑会把它当 0-1 范围处理,导致整张图白茫茫一片。

解决办法是显示前做一次归一化,把值域拉回 0-255:

result_float = np.clip(result_float, 0, 255) result_uint8 = result_float.astype(np.uint8)

或者用 normalize 函数:

result_norm = cv2.normalize(result, None, 0, 255, cv2.NORM_MINMAX)

这是一个非常容易被忽略的坑。很多人在滤波、锐化、频率域处理后图莫名其妙全白,十有八九是浮点显示问题,而不是算法本身错了。

6. 逻辑运算与 mask 实用套路:bitwise_and、or、xor、not

6.1 四个位运算符到底是干什么的

OpenCV 的逻辑运算包括 bitwise_and、bitwise_or、bitwise_xor、bitwise_not,它们对图像的每个像素按位进行与、或、异或、非操作。

最常见的应用场景是 mask 遮罩。所谓 mask,就是一张单通道二值图,白色区域对应 255(保留),黑色区域对应 0(忽略)。把 mask 交给 bitwise_and,就能把图像中 mask 白色区域的像素保留下来,黑色区域清零。

mask = np.zeros(img.shape[:2], dtype=np.uint8) mask = cv2.circle(mask, (320, 240), 150, 255, -1) masked = cv2.bitwise_and(img, img, mask=mask)

这段代码先创建一张全黑的单通道图,然后以(320, 240)为圆心画一个半径为 150 的白色实心圆,得到圆形 mask。最后 bitwise_and(img, img, mask=mask) 的效果就是:原图只保留圆形区域,圆形以外的部分全部变成黑色。

注意 bitwise_and 接收两个输入 src1、src2,当你想保留的是原图本身时,两个输入可以都传 img。mask 参数是可选的,一旦传入,运算只作用于 mask 白区。

6.2 mask 的三种常见来源

mask 不一定都要手动画圆。实际项目里 mask 最常见的三种来源是:

  • 阈值分割结果(cv2.threshold 输出的二值图);
  • 边缘检测后填充的结果;
  • 语义分割模型的输出。

比如你做了阈值分割,得到了一个只有黑白两色的 mask,接下来想单独统计原图中白色区域内的平均颜色,就可以用:

ret, mask = cv2.threshold(gray, 128, 255, cv2.THRESH_BINARY) mean_val = cv2.mean(img, mask=mask)

cv2.mean 配合 mask 参数,只会计算白色区域的均值,这不是一次性函数,而是“mask 机制”在整条 OpenCV 生态里的通用用法。学会了 mask 的思维方式,再看很多 OpenCV 函数里的 mask 参数,就会觉得很自然。

6.3 实战:把一个小 Logo 贴到大图上

这里做一个完整的小项目演示,把黑底图标贴到咖啡图的一角。原图先读进来,Logo 图读进来,假设 Logo 是黑背景。第一步先把 Logo resize 成目标大小,第二步把 Logo 转灰度后做阈值,得到前景 mask 和它的反相,第三步在原图 ROI 区域中最难的部分就是用 mask_inv 把背景区抠掉,第四步把 Logo 前景和原图背景相加完成贴图。

photo = cv2.imread("coffee.jpg") logo = cv2.imread("logo.jpg") logo = cv2.resize(logo, (150, 100)) h, w = logo.shape[:2] x1, y1 = 50, 50 x2, y2 = x1 + w, y1 + h roi = photo[y1:y2, x1:x2] gray_logo = cv2.cvtColor(logo, cv2.COLOR_BGR2GRAY) ret, mask_logo = cv2.threshold(gray_logo, 10, 255, cv2.THRESH_BINARY) mask_inv = cv2.bitwise_not(mask_logo) roi_bg = cv2.bitwise_and(roi, roi, mask=mask_inv) logo_fg = cv2.bitwise_and(logo, logo, mask=mask_logo) photo[y1:y2, x1:x2] = cv2.add(roi_bg, logo_fg)

这个四步走你一定要亲手敲一遍:roi_bg 是把原图 ROI 区域中 Logo 会挡住的部分抠成黑色;logo_fg 是把 Logo 本身从黑背景里抠出来;两者相加,就是“原图背景 + Logo 前景”的干净叠加。这套流程在很多纹理贴图、动态贴纸、相框合成项目里反复出现。

6.4 逻辑运算比条件判断快在哪

有人可能会问:为什么不用循环 + 条件判断来逐像素处理,非要搞 bitwise 这一套?答案是性能。图像是 480×640×3 就有几十万个像素,Python 的 for 循环逐像素处理一张图可能要几秒甚至更久,而 NumPy/OpenCV 的向量化位运算都是 C 层实现,毫秒级完成。位运算本身在硬件层面也是单指令操作,比乘除法、分支判断都快很多。所以凡是能用 bitwise 实现的遮罩操作,坚决不要写 Python 循环。

7. 保姆级避坑清单:7 个常见报错与修复

把前面踩过的和容易踩的坑整理成一张速查表,建议收藏:

问题现象根本原因修复方式
img.shape 报 AttributeError: NoneTypeimread 路径错误,读图失败返回 None检查路径,读图后先判断 img is None
裁剪区域出来后位置不对把 shape[0] 当成了宽度记住 shape 是(高,宽,通道数),裁剪是 img[y1:y2, x1:x2]
修改 roi 后原图也被改了切片是视图不是副本需要独立数据时用 roi = img[y1:y2, x1:x2].copy()
cv2.add 报 Sizes of input arguments do not match两图尺寸不一致先 cv2.resize 统一尺寸
输出图全是白的float32 图直接被 imshow显示前用 np.clip + astype(np.uint8) 或 cv2.normalize
split 在循环里特别慢内存拷贝开销大循环里改用 img[:, :, 0] 切片取通道
bitwise_and 报 mask 尺寸或通道数不对mask 是多通道或尺寸与 src 不一致mask 必须单通道,且 shape 与 img.shape[:2] 一致

这里面每一条我都实际遇过,尤其是“修改 roi 后原图被改”和“float 图全白”这两个,属于那种不踩一次永远记不住的坑。排查时别看报错信息只盯着最后一行,先打印一下 img 的 shape、dtype、以及是否为 None,能快速定位 80% 的问题。

8. 个人实操体会:这些语法到底该怎么练

写到这里,我想给还在坚持的同学一点鼓励和练习建议。语法(四)是系列里最像“背单词”的一课,不像边缘检测、人脸识别那样有酷炫的视觉反馈。但说真的,我在后面做图像融合、做数据增强、做目标检测预处理时,用得最多的反而不是那些大模型 API,就是今天这些基础操作。

练的时候可以这样组合出一个完整任务:读两张图,各自做 ROI 裁剪,用 split/merge 调整通道,再用 addWeighted 做融合,最后用 bitwise_and 加圆形 mask 做局部展示。这个过程把今天所有语法全部串起来了,大概花一个下午就能跑通。跑通之后你再回去看任何 CV 项目的源码,会发现很多代码就是这些基础语法的组合变体。

还有一个值得养成的习惯:每学一个 OpenCV 函数,都去看一眼它的重载形式和参数文档。今天讲的 add 和 addWeighted,都有带 mask 的版本,后面学阈值、学轮廓时,你会发现这些函数几乎全员都支持 mask。把 mask 这个心智模型刻进脑子里,OpenCV 就算入门一半了。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进