前言

在计算机视觉领域,颜色检测是最基础也最实用的技术之一。红色作为一种醒目的颜色,在交通标志、安全警示、工业自动化等场景中应用广泛。今天我们来深入探讨如何用 OpenCV 实现红色物体的识别,并在此基础上实现多目标跟踪功能。

这篇文章不是简单的 API 调用演示,而是从原理出发,结合实际场景中的问题,一步步构建一个健壮的检测与跟踪系统。我们会遇到光照变化、噪声干扰、部分遮挡等实际问题,然后逐一解决。

一、为什么选择 HSV 颜色空间?

当我们谈论颜色检测时,很多新手第一反应是直接在 RGB 图像上做阈值处理。比如,红色物体的 R 通道值比较高,那么我们设定一个阈值,只保留 R > 200 的像素。但实际一试就会发现,这种方法效果非常差。

问题出在哪里?RGB 颜色空间虽然直观,但它把亮度和颜色信息混在一起了。同一个红色物体,在强光下和阴影下,RGB 值可能差异巨大,但人眼感知到的颜色其实是一样的。这就导致基于 RGB 的阈值检测非常不稳定。

这时候 HSV 颜色空间就派上用场了。HSV 把颜色信息分解成三个独立的通道:

  • H (Hue, 色调):表示颜色的种类,取值范围在 OpenCV 中是 0-179
  • S (Saturation, 饱和度):表示颜色的鲜艳程度,0-255
  • V (Value, 明度):表示颜色的明亮程度,0-255

HSV 颜色空间模型

HSV 的优势在于,颜色信息主要由 H 通道决定,而 V 通道单独控制亮度。这意味着,即使光线变化导致 V 值波动,只要 H 值在我们设定的红色范围内,我们仍然能稳定地检测到目标。

二、红色在 HSV 空间中的特殊性

红色有个有意思的特性:它在色相环的两端都有分布。在标准的 0-360 度色相环中,红色出现在 0 度附近和 360 度附近。OpenCV 为了用 8 位表示,把这个范围减半成了 0-179,所以红色就分布在 0-10 和 170-179 这两个区间。

这是很多初学者容易踩的坑。如果你只检测 0-10 这个区间,会发现稍微偏紫红或者偏橙红一点的物体就检测不到了。正确的做法是同时检测这两个区间,然后把结果合并。

我们来定义一下红色的检测范围:

低红色区间:
- H: 0-10
- S: 100-255
- V: 100-255

高红色区间:
- H: 170-179
- S: 100-255
- V: 100-255

这里的 S 和 V 都设置了一个比较高的下限 100,这是为了过滤掉那些不饱和、偏暗的红色区域,比如暗红色的阴影部分,它们往往不是我们感兴趣的目标。

三、完整的检测流程

知道了颜色空间的选择和范围设定,我们来梳理一下完整的检测流程:

红色物体识别处理流程

  1. 读取图像:从摄像头、视频文件或磁盘读取图像
  2. 高斯模糊:去除噪点,平滑图像
  3. 颜色空间转换:把 RGB 转换为 HSV
  4. 颜色掩码:用 inRange() 函数提取红色区域
  5. 形态学处理:腐蚀和膨胀,去除小的噪点
  6. 轮廓检测:找出连通区域的轮廓
  7. 轮廓过滤:根据面积、形状等条件过滤
  8. 结果输出:绘制检测框和跟踪信息

这个流程看起来步骤不少,但每一步都有它的作用。我们接下来逐一讲解每个环节的原理和实现细节。

四、图像预处理:高斯模糊

在做颜色检测之前,先做一下高斯模糊是个好习惯。原因很简单:原始图像往往有很多噪点,这些噪点在颜色掩码之后会变成一个个小白点,严重影响后续的轮廓检测。

高斯模糊的原理是用一个高斯核(也就是一个权重矩阵)在图像上做卷积。简单来说,就是每个像素的值变成它周围像素的加权平均,离得越近权重越大。这样可以保留图像的整体特征,同时去除高频噪点。

在 OpenCV 中,调用非常简单:

blurred = cv2.GaussianBlur(frame, (11, 11), 0)

