🍓 第十六章 硬件接口实验范例(二):树莓派 AI 自驾车
📄 一句话概括

让程序从【看到一团像素】升级到【认得出这是目标颜色,还知道它此刻在画面哪个位置】——换到 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,颜色判定就跨越了明暗变化——这是色彩检测从【玩具】变成【能用】的那一步。

⚠️ H 为什么只到 179 而不是 360

标准色相环是 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 里极其常见的一个概念,它不直接给人看,而是当【模板】用。

第二步形态学去噪:现场光线总有噪点,掩膜上会撒着零星白点。两步搞定:

先用 1 次腐蚀、再用 2 次膨胀,是色彩检测的默认起手参数(先削再补,补得比削得多一点,边缘更饱满)。

第三步 findContours 找轮廓:把掩膜上每一片连通的白色区域,描出它的边界点集。之后所有几何计算(面积、外框、中心)都基于轮廓做。

⚠️ 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 的转向全靠这个数。

⚠️ 用 moments 取重心时,必须先检查 m00

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 退出

读结果:

六、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)      # 两张掩膜按位或,合成一张

只写一段的后果:能追到偏橙的那半边红,但正红、深红一律漏掉——现场表现是【有时候追不到】,极易被误判成"代码不稳定"。

八、常见误区

  1. 【颜色检测用 BGR 阈值就行,我跑通了啊】——跑通的是你此刻这盏灯。换个房间、拉个窗帘,三个数字全变、阈值立刻失效。H 与 V 分家正是为了解决这件事,别退回 BGR。
  2. 【红色写一段阈值够了】——红在色相环的接缝上,必须 0~10 与 170~179 两段再 bitwise_or 合并,单段必漏一半。
  3. 【contours[0] 就是我要的那个】——findContours 的返回顺序是按扫描线(从上到下、从左到右),不按大小。要用必须 max(contours, key=cv2.contourArea) 自己挑。
  4. 【直接用 moments 求重心,不用管 m00】——m00 在分母上,轮廓极小时它为 0 → 除零崩溃。现场一定遇到,先判 if M["m00"] != 0。
  5. 【形态学只是润色,可以不做】——不做的话掩膜上会撒满噪点,findContours 会返回几十个碎轮廓,max 出来的可能是噪点而不是目标。先腐蚀 1 次再膨胀 2 次是标配。
  6. 【cv2.findContours 返回三个值】——那是 OpenCV 3 的写法,OpenCV 4 只返回两个。本课 11-2 装的是 4,照抄老教程必报【too many values to unpack】。
  7. 【掩膜窗口是给人看的,可以关掉】——恰恰相反,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 的自驾车转向,用的都是这一个数。

相关笔记