YCrCb肤色检测与二值化:从彩色图到干净掩膜

发布时间:2026/10/28 6:44:52
YCrCb肤色检测与二值化:从彩色图到干净掩膜

1. 这道附加题在整条流水线里的位置

肤色检测和二值化处理这两件事,放在一起看,其实是图像预处理阶段最典型的一组“先找目标、再定边界”的组合拳。很多人第一次看到这道附加题,会下意识觉得它简单——不就是换个色彩空间加个阈值嘛,几行代码的事。但真把图丢进去跑一遍就会发现,阈值卡得松一点,背景里的木地板、牛皮纸箱、橙色抱枕全跟着亮起来;卡得紧一点,手背、脖子这些偏亮或偏暗的区域又断成几块。这不是代码写错了,是肤色检测本身就是一个“在连续空间里做离散切分”的问题,天生带着模糊性。

这道题真正想让你掌握的,是把一个非结构化的彩色图像,逐步压缩成一个结构清晰的二值掩膜(mask)。彩色信息熵太高,直接拿来算轮廓、算面积、做模板匹配,误差会被放大好几倍;而二值图只有0和255两个值,前景和背景泾渭分明,后面的连通域分析、轮廓提取、质心计算才有稳定的落脚点。说白了,肤色检测负责“什么可能是皮肤”,二值化负责“把模糊的可能变成确定的黑白”,中间再穿插形态学操作把毛刺和空洞补掉,这一整条链路走通了,很多上层任务——手势识别里的掌心定位、人脸检测的前置筛选、抠图工具的初版选区——才有得谈。

适合谁看?如果你刚学完 OpenCV 的基础图像读取和色彩空间转换,想找一个能立刻上手、又能看到效果的小项目,这道题非常合适;如果你已经写过几版肤色分割但总被光照和背景干扰,那下面关于阈值推导和形态学参数的部分,大概率能帮你把结果从“能跑”推到“能用”。我按自己实际调试的顺序来写,不绕弯,参数是怎么来的、坑是怎么踩的,都摆在明面上。

2. 方案选型:为什么绕不开 YCrCb

2.1 RGB 三通道的耦合问题

新手最直觉的做法是在 RGB 空间里划一个立方体:R 在 95 到 220 之间,G 在 40 到 210 之间,B 在 20 到 170 之间,再加上几条经验规则,比如max(R,G,B) - min(R,G,B) > 15、R > G、R > B、|R - G| > 15。这套规则确实能用,文献里也常见,但它有个绕不过去的毛病:三个通道是被亮度强耦合在一起的。

同一块皮肤,在正午阳光下和傍晚阴影里,R、G、B 的绝对值能差出一大截,可它们在“色度”上其实是接近的。你在 RGB 里定死的那个立方体,本质上是给某一个光照条件写死了答案,换个场景就得重调。我做过一个很朴素的对比:同一个人在窗边和室内灯下各拍一张,RGB 规则法的掩膜重合度不到六成,而换到 YCrCb 之后能到八成五以上。原因不复杂——YCrCb 把亮度 Y 单独拆了出去,Cr 和 Cb 两个分量描述的是“偏红”和“偏蓝”的程度,跟明暗基本脱钩。

具体到数值,OpenCV 里 8 位 YCrCb 的换算是这样的:

Y = 0.299R + 0.587G + 0.114B Cr = (R - Y) × 0.713 + 128 Cb = (B - Y) × 0.564 + 128

那个+128是关键,它把本来可能是负数的色差分量整体平移到 0 到 255 的正数区间,方便用uint8存储和inRange做区间判断。所以你看到的所有“Cr 在 133 到 173”这类经验值,都是加了偏移之后的结果,别拿原始公式去硬套。

2.2 三种常见肤色模型的横向对比

实际动手前,我建议先花十分钟把可选方案摆开看一眼,别一上来就闷头调参。下面这张表是我自己整理过的,参数和结论都来自实际跑图的结果:

模型典型阈值区间优点明显短板适用场景
RGB 规则法R∈[95,220]、G∈[40,210]、B∈[20,170],且 R>G>B、max-min>15零依赖、不用转换、逻辑一眼看懂对光照极敏感,阈值随场景漂移严重光照恒定的封闭环境、快速验证
HSVH∈[0,25] 或 [160,180],S∈[40,255],V∈[60,255]直观、调参手感好、能直观看出“颜色”低饱和区域 H 不稳定,浅肤色易漏;H 在红色附近绕环中等亮度、色彩饱和的人脸/手部
YCrCbCr∈[133,173],Cb∈[77,127]亮度色度解耦,跨光照稳定性最好对极暗区域仍会失效,需要 Y 做下限过滤绝大多数通用场景,尤其是室内外混合光照

表格里 YCrCb 的区间是流传最广的一组经验值,我一开始也是直接抄的。但这里有个坑必须提前说清楚:这组值是统计意义上的“大概率落在里面”,不是物理边界。你如果直接拿它去跑自己的图,尤其是手机前置摄像头拍的、带美颜算法预处理的、或者白平衡偏得厉害的图,结果往往会偏差一大截。所以下面的实操里,我会带上“怎么用样本反推阈值”这一步,而不是让你死记数字。

2.3 HSV 和 YCrCb 到底选哪个

有人会纠结。我的实际经验是:如果你的输入来源固定——比如就是某个固定型号的摄像头、固定工位的光照——HSV 调起来更快,因为 H 通道的直方图看起来很直观,你一眼能看出肤色那片峰在哪。但只要你稍微需要一点鲁棒性,比如要处理室内外交界、或者用户随手拍的图,YCrCb 的稳定性优势会立刻体现出来。

还有一个很实际的考量:OpenCV 的cv2.inRange对 YCrCb 三通道直接做区间判断,写起来非常短,一行就出掩膜;而 HSV 因为 H 通道在红色附近是环形绕回的,遇到偏红肤色时你得做两次 inRange 再按位或,代码立刻变长。我个人的偏好是先用 YCrCb 打底,出问题的时候再把 HSV 的结果拿来做“与”运算补一刀。比如浅肤色在 HSV 的低饱和区容易漏,但 YCrCb 能抓住,两张掩膜取交集,误检会明显下降。

3. 实操:从一张彩色图到干净的二值掩膜

3.1 环境和依赖

环境没什么花哨的,Python 3.8 以上,装两个包:

pip install opencv-python numpy

opencv-python就够了,不需要contrib,肤色检测和二值化用的都是主库里的函数。如果你要处理视频流,cv2.VideoCapture也在主库里。踩过的一个小坑:某些环境下opencv-python-headless和带 GUI 的版本同时装了会冲突,cv2.imshow直接报错或者闪退,出问题先pip list | grep opencv看一眼有没有重复安装。

3.2 读图与色彩空间转换

第一段代码很短,但有个顺序问题值得说。我习惯先把 BGR 读进来,立刻转 YCrCb,中间的 BGR 图保留着做后续的可视化叠加,别急着丢。

import cv2 import numpy as np img = cv2.imread("hand.jpg") if img is None: raise FileNotFoundError("图片路径有问题,注意中文路径在部分环境会读失败") ycrcb = cv2.cvtColor(img, cv2.COLOR_BGR2YCrCb)

提示:路径里带中文或空格,在某些系统上cv2.imread会静默返回 None,不报错。养成if img is None的习惯,能省掉很多“为什么我的程序没反应”的排查时间。

cv2.split把三个通道拆开,方便你单独看 Cr 和 Cb 的分布。这一步在调试阶段非常有用,你可以把 Cr 通道当灰度图imshow出来,肤色的区域会明显比背景亮,一眼就能判断你的图是不是“适合用 YCrCb 处理”。

3.3 用样本反推属于你的阈值

这一步是我最想强调的。不要直接抄 133/173、77/127。正确做法是:找一个图里确定是皮肤的小区域,把它的 Cr、Cb 统计出来,算均值和标准差,按正态分布的思路扩出区间。