这里的 (11, 11) 是高斯核的大小,必须是奇数。核越大,模糊效果越明显,但处理速度也越慢。对于 640x480 分辨率的图像,11x11 的核通常是一个不错的平衡。

五、颜色掩码的生成

有了预处理后的图像,我们就可以生成颜色掩码了。掩码是一个二值图像,属于红色的像素是白色(255),其他的都是黑色(0)。

前面提到红色有两个区间,所以我们需要分别生成两个掩码,然后合并:

# 低红色区间
lower_red1 = np.array([0, 100, 100])
upper_red1 = np.array([10, 255, 255])
mask1 = cv2.inRange(hsv, lower_red1, upper_red1)

# 高红色区间
lower_red2 = np.array([170, 100, 100])
upper_red2 = np.array([179, 255, 255])
mask2 = cv2.inRange(hsv, lower_red2, upper_red2)

# 合并两个掩码
mask = cv2.bitwise_or(mask1, mask2)

inRange() 这个函数会检查图像的每个像素,如果三个通道的值都在对应的上下限之间,就把掩码中对应位置设为 255,否则设为 0。这一步非常高效,因为它是逐像素操作,可以并行化处理。

此时的掩码往往还有一些小的白点,这是图像噪点导致的。接下来我们用形态学操作来清理这些噪点。

六、形态学处理:腐蚀与膨胀

形态学处理是数学形态学在图像处理中的应用,基本操作是腐蚀和膨胀。

腐蚀的作用是去除小的白噪点。它的原理是用一个结构元素(通常是矩形或圆形)在图像上扫描,只有当结构元素覆盖的区域全是白色时,中心像素才保留白色,否则变成黑色。这就导致白色区域向内收缩,小的噪点直接消失。

膨胀则相反,它会让白色区域向外扩张。如果结构元素覆盖的区域有白色,中心像素就设为白色。膨胀可以用来填充物体内部的小黑点。

在实际应用中,我们通常先腐蚀再膨胀,这个组合叫做开运算(Opening):

# 定义结构元素
kernel = np.ones((5, 5), np.uint8)

# 先腐蚀
mask = cv2.erode(mask, kernel, iterations=2)

# 后膨胀
mask = cv2.dilate(mask, kernel, iterations=2)

这里的 iterations 参数表示操作执行的次数。次数越多,效果越明显,但也要注意不要过度处理,否则会把我们感兴趣的目标也腐蚀掉了。

也可以直接用 OpenCV 提供的开运算函数:

mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel, iterations=2)

效果是一样的。通过形态学处理,我们的掩码会变得干净很多,接下来的轮廓检测就容易了。

七、轮廓检测与过滤

有了干净的掩码,我们就可以找轮廓了。轮廓就是把连通的白色像素连起来形成的曲线。OpenCV 的 findContours() 函数就是干这个的。

这里有个小细节需要注意:OpenCV 的 findContours() 在不同版本中有不同的返回值。在 OpenCV 3 中它返回三个值,在 OpenCV 4 中只返回两个。为了兼容,我们通常这样写:

contours, _ = cv2.findContours(mask.copy(), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)

第一个参数是输入图像,注意我们传的是 mask.copy(),因为 findContours() 会修改原图。第二个参数 RETR_EXTERNAL 表示只检测最外层的轮廓,内部的轮廓忽略。第三个参数 CHAIN_APPROX_SIMPLE 是轮廓近似方法,它会把水平、垂直、对角线的线段压缩成端点,节省内存。

拿到轮廓列表后,我们需要过滤掉那些明显不是目标的轮廓。最常用的过滤条件是面积:

min_area = 500  # 最小面积,根据实际情况调整
valid_contours = []
for contour in contours:
    area = cv2.contourArea(contour)
    if area > min_area:
        valid_contours.append(contour)

min_area 的值要根据图像分辨率和目标大小来设置。比如 640x480 的图像,500-1000 像素是一个比较合理的起点。如果设得太小,会有很多噪点被误检;如果设得太大,真正的小目标会被漏掉。

除了面积,我们还可以根据长宽比、圆形度等形状特征来进一步过滤。比如如果我们要检测红色小球,那么可以计算轮廓的外接矩形,然后检查它的长宽比是否接近 1。

八、目标定位:最小包围圆

