一、前言:为什么要用 HSV 做颜色识别
在计算机视觉的入门项目中,颜色识别几乎是绕不开的一个经典场景。无论是智能小车循迹、机械臂抓取指定颜色的物体,还是工业流水线上的色差检测,背后都离不开一个核心问题:如何让程序"看懂"颜色。
很多初学者第一次接触颜色识别时,会直接拿 BGR 图像去做阈值判断,结果发现效果非常不稳定:光照稍微变一点,阈值就失效了。原因在于 BGR 三个通道都同时受亮度影响,很难用一个固定的数值区间去描述"红色"或"黄色"。
而 HSV 色彩空间把颜色拆成了三个更符合人类直觉的分量:
- H(Hue,色相):表示颜色本身,取值 0~180(OpenCV 中为了适配 8 位存储,把 0~360 度压缩了一半)。
- S(Saturation,饱和度):表示颜色的纯度,取值 0~255,越低越接近灰色。
- V(Value,明度):表示亮度,取值 0~255,越低越接近黑色。
这样一来,我们只需要锁定 H 的范围,就能大致确定"是什么颜色",而 S 和 V 则用来过滤掉过暗、过灰的干扰像素。这正是本文示例代码的核心思想。
一句话总结:BGR 描述的是"像素值",HSV 描述的是"颜色感知"。做颜色识别,优先选 HSV。
二、环境准备
本文示例基于 Python 和 OpenCV,运行前请确保环境就绪。
2.1 安装依赖
bash
pip install opencv-python numpy
如果需要读取摄像头,建议安装完整版 opencv-python,而不是 opencv-python-headless,因为后者不包含 GUI 相关模块,cv2.imshow 会报错。
2.2 验证安装
python
import cv2
print(cv2.__version__)
能正常打印版本号,说明 OpenCV 已经安装成功。
三、核心原理:HSV 与颜色区间
在 OpenCV 中,HSV 各分量的取值范围如下表所示:
| 分量 | 含义 | 取值范围 |
|---|---|---|
| H | 色相 | 0 ~ 180 |
| S | 饱和度 | 0 ~ 255 |
| V | 明度 | 0 ~ 255 |
常见颜色的 H 值区间大致如下(不同光照下会有偏移,仅作参考):
| 颜色 | H 范围 |
|---|---|
| 红色 | 0 ~ 10 以及 156 ~ 180 |
| 橙色 | 11 ~ 25 |
| 黄色 | 26 ~ 34 |
| 绿色 | 35 ~ 77 |
| 青色 | 78 ~ 99 |
| 蓝色 | 100 ~ 124 |
| 紫色 | 125 ~ 155 |
注意红色比较特殊,它跨越了 0 和 180 两个端点,所以判断红色时需要写成两段区间的"或"关系,这也是很多新手容易踩坑的地方。
四、方案一:基于中心区域色相统计的颜色判断
第一种思路非常直观:在画面中央画一个矩形框,把框内所有像素的 H 值取出来,求最小值和最大值,再根据这个范围去匹配颜色名称。
4.1 完整代码
python
import cv2
def get_color(img):
H = []
color_name = None
img = cv2.resize(img, (640, 480))
# 将彩色图转成 HSV
HSV = cv2.cvtColor(img, cv2.COLOR_BGR2HSV)
# 画矩形框
cv2.rectangle(img, (280, 180), (360, 260), (0, 255, 0), 2)
# 依次取出每行每列的 H
for i in range(280, 360):
for j in range(180, 260):
H.append(HSV[j, i][0])
# 分别计算出 H 的最小值和最大值
H_min = min(H)
H_max = max(H)
# print(H_min, H_max)
# 判断颜色
if H_min >= 0 and H_max <= 10 or H_min >= 156 and H_max <= 180:
color_name = 'red'
elif H_min >= 26 and H_max <= 34:
color_name = 'yellow'
elif H_min >= 35 and H_max <= 77:
color_name = 'green'
elif H_min >= 100 and H_max <= 124:
color_name = 'blue'
print(color_name)
return img, color_name
cap = cv2.VideoCapture(0)
while 1:
_, frame = cap.read()
img, cal = get_color(frame)
cv2.imshow('', img)
if cv2.waitKey(1) == 27:
break
4.2 代码逐段解析
第一步:统一分辨率。 cv2.resize(img, (640, 480)) 把画面固定成 640×480,这样后面写死的矩形坐标才有意义。如果不做这一步,不同摄像头分辨率下框的位置会完全错乱。
第二步:BGR 转 HSV。 cv2.cvtColor(img, cv2.COLOR_BGR2HSV) 是颜色识别的必经之路。注意 OpenCV 读入的图像默认是 BGR 顺序,不是 RGB。
第三步:画检测框。 cv2.rectangle 的四个参数分别是左上角坐标、右下角坐标、颜色和线宽。这里用绿色框标出采样区域,方便肉眼确认。
第四步:遍历采样。 双重循环把框内每个像素的 H 分量收集到列表 H 中。这里有一个细节值得注意:HSV[j, i] 中 j 是行(y 方向),i 是列(x 方向),而 cv2.rectangle 的坐标是 (x, y) 顺序,两者刚好相反,写代码时一定要小心。
第五步:统计极值并判断。 用 min 和 max 得到 H 的范围,再逐条匹配颜色区间。
4.3 这个方案的局限
虽然代码能跑通,但它存在几个明显问题:
- 用极值判断过于敏感。 只要框内混入一个噪点像素,
H_min或H_max就会被拉偏,导致判断失败。更稳健的做法是取中位数或众数。 - 没有考虑 S 和 V。 白色、灰色、黑色的 H 值其实是随机的,但这段代码依然会给出一个"颜色名",结果毫无意义。
- 纯 Python 双重循环效率低。 640×480 的图每帧要循环 6400 次,帧率会明显下降。用 NumPy 切片可以一行搞定。
- 红色判断逻辑有隐患。 由于
and优先级高于or,实际执行的是(A and B) or (C and D),逻辑本身没错,但可读性差,建议加括号。
下面给出一个用 NumPy 优化的版本:
python
import cv2
import numpy as np
def get_color_fast(img):
img = cv2.resize(img, (640, 480))
hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV)
cv2.rectangle(img, (280, 180), (360, 260), (0, 255, 0), 2)
# 直接切片取 ROI,避免 Python 循环
roi = hsv[180:260, 280:360]
h_channel = roi[:, :, 0]
s_channel = roi[:, :, 1]
v_channel = roi[:, :, 2]
过滤掉低饱和、低亮度的无效像素
valid = (s_channel > 43) & (v_channel > 46)
if not np.any(valid):
return img, None
h_valid = h_channel[valid]
h_median = int(np.median(h_valid))
color_name = None
if h_median <= 10 or h_median >= 156:
color_name = 'red'
elif 26 <= h_median <= 34:
color_name = 'yellow'
elif 35 <= h_median <= 77:
color_name = 'green'
elif 100 <= h_median <= 124:
color_name = 'blue'
print(color_name)
return img, color_name</code></pre>
这个版本用中位数代替极值,抗噪能力更强,同时用布尔掩码过滤了无效像素,速度也快了一个数量级。
五、常见问题与排查
颜色识别的代码往往只有几十行,但真正跑起来时遇到的问题却五花八门。下面把实际开发中最常见的几类问题整理成表格,方便对照排查。
| 现象 | 原因 | 解决方式 |
|---|---|---|
| 同一套阈值在强光下能识别,换到弱光环境就失效;或者白天正常、晚上完全不准 | 固定阈值只适配某一种光照条件。强光会让 S、V 整体抬高甚至过曝,弱光会让 V 大幅下降,色相也会发生偏移,原本落在区间内的像素被挤出范围 | 先用 cv2.createTrackbar 做可视化调参,边看 mask 边调;再引入自适应机制,例如用画面中的白纸做参考实时校准 S、V 基准,或改用 cv2.calcBackProject 直方图反向投影让程序自己学习颜色分布 |
| 白色、灰色、浅色物体被误判成红色、黄色等彩色 | 低饱和、高亮度的像素 H 值本身是随机噪声,如果不加 S、V 下限过滤,这些像素会被强行归入某个颜色区间 | 在判断前先加有效性掩码,例如 valid = (s_channel > 43) & (v_channel > 46),把低饱和、低亮度像素剔除;若有效像素为空则直接返回 None,不要硬给颜色名 |
| 红色物体明明在画面里,程序却始终判断不出来 | 红色在 HSV 中跨越了 0 和 180 两个端点,只写 0 ~ 10 或只写 156 ~ 180 都只能覆盖一半;另外用极值法时,一个噪点就能把 H_min 拉到中间区域 |
用两段区间取"或":mask1 = cv2.inRange(hsv, (0, 43, 46), (10, 255, 255)) 与 mask2 = cv2.inRange(hsv, (156, 43, 46), (180, 255, 255)),再 cv2.bitwise_or 合并;统计方式改用中位数替代极值 |
画面卡顿、帧率明显偏低,cv2.waitKey 响应迟钝 |
用 Python 双重循环逐像素采样,640×480 的 ROI 每帧要执行数千次解释器循环;同时对全分辨率图像做 inRange 也增加了计算量 |
用 NumPy 切片 roi = hsv[180:260, 280:360] 加布尔掩码替代循环;降低处理分辨率到 320×240;只在 ROI 内做 inRange;必要时跳帧处理,显示时复用上一帧结果 |
运行时报 cv2.imshow 相关错误,或提示 GUI 后端不可用;摄像头打不开、cap.read() 一直返回 False |
安装了 opencv-python-headless,该版本不包含 HighGUI 模块,无法创建窗口;也可能是摄像头索引不对、被其他程序占用,或 Linux 下缺少 /dev/video* 访问权限 |
卸载 headless 版本并改装完整版:pip uninstall opencv-python-headless 后 pip install opencv-python;确认 cv2.VideoCapture(0) 索引是否正确,多摄像头可尝试 1 或 2;关闭占用摄像头的程序,Linux 下把当前用户加入 video 组 |
排查时有一个通用思路值得记住:先把中间结果可视化出来。把 mask 窗口、ROI 区域、H 值统计结果都显示在屏幕上,问题往往一眼就能看出来,比盯着代码猜要高效得多。
六、总结
回顾全文,围绕 OpenCV 颜色识别这条主线,可以把核心结论归纳为以下几点:
- HSV 比 BGR 更适合颜色识别。 BGR 三个通道都受亮度影响,光照一变阈值就失效;HSV 把颜色拆成色相、饱和度、明度三个符合直觉的分量,阈值更稳定、更易调。
- H 分量决定"是什么颜色"。 在 OpenCV 中 H 的取值范围是 0~180,锁定 H 的区间就能大致判断颜色类别,红色因为跨越 0 和 180 两个端点,需要写成两段区间的"或"关系。
- S 和 V 用于过滤无效像素。 白色、灰色、黑色的 H 值本身是随机的,只有先用 S、V 下限(如 S > 43、V > 46)把低饱和、低亮度的像素剔除,颜色判断才有意义。
- 极值法 vs 中位数法要按场景取舍。 极值法实现简单,但一个噪点就能把
H_min或H_max拉偏;中位数法抗噪更强、结果更稳健,代价是多一次排序统计,实际项目优先选中位数。 - NumPy 切片替代 Python 循环带来数量级性能提升。 双重循环逐像素采样在 640×480 下每帧要跑数千次,改用
roi[:, :, 0]切片加布尔掩码后,代码更短、速度更快,这是 OpenCV 图像处理的通用优化思路。