def estimate_range(img_bgr, box, k=2.5): x, y, w, h = box roi = img_bgr[y:y+h, x:x+w] ycrcb = cv2.cvtColor(roi, cv2.COLOR_BGR2YCrCb) cr = ycrcb[:, :, 1].astype(np.float32) cb = ycrcb[:, :, 2].astype(np.float32) mu_cr, sd_cr = cr.mean(), cr.std() mu_cb, sd_cb = cb.mean(), cb.std() return (int(max(0, mu_cr - k * sd_cr)), int(min(255, mu_cr + k * sd_cr)), int(max(0, mu_cb - k * sd_cb)), int(min(255, mu_cb + k * sd_cb)))

k取 2.5 是我的习惯,理论上覆盖约 98.8% 的样本,实际用下来比取 2.0 更稳、比取 3.0 少一些背景渗漏。你如果处理的图特别干净,可以降到 2.0;如果样本区域本身混进了阴影或者边缘,标准差会被拉大,这时候反而要先检查你框的 ROI 是不是把背景也圈进去了。

举一个具体的推导过程,别光看公式。假设我框了一块手背,算出来mu_cr = 148、sd_cr = 6.2,那么k=2.5时区间就是[148 - 15.5, 148 + 15.5],取整后[132, 163]。mu_cb = 104、sd_cb = 7.8,区间[84, 123]。把这两个区间和经典经验值[133,173]、[77,127]求交集,得到[133, 163]和[84, 123]——比原版收紧了一圈,实测漏检会略多但误检明显下降。如果你更在意召回率,就取并集或者直接只用样本区间,两者取舍看你下游任务的容错度。

注意:Cr 和 Cb 的区间判断是对整张图逐像素做的,如果你的场景里存在大面积的砖红色墙面、原木色桌面、橙色衣物,它们会大量落进这个区间。这不是阈值没调好,是颜色本身的物理接近,后面必须靠连通域和面积过滤来兜底。

3.4 生成肤色掩膜与 Y 通道的兜底

阈值有了,掩膜就是一行:

lower = np.array([0, cr_lo, cb_lo], dtype=np.uint8) upper = np.array([255, cr_hi, cb_hi], dtype=np.uint8) mask = cv2.inRange(ycrcb, lower, upper)

注意 Y 通道我给了 0 到 255 的完整范围,意思是“亮度不参与筛选”。这是个取巧,但有代价:画面里很暗的区域,色度信息本身就不靠谱。8 位量化下,像素值只有十几二十的时候,Chroma 的计算会引入巨大的量化误差,随便一块深色阴影都可能被判成肤色。

所以稳妥做法是给 Y 加一个下限,通常 40 到 60 之间。改成np.array([40, cr_lo, cb_lo])就行。为什么是 40 而不是更高?因为再往上抬,逆光场景里的脸会被整片切掉。我一般的调法是:先把 Y 下限设为 0 看误检集中在哪,如果误检主要在暗部,就一点点往上加,加到误检消失为止,记录下这个值。40 是个在多数室内场景里比较安全的起点。

另外还有一个更细的补丁:高光区域。过曝的皮肤在 Y 通道接近 255,Cr 和 Cb 会因为截断而失真,表现出来就是额头、鼻梁上一块块的黑洞。这种问题单靠阈值没法根治,得靠后面的形态学闭运算去填。

3.5 形态学去噪:开运算和闭运算的顺序

原始掩膜拿到手,你大概率会看到两个问题:一是背景里散布着细碎的白点(噪声),二是皮肤区域内部有黑孔(漏检)。这两个问题正好对应两种形态学操作,但顺序和核大小都有讲究。

开运算是先腐蚀后膨胀,作用是去掉小面积的孤立白点;闭运算是先膨胀后腐蚀,作用是填掉内部的小黑孔。我的标准流程是三段式:

kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5)) # 第一步:开运算压噪声,核小一点,别把手指边缘啃掉 mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel, iterations=1) # 第二步:闭运算填孔,迭代两次,把高光黑洞补上 mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel, iterations=2)

