让程序从【看到一团像素】升级到【认得出这是目标颜色,还知道它此刻在画面哪个位置】——换到 HSV 色彩空间取掩膜、形态学去噪、找轮廓取中心,三步就能让摄像头一路追着彩色小球跑。 这是全课第一次【不加任何模型、纯靠像素规则】做视觉判断,也是 AI 自驾车的眼睛地基。
11-3 埋的那颗种子终于发芽了——当时讲【一张图就是一个三维数组、BGR 三个通道】,本节第一次真正按通道做判断:不是看整张图,而是问【哪些像素的色相落在绿色区间】。
与 11-4 恰好是一组对照:11-4 是【模型找脸】(硬盘上有个级联文件、OpenCV 读进来跑推理),本节是【像素找颜色】(零模型、纯阈值)。两条路各有所长:找脸必须是模型(脸的颜色不固定),找彩色小球用阈值反而更快更省。
15-4 的车已经能走、能看,但摄像头只是把画面推给浏览器给操作者看;本节第一次让程序自己读画面做出判断——这是从【遥控】到【自主】的那一步。
取流依旧用 10-4 的 picamera2(CSI 摄像头)或 10-6 的 V4L2(USB 摄像头),本节为了代码通用统一写 cv2.VideoCapture(0)。
一、先问一个笨问题:为什么不能直接对 BGR 做阈值
最直觉的做法是这样:绿色物体嘛,绿通道(G)应该比另外两个高,那就写
mask = (frame[:, :, 1] > 150) & (frame[:, :, 0] < 100) & (frame[:, :, 2] < 100)
在你当前这盏灯下它确实能跑通。但把它拿到窗边就废了,原因在于 BGR 的每个通道都在同时承担两件事:
| 通道 | 它其实同时表达了 | 后果 |
|---|---|---|
| B / G / R | 这个像素有多亮 + 这个像素偏什么色 | 灯一暗,三个数一起掉;灯一黄,三个数一起偏 |
也就是说,亮度和颜色被搅在三个数里分不开。同一个小球,在台灯下和窗边,BGR 三个数字完全不同——阈值必须重调。
HSV 做的事就是把这两件事拆开装:
| 通道 | 名字 | 取值 | 管什么 |
|---|---|---|---|
| H | 色相 Hue | 0 ~ 179 | 什么颜色(与亮度无关) |
| S | 饱和度 Saturation | 0 ~ 255 | 颜色有多纯(越淡越接近灰) |
| V | 明度 Value | 0 ~ 255 | 有多亮 |
关键就在 H 与 V 分家:一个绿球从亮处走到暗处,V 掉下去了,但 H 几乎不动。所以只要卡住 H,颜色判定就跨越了明暗变化——这是色彩检测从【玩具】变成【能用】的那一步。
标准色相环是 360°,OpenCV 为了把 H 塞进一个字节(0 ~ 255)做了对折:H 每 1 个单位 = 标准色相 2°。所以标准色相 120°(纯绿)在 OpenCV 里写成 H = 60。看到教程里写 [40, 70, 70] 别以为它在乱写——那对应标准色相的 80° ~ 160°,正是黄色偏绿到青绿的一段。
二、常用颜色的 H 区间(背下来省半小时)
| 颜色 | OpenCV H 区间 | 标准色相 | 备注 |
|---|---|---|---|
| 红 | 0 ~ 10 和 170 ~ 179 | 0° ~ 20° 与 340° ~ 360° | 要两段(见第四节) |
| 橙 | 10 ~ 25 | 20° ~ 50° | |
| 黄 | 25 ~ 35 | 50° ~ 70° | |
| 绿 | 40 ~ 80 | 80° ~ 160° | 本节 Demo 用它 |
| 青 | 80 ~ 100 | 160° ~ 200° | |
| 蓝 | 100 ~ 130 | 200° ~ 260° | |
| 紫 / 品红 | 130 ~ 160 | 260° ~ 320° |
S 和 V 的下限也有讲究:S 太低说明颜色已经很灰(白墙、灰地);V 太低说明太暗(阴影、晚上)。所以通用起手式是 S 和 V 都从 70 起,把【灰】和【黑】直接排除在外——这也是为什么上面的绿色写成 [40, 70, 70] 而不是 [40, 0, 0]。
三、三步流水线
原图(BGR) --cvtColor--> HSV --inRange--> 掩膜(黑白) --形态学--> 干净掩膜 --findContours--> 轮廓 --取面积最大--> 目标
第一步 inRange:给它 HSV 图和下界、上界两个数组,它逐像素比对,落在区间里的输出 255(白),其余输出 0(黑)——得到的是一张只有黑白两色的掩膜图(mask)。掩膜是 OpenCV 里极其常见的一个概念,它不直接给人看,而是当【模板】用。
第二步形态学去噪:现场光线总有噪点,掩膜上会撒着零星白点。两步搞定:
erode(腐蚀):把白区缩小一圈——零星白点直接消失,大块白区只是瘦一圈;dilate(膨胀):把白区扩大一圈——大块白区回到原大小,但已经被抹掉的噪点回不来。
先用 1 次腐蚀、再用 2 次膨胀,是色彩检测的默认起手参数(先削再补,补得比削得多一点,边缘更饱满)。
第三步 findContours 找轮廓:把掩膜上每一片连通的白色区域,描出它的边界点集。之后所有几何计算(面积、外框、中心)都基于轮廓做。
OpenCV 3 返回三个值 (image, contours, hierarchy),OpenCV 4 返回两个值 (contours, hierarchy)。老教程里的 img, contours, _ = cv2.findContours(...) 照抄到 OpenCV 4 上会直接报【too many values to unpack】。本课 11-2 装的是 OpenCV 4,一律写两个值。
四、从一个轮廓,算出【它在哪】
轮廓本身只是一堆点,要拿到【位置】得再走一步。三种取法,各有场景:
| 取法 | 代码 | 得到 | 适合 |
|---|---|---|---|
| 外接矩形 | x, y, w, h = cv2.boundingRect(cnt) |
左上角+宽高 | 最常用,画框+取中心都靠它 |
| 矩(重心) | M = cv2.moments(cnt) |
精确重心 | 形状不规则、想取真正的质心 |
| 最小外接矩形 | cv2.minAreaRect(cnt) |
可旋转矩形 | 需要知道物体角度时 |
外接矩形取中心是最顺手的:
cx, cy = x + w // 2, y + h // 2
cx 就是左右位置——它是接下来所有【追踪】逻辑的输入:目标在画面左边 → 往左转;在右边 → 往右转。16-2 和 16-4 的转向全靠这个数。
cv2.moments(cnt) 返回的字典里,M["m00"] 是轮廓的面积,而求重心的公式是
cx = int(M["m10"] / M["m00"])、cy = int(M["m01"] / M["m00"])
公式里 m00 在分母上。轮廓极小时 m00 = 0 → ZeroDivisionError 当场崩。所以要么先过滤小轮廓,要么写 if M["m00"] != 0: 再算。这个坑老教程基本不写,但现场一定会遇到。
五、动手 Demo:追着一个绿色小球
先做一个不接电机的纯视觉版:画面上把绿球框出来、点上中心、把坐标打在框上方。先确认追踪准了,再接电机——这是硬件项目通用的分层验证法(7 章、15 章都这么干)。
import cv2
import numpy as np
cap = cv2.VideoCapture(0)
cap.set(cv2.CAP_PROP_FRAME_WIDTH, 320) # 先降分辨率:320x240 比 640x480 少 75% 像素
cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 240)
# 绿色的 HSV 区间(16-1 第二节的表)
lower = np.array([40, 70, 70])
upper = np.array([80, 255, 255])
kernel = np.ones((5, 5), np.uint8)
while True:
ok, frame = cap.read()
if not ok:
break
hsv = cv2.cvtColor(frame, cv2.COLOR_BGR2HSV) # BGR -> HSV,只做这一件事
mask = cv2.inRange(hsv, lower, upper) # 双阈值取掩膜,得黑白图
# 先腐蚀去掉零星白噪点,再膨胀把大块白区补回来
mask = cv2.erode(mask, kernel, iterations=1)
mask = cv2.dilate(mask, kernel, iterations=2)
# OpenCV 4 只返回两个值
contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL,
cv2.CHAIN_APPROX_SIMPLE)
if contours:
biggest = max(contours, key=cv2.contourArea) # 按面积挑最大的那片
if cv2.contourArea(biggest) > 500: # 太小的当噪声丢掉
x, y, w, h = cv2.boundingRect(biggest)
cx, cy = x + w // 2, y + h // 2
cv2.rectangle(frame, (x, y), (x + w, y + h), (0, 255, 0), 2)
cv2.circle(frame, (cx, cy), 5, (0, 0, 255), -1)
cv2.putText(frame, "x={} y={}".format(cx, cy), (x, y - 8),
cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 255, 0), 1)
cv2.imshow("frame", frame)
cv2.imshow("mask", mask)
if cv2.waitKey(1) & 0xFF == ord("q"):
break
cap.release()
cv2.destroyAllWindows()
看到什么算成功:
窗口 frame:画面里那个绿色物体被一个绿框套住,框中心有个红点,
框上方写着 x=142 y=118 这样的坐标,数字随物体移动实时跳变
窗口 mask :该位置是一片干净的白色,其余全黑(没有零散白点)
按 q 退出
读结果:
- 掩膜干净=参数对了。若掩膜上白斑一块块、还带零星小点 → 不是代码问题,是 H 区间没对准(去第六节做 30 秒实验)。
- 中心坐标会跳动 1~2 像素是正常的(摄像机噪声+二值化的边界抖动),不用慌;16-4 会用滤波把它抹平。
- 面积阈值 500 是怎么来的:320×240 = 76800 像素,500 只占 0.65%。换算成方块边长
√500 ≈ 22像素——即【大约 22×22 以上的色块才算数】,够滤掉噪点又不会误杀远处的小球。 - 画面是左右镜像的别惊讶(自拍视角),做转向时按镜像后的坐标算即可,上下别搞反。
六、30 秒实验:看一眼【H 卡得太死】长什么样
把 lower 的第一个数(H 下界)从 40 改成 70,其余不动,重跑。预期:掩膜上的白色明显变小甚至消失——因为你把 H 的接受范围从 40~80 缩成了 70~80,大部分绿色被拒之门外。
再改回 40,把 upper 的 H 上界从 80 改成 45,重跑。预期:白色也变小,只剩最纯的那点绿。
结论一句话:H 区间是【一扇窗】,窗开小了漏检、开大了误检——现场调参就是给这扇窗找宽度。这就是为什么下一节要教 Trackbar(滑杆)——边拖边看,比盲改代码快十倍。
七、把调参做成滑杆(现场必备)
上面的做法有个致命的不便:每改一个数就得重启一次程序。正确姿势是拖出六个滑杆,实时看掩膜:
import cv2
import numpy as np
def nothing(x):
pass
cv2.namedWindow("trackbar")
cv2.createTrackbar("H low", "trackbar", 40, 179, nothing)
cv2.createTrackbar("H high", "trackbar", 80, 179, nothing)
cv2.createTrackbar("S low", "trackbar", 70, 255, nothing)
cv2.createTrackbar("V low", "trackbar", 70, 255, nothing)
cap = cv2.VideoCapture(0)
while True:
ok, frame = cap.read()
if not ok:
break
hsv = cv2.cvtColor(frame, cv2.COLOR_BGR2HSV)
h_lo = cv2.getTrackbarPos("H low", "trackbar")
h_hi = cv2.getTrackbarPos("H high", "trackbar")
s_lo = cv2.getTrackbarPos("S low", "trackbar")
v_lo = cv2.getTrackbarPos("V low", "trackbar")
mask = cv2.inRange(hsv, np.array([h_lo, s_lo, v_lo]),
np.array([h_hi, 255, 255]))
cv2.imshow("frame", frame)
cv2.imshow("mask", mask)
if cv2.waitKey(1) & 0xFF == ord("q"):
break
cap.release()
cv2.destroyAllWindows()
用法:把目标物体放镜头前,只动 H 的两个滑杆,直到掩膜里只剩目标、其余全黑;如果还有白色残留在白色/灰色物体上,再上调 S low(把灰踢出去);如果阴影处还在闪白,上调 V low(把暗踢出去)。调好后把这四个数抄回上面那份代码的 lower/upper 里——这才是可以固化的参数。
上面这张表里,红的 H 是【0 ~ 10】和【170 ~ 179】两段——因为红在色相环的【接缝】上,绕一圈回来还是红。所以红色必须做两张掩膜再合并:
m1 = cv2.inRange(hsv, np.array([0, 70, 70]), np.array([10, 255, 255]))
m2 = cv2.inRange(hsv, np.array([170, 70, 70]), np.array([179, 255, 255]))
mask = cv2.bitwise_or(m1, m2) # 两张掩膜按位或,合成一张
只写一段的后果:能追到偏橙的那半边红,但正红、深红一律漏掉——现场表现是【有时候追不到】,极易被误判成"代码不稳定"。
八、常见误区
- 【颜色检测用 BGR 阈值就行,我跑通了啊】——跑通的是你此刻这盏灯。换个房间、拉个窗帘,三个数字全变、阈值立刻失效。H 与 V 分家正是为了解决这件事,别退回 BGR。
- 【红色写一段阈值够了】——红在色相环的接缝上,必须 0~10 与 170~179 两段再
bitwise_or合并,单段必漏一半。 - 【
contours[0]就是我要的那个】——findContours的返回顺序是按扫描线(从上到下、从左到右),不按大小。要用必须max(contours, key=cv2.contourArea)自己挑。 - 【直接用
moments求重心,不用管 m00】——m00在分母上,轮廓极小时它为 0 → 除零崩溃。现场一定遇到,先判if M["m00"] != 0。 - 【形态学只是润色,可以不做】——不做的话掩膜上会撒满噪点,
findContours会返回几十个碎轮廓,max出来的可能是噪点而不是目标。先腐蚀 1 次再膨胀 2 次是标配。 - 【
cv2.findContours返回三个值】——那是 OpenCV 3 的写法,OpenCV 4 只返回两个。本课 11-2 装的是 4,照抄老教程必报【too many values to unpack】。 - 【掩膜窗口是给人看的,可以关掉】——恰恰相反,mask 才是真正的调试窗口。追踪不准时第一件事就是看 mask,而不是改代码。
自测三问(点开对答案)
1. **为什么色彩检测要用 HSV 而不是 BGR?** 因为 BGR 的三个通道把【亮度】和【颜色】搅在一起——灯一暗三个数一起掉。HSV 把色相(H)单独拆出来,**H 几乎不受明暗影响**,所以同一个阈值能跨光照使用。H 只取 0~179,是因为 OpenCV 把 360° 色相环对折塞进一个字节(1 单位 = 2°)。 2. **掩膜上有零星白噪点,应该调哪一步?** 先加/加强**形态学**——`erode` 1 次削掉小噪点、`dilate` 2 次把主体补回来。若噪点来自白色/灰色物体被误判成目标色,则应该**上调 S 的下界**把低饱和的灰踢出区间;若来自阴影,**上调 V 的下界**。 3. **拿到轮廓之后,怎么算出目标在画面中的左右位置?为什么这个数很关键?** `x, y, w, h = cv2.boundingRect(cnt)` 取外接矩形,再 `cx = x + w // 2` 得中心横坐标。**`cx` 是后续所有追踪逻辑的输入**:目标偏左就往左转、偏右就往右转。16-2 的物体追踪、16-4 的自驾车转向,用的都是这一个数。- ☐ 下一节(16-2 打造自动避障和物体追踪车)
相关笔记
- OpenCV 的基本使用 —— 【一张图就是三维数组】的世界观在这里第一次被按通道用起来
- AI 实验范例:OpenCV 人脸识别 —— 对照:那是【模型找脸】,本节是【像素找颜色】,两条路的取舍
- 打造自动避障和物体追踪车 —— 本节算出的
cx直接变成下一节的转向输入 - 打造树莓派 WiFi 遥控视频车 —— 上一章摄像头只是【推给操作者看】,本节第一次让程序自己读画面
- 🍓 树莓派 · 索引 —— 全课路线图