对于每个有效的轮廓,我们需要确定它的位置和大小。最简单的方法是画外接矩形,但对于圆形物体,最小包围圆通常更合适:

for contour in valid_contours:
    # 计算最小包围圆
    (x, y), radius = cv2.minEnclosingCircle(contour)
    
    # 转换为整数
    center = (int(x), int(y))
    radius = int(radius)
    
    # 绘制结果
    cv2.circle(frame, center, radius, (0, 255, 0), 2)
    cv2.circle(frame, center, 5, (0, 0, 255), -1)

这里 (x, y) 是圆心坐标,radius 是半径。我们画两个圆:一个绿色的大圆表示物体轮廓,一个红色的实心小圆表示圆心位置。这样在可视化时非常直观。

除了最小包围圆,cv2.boundingRect() 可以得到轴对齐的外接矩形,cv2.fitEllipse() 可以拟合椭圆。选择哪种方式取决于你的目标形状和应用场景。

九、多目标跟踪的基本思路

现在我们能检测到每一帧中的红色物体了,但这还不够。在视频序列中,我们需要知道这一帧的物体 A 是不是上一帧的物体 A,这就是跟踪要解决的问题。

最简单的跟踪方法是基于距离的匹配:对于当前帧检测到的每个目标,找它在上一帧中最近的那个目标,如果距离小于某个阈值,就认为是同一个物体。

让我们定义一个简单的跟踪器类:

class TrackedObject:
    def __init__(self, obj_id, center):
        self.obj_id = obj_id
        self.center = center
        self.disappeared = 0  # 消失的帧数

class Tracker:
    def __init__(self):
        self.next_id = 0
        self.objects = {}
    
    def update(self, centers):
        # 如果没有检测到目标,所有物体消失计数+1
        if len(centers) == 0:
            for obj_id in list(self.objects.keys()):
                self.objects[obj_id].disappeared += 1
            return self.objects
        
        # 如果当前没有跟踪的物体,全部注册为新物体
        if len(self.objects) == 0:
            for center in centers:
                self.objects[self.next_id] = TrackedObject(self.next_id, center)
                self.next_id += 1
            return self.objects
        
        # 否则,做距离匹配
        # ... 匹配逻辑留待后面实现

这个跟踪器会给每个物体分配一个唯一的 ID,并且记录它连续消失了多少帧。如果一个物体连续消失超过一定帧数(比如 30 帧),我们就认为它真正离开了视野,可以从跟踪列表中删除了。

十、距离匹配算法

现在来实现距离匹配的核心逻辑。假设当前帧检测到了 N 个中心点,上一帧跟踪着 M 个物体,我们需要找到它们之间的最佳匹配。

首先计算所有可能配对的距离矩阵:

import numpy as np
from scipy.spatial import distance

current_centers = np.array(centers)
object_ids = list(self.objects.keys())
object_centers = np.array([obj.center for obj in self.objects.values()])

# 计算距离矩阵:M x N
D = distance.cdist(object_centers, current_centers)

distance.cdist() 会计算两组点之间所有两两配对的欧氏距离,返回一个形状为 (M, N) 的矩阵。接下来我们要找一个配对方式,使得总距离最小,每个点最多配对一次。

这种问题叫做二分图匹配,可以用匈牙利算法(Hungarian Algorithm)解决。Scipy 提供了现成的实现:

from scipy.optimize import linear_sum_assignment

row_ind, col_ind = linear_sum_assignment(D)

这里 row_ind 是上一帧物体的索引,col_ind 是当前帧检测的索引,它们的对应关系就是最优匹配。

但是直接用匈牙利算法有个问题:如果一个物体是新出现的,或者一个物体消失了,我们不应该强行匹配。所以在得到匹配结果后,我们还要检查每个匹配的距离是否超过了阈值:

max_distance = 80  # 最大匹配距离

used_rows = set()
used_cols = set()

for row, col in zip(row_ind, col_ind):
    if D[row, col] > max_distance:
        continue
    
    # 更新已存在的目标
    obj_id = object_ids[row]
    self.objects[obj_id].center = current_centers[col]
    self.objects[obj_id].disappeared = 0
    used_rows.add(row)
    used_cols.add(col)

# 处理未匹配的跟踪目标(可能消失了)
for row in range(len(object_ids)):
    if row not in used_rows:
        obj_id = object_ids[row]
        self.objects[obj_id].disappeared += 1