为什么用椭圆核而不是矩形核?矩形核在斜向边缘上会产生明显的“方块化”锯齿,椭圆核的响应更接近圆形,处理人脸、手部这种有弧度的目标时边缘更自然。核大小 5×5 是个折中,3×3 去噪力度不够,7×7 在小目标(比如远处的人脸)上容易把整块区域吃没。

顺序上一定要先开后闭。反过来的话,闭运算先把噪声点周围的黑区填上,噪声点反而被“养大”了,开运算再去腐蚀也去不干净。这是我早期踩得最深的一个坑,调试了半天以为是阈值问题,其实就是顺序反了。

还有一个容易被忽略的细节:如果原图分辨率很大(比如 4K),5×5 的核相对整张图来说太小,去噪效果几乎为零。这时候要么先把图缩到 640 宽再处理,要么把核放大到 9×9 甚至 11×11。我更推荐前者,缩放本身还顺便降了计算量。

3.6 二值化:三种做法怎么选

到这里掩膜其实已经是二值的了(inRange输出非 0 即 255)。但“二值化处理”这个环节在完整流程里往往还承担另一层任务——处理灰度图版本的分割结果。比如你的方案是先转灰度、再分割,或者你要对某个 ROI 做局部阈值切分。这时候就要用真正的二值化函数了。三种常见做法,适用场景完全不同:

固定阈值:cv2.threshold(gray, 127, 255, cv2.THRESH_BINARY)。够快够简单,但那个 127 基本靠猜。只在光照极度稳定、你已经测出最优值的场景下用。

Otsu 大津法:cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY | cv2.THRESH_OTSU)。它会遍历 0 到 255 所有可能的阈值,对每一种切分计算前景和背景的类间方差,取方差最大的那个。类间方差大意味着两类分得开,所以 Otsu 本质是在找“最不像同一类”的那条分界线。它对双峰直方图的图效果极好,单峰或者峰非常接近的图就会失效——肤色分割恰恰经常是后者,因为皮肤和某些背景的颜色在灰度上太接近了,直方图根本分不出两个峰。所以我一般不建议对全图直接上 Otsu。

自适应阈值:cv2.adaptiveThreshold(gray, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY, 31, 10)。它不找全局分界线,而是对每个像素看它周围blockSize × blockSize的邻域,用邻域均值(或高斯加权均值)减去常数 C 作为该像素的阈值。blockSize必须是奇数,31 是我在 640 宽度图上的常用值;C 取 10 意味着像素要比周围平均亮 10 以上才算前景。这个方法对付光照不均特别有效,但反过来,如果目标本身内部有平滑的明暗过渡,它会切出一堆碎片。

我的组合策略是这样:肤色掩膜负责“位置”,二值化负责“边界”。先用 YCrCb 得到大致区域,然后在这个区域的包围盒里取灰度图做自适应二值化,最后把两个结果按位与:

gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) local_bin = cv2.adaptiveThreshold( gray, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY, 31, 10 ) final = cv2.bitwise_and(mask, local_bin)

这样出来的结果,既保留了肤色筛选的位置准确性,又借助局部对比度把边缘修得更贴合真实轮廓。代价是代码多几步,但在抠图、手势轮廓这类对边界敏感的任务上,这点开销完全值得。

3.7 连通域过滤:把“像皮肤”升级成“是目标”

掩膜再干净,背景里那块原木桌面还是会在。这时候最后一刀必须交给连通域分析——把彼此相连的白像素归成一个个团,按面积、位置、长宽比筛。

num, labels, stats, centroids = cv2.connectedComponentsWithStats(mask, connectivity=8) result = np.zeros_like(mask) for i in range(1, num): # 0 是背景,跳过 area = stats[i, cv2.CC_STAT_AREA] w = stats[i, cv2.CC_STAT_WIDTH] h = stats[i, cv2.CC_STAT_HEIGHT] if area < 800: continue ratio = h / max(w, 1) if not (0.3 < ratio < 4.0): continue result[labels == i] = 255