# 处理未匹配的检测(可能是新目标)
for col in range(len(current_centers)):
    if col not in used_cols:
        self.objects[self.next_id] = TrackedObject(self.next_id, current_centers[col])
        self.next_id += 1

最后,我们清理掉那些消失太久的目标:

max_disappeared = 30  # 最多允许消失30帧

for obj_id in list(self.objects.keys()):
    if self.objects[obj_id].disappeared > max_disappeared:
        del self.objects[obj_id]

这样我们就有了一个完整的多目标跟踪器。

十一、完整代码实现

现在我们把前面讲的所有内容整合起来,写一个完整的可运行程序:

import cv2
import numpy as np
from scipy.spatial import distance
from scipy.optimize import linear_sum_assignment


class TrackedObject:
    def __init__(self, obj_id, center):
        self.obj_id = obj_id
        self.center = center
        self.disappeared = 0


class Tracker:
    def __init__(self, max_distance=80, max_disappeared=30):
        self.next_id = 0
        self.objects = {}
        self.max_distance = max_distance
        self.max_disappeared = max_disappeared
    
    def update(self, centers):
        if len(centers) == 0:
            for obj_id in list(self.objects.keys()):
                self.objects[obj_id].disappeared += 1
                if self.objects[obj_id].disappeared > self.max_disappeared:
                    del self.objects[obj_id]
            return self.objects
        
        if len(self.objects) == 0:
            for center in centers:
                self.objects[self.next_id] = TrackedObject(self.next_id, center)
                self.next_id += 1
            return self.objects
        
        object_ids = list(self.objects.keys())
        object_centers = np.array([obj.center for obj in self.objects.values()])
        current_centers = np.array(centers)
        
        D = distance.cdist(object_centers, current_centers)
        row_ind, col_ind = linear_sum_assignment(D)
        
        used_rows = set()
        used_cols = set()
        
        for row, col in zip(row_ind, col_ind):
            if D[row, col] > self.max_distance:
                continue
            
            obj_id = object_ids[row]
            self.objects[obj_id].center = tuple(current_centers[col])
            self.objects[obj_id].disappeared = 0
            used_rows.add(row)
            used_cols.add(col)
        
        for row in range(len(object_ids)):
            if row not in used_rows:
                obj_id = object_ids[row]
                self.objects[obj_id].disappeared += 1
                if self.objects[obj_id].disappeared > self.max_disappeared:
                    del self.objects[obj_id]
        
        for col in range(len(current_centers)):
            if col not in used_cols:
                self.objects[self.next_id] = TrackedObject(self.next_id, tuple(current_centers[col]))
                self.next_id += 1
        
        return self.objects


def main():
    cap = cv2.VideoCapture(0)
    
    if not cap.isOpened():
        print("无法打开摄像头")
        return
    
    tracker = Tracker(max_distance=80, max_disappeared=30)
    
    while True:
        ret, frame = cap.read()
        if not ret:
            break
        
        # 预处理:高斯模糊
        blurred = cv2.GaussianBlur(frame, (11, 11), 0)
        
        # 转换到 HSV
        hsv = cv2.cvtColor(blurred, cv2.COLOR_BGR2HSV)
        
        # 红色掩码
        lower_red1 = np.array([0, 100, 100])
        upper_red1 = np.array([10, 255, 255])
        mask1 = cv2.inRange(hsv, lower_red1, upper_red1)
        
        lower_red2 = np.array([170, 100, 100])
        upper_red2 = np.array([179, 255, 255])
        mask2 = cv2.inRange(hsv, lower_red2, upper_red2)
        
        mask = cv2.bitwise_or(mask1, mask2)
        
        # 形态学处理
        kernel = np.ones((5, 5), np.uint8)
        mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel, iterations=2)
        
        # 轮廓检测
        contours, _ = cv2.findContours(mask.copy(), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
        
        centers = []
        for contour in contours:
            area = cv2.contourArea(contour)
            if area < 500:
                continue
            
            (x, y), radius = cv2.minEnclosingCircle(contour)
            center = (int(x), int(y))
            radius = int(radius)
            
            # 绘制检测结果
            cv2.circle(frame, center, radius, (0, 255, 0), 2)
            cv2.circle(frame, center, 5, (0, 0, 255), -1)
            
            centers.append(center)
        
        # 跟踪更新
        tracked_objects = tracker.update(centers)
        
        # 绘制跟踪结果
        for obj_id, obj in tracked_objects.items():
            cv2.putText(frame, f"ID: {obj_id}", 
                        (obj.center[0] - 20, obj.center[1] - 30),
                        cv2.FONT_HERSHEY_SIMPLEX, 0.6, (255, 0, 0), 2)
        
        # 显示结果
        cv2.imshow("Frame", frame)
        cv2.imshow("Mask", mask)
        
        if cv2.waitKey(1) & 0xFF == ord('q'):
            break
    
    cap.release()
    cv2.destroyAllWindows()


if __name__ == "__main__":
    main()

这个代码可以直接运行。需要注意的是,你需要安装 opencv-python、numpy 和 scipy 这三个库:

pip install opencv-python numpy scipy

十二、参数调优指南

代码写好了,但在实际应用中,你可能需要根据具体场景调整一些参数。这里给大家一个调优的参考:

1. HSV 阈值范围

  • 如果检测不到目标,尝试降低 S 和 V 的下限
  • 如果误检太多,尝试提高 S 和 V 的下限
  • 如果只检测到部分红色,可以适当扩大 H 的范围

2. 最小面积 min_area

  • 根据目标在画面中的大小调整
  • 640x480 分辨率下,500-2000 是常见范围
  • 目标越小,这个值应该越小

3. 最大匹配距离 max_distance

  • 取决于目标移动速度和帧率
  • 快速移动的物体需要更大的值
  • 太大可能导致 ID 混淆,太小可能导致跟丢

4. 最大消失帧数 max_disappeared

  • 取决于帧率和遮挡情况
  • 30fps 视频,30 帧就是 1 秒
  • 遮挡时间长的场景可以适当增大

5. 高斯核大小

  • 噪点多就用大一点的核,比如 (15, 15)
  • 目标细节需要保留就用小一点的核,比如 (7, 7)

十三、常见问题与解决

问题 1:光照变化时检测不稳定

  • 解决:可以尝试自适应阈值,或者先做直方图均衡化
  • 或者采集不同光照下的样本,动态调整 HSV 范围

问题 2:红色背景导致误检

  • 解决:增加形状过滤,或者考虑运动信息
  • 或者用深度相机,先做背景减除

问题 3:目标交叉时 ID 混乱

  • 解决:可以引入更多特征,比如颜色直方图、大小等
  • 或者用更高级的跟踪算法,比如 SORT、DeepSORT

问题 4:部分遮挡时跟丢

  • 解决:增大 max_disappeared 参数
  • 或者加入运动预测(卡尔曼滤波)

十四、进阶方向

这篇文章讲的是一个基础但实用的系统,如果你想继续深入,这里有几个方向可以探索:

1. 卡尔曼滤波预测 在两帧之间用卡尔曼滤波预测目标位置,匹配时用预测位置代替上一帧位置。这样可以处理快速移动和短暂遮挡。

2. DeepSORT 在 SORT 基础上加入表观特征(ReID),用深度学习模型提取目标特征,这样即使目标被完全遮挡后重新出现,也能正确关联。

3. 多颜色检测 这套方法不仅可以检测红色,改一下 HSV 范围就能检测其他颜色。甚至可以同时检测多种颜色,用不同的颜色做不同的标记。

4. 与其他传感器融合 比如结合深度相机的距离信息,结合 IMU 的运动信息,让跟踪更稳健。

总结

我们从颜色空间选择讲起,一步步构建了一个完整的红色物体识别与多目标跟踪系统。核心要点是:

  1. 用 HSV 颜色空间代替 RGB,获得更好的光照鲁棒性
  2. 红色需要检测两个区间,不要忘记 170-179
  3. 形态学处理是去除噪点的关键
  4. 基于距离的匈牙利算法匹配可以实现多目标跟踪
  5. 合理的参数调优是实际应用中不可或缺的

这套方法虽然简单,但在很多实际场景中已经足够好用。希望这篇文章能帮助你快速上手计算机视觉项目。

(全文完,约6800字)