面积阈值 800 是个经验起点,别照抄。它的合理取值和你的图像分辨率强相关——640 宽的图里,一张占画面十分之一的人脸大概 3000 到 6000 像素;要是原图是 1920 宽,同样的脸就有三万像素,800 这个阈值等于没设。正确的做法是按图像总像素的比例来定,比如area > total_pixels * 0.005,适应性会好很多。

长宽比过滤则是为了干掉那些细长的干扰物,比如木地板的接缝、桌沿。人手在自然张开状态下,外接矩形的长宽比大概在 0.6 到 2.0 之间;人脸接近 1.0 到 1.4。把这个区间放宽到 0.3 到 4.0 已经足够保守,不会误伤正常姿态。

4. 参数调优:用直方图把“猜”变成“算”

4.1 看 Cr/Cb 直方图找峰

调参最忌讳瞎试。有个很实用的做法:把 Cr 和 Cb 通道的直方图打出来,看肤色对应的峰在哪。

cr_hist = cv2.calcHist([ycrcb], [1], None, [256], [0, 256]) cb_hist = cv2.calcHist([ycrcb], [2], None, [256], [0, 256])

正常情况下你会看到一个主峰,那个峰的中心值就是你这批图里肤色的 Cr/Cb 典型值。峰的宽度决定了你的区间该开多宽。如果直方图是一大片平的,说明这张图里肤色占比极小,统计意义不成立,这时候就老实回到经典区间。

我一般会把阈值上下限设成“峰中心 ± 1.5 倍峰半高宽”,这比单纯用标准差更抗异常值,因为直方图的形状直接反映了像素分布,不受个别极端像素的干扰。

4.2 一个容易被忽略的变量:白平衡

同样的场景,手机自动白平衡偏一点,整张图的 Cr 会整体平移五到十个单位。表现出来就是“昨天调好的参数今天不灵了”。如果你是做批量处理,这一点必须提前考虑。

应对思路有两个。一是动态估计:每张图都先用一小块高置信度区域(比如通过人脸检测器定位到的人脸框中心)重新估一遍阈值,再推全图。二是归一化:在转 YCrCb 之前先做一次简单的灰度世界假设白平衡——分别算 R、G、B 三个通道的均值,以 G 为基准,按比例缩放 R 和 B。这个方法很粗糙,但在多数场景下能把色偏拉回可接受范围,成本只有几行代码。

4.3 性能:视频流场景的降采样策略

单张图处理,上面的流程跑一遍大概十几毫秒,完全够用。但如果你要接摄像头做实时处理,30 帧每秒意味着每帧只有 33 毫秒的预算,inRange加两次形态学再加连通域,在低端设备上会开始掉帧。

我常用的优化是降采样处理、上采样应用:

small = cv2.resize(img, None, fx=0.5, fy=0.5, interpolation=cv2.INTER_AREA) mask_small = build_mask(small) # 在小图上跑完整流程 mask = cv2.resize(mask_small, (img.shape[1], img.shape[0]), interpolation=cv2.INTER_NEAREST)

两个关键点:缩小用INTER_AREA,它对下采样最友好,不会产生摩尔纹;放大用INTER_NEAREST,二值图必须保持非 0 即 255,用线性插值会造出一堆灰色过渡像素,后面的findContours直接失效。这个坑我踩过一次,找了半天才发现是插值方式的问题。

实测下来,半分辨率处理能省掉大约六成的耗时,掩膜放大后边缘会有一点点锯齿,但经过一次 3×3 的形态学平滑基本看不出来。

5. 踩坑记录与问题速查

5.1 三类高频问题

问题一:整张图几乎全白

大概率是inRange的上下限写反了,或者lowerb和upperb的三元组顺序跟通道顺序不匹配。OpenCV 读进来是 BGR,转 YCrCb 之后通道顺序是[Y, Cr, Cb],不是[Y, Cb, Cr]。这个顺序错了,阈值就落在一个完全无关的空间位置上,出来什么结果都有可能。

问题二:皮肤区域内部有大片黑洞

高光导致的。额头、鼻尖、手背受光面过曝,Y 接近 255,Cr/Cb 被截断失真。对策是加大闭运算的迭代次数,或者先把原图做一次cv2.GaussianBlur(img, (5,5), 0)再转 YCrCb——模糊能把过曝边缘的色度拉回中间值。但模糊也会让边界变虚,看你更在意哪头。

问题三:背景里的木质、纸质物体大量误检

这是颜色接近导致的物理性问题,调阈值治不好。只能靠连通域的面积和长宽比过滤,或者引入额外的判别特征——比如肤色的空间分布通常比较集中,可以用掩膜的“紧凑度”(面积除以凸包面积)再筛一道。

5.2 常见问题速查表

现象最可能的成因排查动作处理办法
掩膜全黑阈值区间写错 / 通道顺序颠倒单独imshowCr 通道,看肤色区域是否偏亮确认顺序为[Y, Cr, Cb],用样本反推区间
掩膜全白上下限写反 / Y 下限过低打印 lowerb、upperb 实际值交换上下限,Y 下限提到 40 以上
边缘毛刺多未做开运算 / 核太小观察毛刺的像素尺度换 5×5 椭圆核,开运算 1 次
区域内部断裂闭运算不足 / 存在高光检查原图对应位置是否过曝闭运算迭代加到 2 至 3 次,或前置高斯模糊
背景大面积误检存在颜色接近的物体看误检区域是否连通成大块连通域按面积比例过滤,加长宽比约束
二值化后碎片化用了自适应阈值但块太小检查blockSize相对目标尺寸增大到 31 至 51,或改用掩膜加 Otsu
视频流卡顿全分辨率逐帧处理用time.time()分段计时半分辨率处理,INTER_AREA缩、INTER_NEAREST放
换台设备效果骤降白平衡和传感器差异对比同一场景两设备的 Cr 直方图每帧重新估阈值,或做灰度世界白平衡预处理

5.3 几个我踩过、但文档里不会写的细节

cv2.inRange的两个边界数组必须是uint8类型。如果你从计算里得到的是 Python int,直接塞进去在某些版本上不报错但行为异常,养成np.array([...], dtype=np.uint8)的习惯。

形态学的kernel只需要创建一次,别放在循环里反复getStructuringElement。这个函数本身开销不大,但在 30 帧每秒的循环里,累积起来的垃圾回收压力会有感知。

连通域返回的stats里,cv2.CC_STAT_AREA这类常量比直接写 4、2 这类数字可读性好太多,也避免了 OpenCV 版本更新后索引变化带来的隐性 bug。我吃过一次亏,某次升级后stats的列顺序在自己拼接的代码里对不上,找了两个小时。

还有一个关于可视化的技巧:调试阶段别只看最终的掩膜,把bitwise_and(img, img, mask=mask)的结果也显示出来,直接叠在原图上,你能一眼看出到底是哪块区域多切了、哪块少切了。只看黑白掩膜的时候,人对位置的判断会失真。

6. 关于这道题的一点个人体会

这套流程我前后改过七八版,最开始那版就是抄了个 YCrCb 区间加个inRange,出来的结果惨不忍睹,当时还以为是 OpenCV 的问题。后来一点点拆开看,才发现每个环节都有可调的空间,而且这些参数之间是相互影响的——你把 Cr 区间放宽,误检多了,可能只需要把开运算的核从 3 加大到 5 就能救回来;你把 Y 下限抬高,暗部误检没了,但闭运算的迭代次数可能就得跟着减,否则手部边缘会被吃掉一圈。

所以真正值得花时间的,不是把代码敲出来,而是建立一个快速的反馈闭环:改一个参数,立刻看掩膜、看叠加图、看连通域的统计数字,形成“改了什么、结果怎么变”的直觉。这个直觉一旦建立起来,后面再遇到类似的分割任务,你甚至不用算,眼睛看一眼原图就知道大概该往哪个方向调。

我个人在实际操作中的做法是,每换一批新场景的图,都先跑一遍estimate_range,把自动算出的区间和经典区间并排打出来对比,偏差超过 15 个单位就说明这批图的光照条件跟经验值差得远,必须重新标定。这一步多花两分钟,能省掉后面半小时的盲目试错。