简介:基于opencv-python的视频小球及颜色检测资源,面向人工智能与计算机视觉初学者,解决运动目标检测与颜色分类问题。压缩包共3个文件,包含完整的Python脚本、mp4测试视频与png效果截图,通过轮廓检测与色彩模型实现对视频中小球的实时追踪与颜色标注,标注框效果类似YOLOv算法检测,便于理解传统视觉处理流程。整体仅320KB,轻量易用,配置基础OpenCV环境即可运行调试,适合课程设计或入门实践。目前已有1871人学习,代码亲测通过,可帮助读者掌握图像预处理、轮廓提取与HSV颜色空间应用,并支持二次修改拓展到其他运动目标检测场景。
1. 从一帧到整段视频:小球颜色检测到底在解决什么
视频里的小球检测,看着像是入门练手,实际上把颜色空间、阈值分割、形态学处理、轮廓分析和帧间跟踪串在了一起。生产线上的彩色工件分拣、物理实验里的运动轨迹捕捉,第一步都是同一件事:在当前帧里找出哪些像素属于目标小球,哪些是背景。opencv-python 给的工具链足够直接,但直接调cv2.inRange往往会发现光照稍微变一点,颜色区间就失效了。我通常从 HSV 区间的标定开始,再做逐帧检测、去抖和跟踪,最后用一个可交互的调阈值小工具把参数固化下来。这篇文章就按这个顺序,把每一步怎么做、参数怎么改、失败时看哪儿讲清楚。
2. opencv-python 的颜色检测:HSV 区间怎么定才不漂
颜色检测最核心的问题不是“找到颜色”,而是“在光照变化的视频里稳定地找到同一种颜色”。BGR 三个通道的数值会同时受亮度影响,同一个红色小球,亮一点可能从(150, 30, 30)变成(90, 15, 15),阈值没法固定。HSV 把色相 H、饱和度 S、明度 V 拆开,其中 H 和 S 对光照相对稳定,所以做颜色检测几乎都从 BGR 转到 HSV 开始。
2.1 为什么 HSV 比 BGR 抗光照变化
看一个具体例子:一个红色小球在顺光和逆光下,用手机拍两帧,BGR 的像素值可能差出一倍,但 H 值基本落在0~10或170~180这两个区间里。这是因为 H 表示的是色调本身,不携带亮度信息;饱和度 S 表示颜色鲜艳程度,也会受光线强度影响但比 BGR 的耦合程度低得多;V 才是真正会被光照剧烈改变的通道。
所以常规做法是先cv2.cvtColor(frame, cv2.COLOR_BGR2HSV),然后用cv2.inRange对 H、S、V 三个通道分别给下限和上限。需要特别注意 OpenCV 里 H 的范围不是常见的0~360,而是压缩到了0~179,写代码时不要把红色区间写成[0, 360],否则整个画面都会变成掩码。
2.2 用 cvtColor 和 inRange 提取目标颜色
下面这段代码从视频里读取第一帧,然后提取红色区域。红色和其他颜色不太一样,它在 HSV 色环上跨越了 H=0 和 H=180 两个边界,所以要写两个区间再做按位或。
import cv2 import numpy as np cap = cv2.VideoCapture("balls.mp4") ok, frame = cap.read() if ok: hsv = cv2.cvtColor(frame, cv2.COLOR_BGR2HSV) lower_red_1 = np.array([0, 120, 90]) upper_red_1 = np.array([10, 255, 255]) lower_red_2 = np.array([170, 120, 90]) upper_red_2 = np.array([180, 255, 255]) mask1 = cv2.inRange(hsv, lower_red_1, upper_red_1) mask2 = cv2.inRange(hsv, lower_red_2, upper_red_2) mask = cv2.bitwise_or(mask1, mask2) cv2.imshow("frame", frame) cv2.imshow("red_mask", mask) cv2.waitKey(0) cap.release() cv2.destroyAllWindows()cv2.inRange的语义是:源图像像素在低阈值和高阈值之间(包含边界)时掩码置 255,否则置 0。这里S和V的下限分别取了 120 和 90,目的是把不太鲜艳的浅色墙、白色反光点排除掉。如果背景里恰好有暗红色的物体,可以调高S_min;如果小球本身颜色发暗,可以调低V_min。np.array里的三个值顺序必须是[H, S, V],很多人第一次写反成[B, G, R],结果掩码全黑或者全白。
2.3 常见颜色的 HSV 参考区间
下面这张表是我在普通室内灯光、白色背景下常用的初始区间,可以直接作为起点,换到实际场景后通常只需要微调S和V的上下限。
| 颜色 | H_min | H_max | S_min | S_max | V_min | V_max | 备注 |
|---|---|---|---|---|---|---|---|
| 红色 | 0 | 10 | 120 | 255 | 90 | 255 | 需要配合 170~180 区间 |
| 红色(第二区间) | 170 | 180 | 120 | 255 | 90 | 255 | 与上一行做按位或 |
| 绿色 | 35 | 85 | 100 | 255 | 80 | 255 | 荧光绿会偏移到 90 左右 |
| 蓝色 | 100 | 130 | 120 | 255 | 80 | 255 | 深蓝色需要降低 V_min |
| 黄色 | 20 | 35 | 120 | 255 | 100 | 255 | 接近橙色时 H 边界要扩 |
| 白色 | 0 | 180 | 0 | 40 | 200 | 255 | 白色主要靠低饱和识别 |
| 黑色 | 0 | 180 | 0 | 255 | 0 | 60 | 高光黑物体容易漏检 |
这组数值不是固定公式。实际项目里,同一个颜色的 H 中心值可能因为摄像头色彩偏移差出 5~10,所以定区间前最好对现场画面采样。常见做法是把鼠标点击事件接到cv2.setMouseCallback上,点击画面某个像素后打印它的 H、S、V 值,多采十几个点再统计最小值和最大值。这样确定的区间比背表靠谱得多,尤其是球体有高光时,顶部反光点会让 V 值非常高,但 H 值仍然稳定,不会把整个球切掉。
3. 单帧小球检测:从掩码到轮廓的完整管线
有了掩码之后,下一步不是直接找圆形,而是先把掩码里的离散噪声去掉,再找轮廓,最后用面积和圆度筛选出真正的小球。这个流程在每帧上都跑一遍,就能得到所有小球的位置和尺寸。
3.1 检测流程总览
对每一帧,我一般按下面这个顺序处理:
- 缩放图像到固定宽度,降低后续计算量。
- 高斯模糊,去掉传感器噪点,避免轮廓边缘过于毛糙。
- 从 BGR 转 HSV,生成颜色掩码。
- 形态学开运算去掉孤立白点,再闭运算补上小球内部的黑洞。
cv2.findContours找外轮廓。- 按轮廓面积、周长计算圆度,筛掉非圆形物体。
- 在原图上绘制矩形框和中心点。
第 2 步到第 6 步每一步都有对应的参数,改一个就可能从“多检”变成“漏检”。下面的表格是每个步骤里最值得先调的参数和它影响的结果。
| 步骤 | 关键参数 | 典型值 | 参数太小 | 参数太大 |
|---|---|---|---|---|
| 缩放 | 目标宽度 | 960 | 检测快但小目标丢失 | 检测慢且噪声放大 |
| 高斯模糊 | 卷积核 | (5, 5) | 轮廓坑坑洼洼 | 小球边界被抹平 |
| 形态学开运算 | 内核尺寸 | (5, 5) 或 (7, 7) | 背景噪点残留 | 小球被腐蚀掉 |
| 形态学闭运算 | 迭代次数 | 1~2 | 小球中心有黑洞 | 邻近物体粘连 |
| 面积筛选 | min_area / max_area | 按画面尺寸定 | 小噪点混入 | 漏掉真实小球 |
| 圆度筛选 | circularity | 0.6~0.8 | 矩形也被误判 | 小球轻微形变就丢 |
3.2 最小可运行的检测代码
下面的代码完整实现了上面这套流程,可以直接保存成脚本跑视频。我故意把变量名写得很直白,方便边跑边看效果。
import cv2 import numpy as np LOWER_RED_1 = np.array([0, 120, 90]) UPPER_RED_1 = np.array([10, 255, 255]) LOWER_RED_2 = np.array([170, 120, 90]) UPPER_RED_2 = np.array([180, 255, 255]) MIN_AREA = 100 MAX_AREA = 20000 MIN_CIRCULARITY = 0.7 cap = cv2.VideoCapture("balls.mp4") while cap.isOpened(): ret, frame = cap.read() if not ret: break frame = cv2.resize(frame, (960, 540)) hsv = cv2.cvtColor(frame, cv2.COLOR_BGR2HSV) mask1 = cv2.inRange(hsv, LOWER_RED_1, UPPER_RED_1) mask2 = cv2.inRange(hsv, LOWER_RED_2, UPPER_RED_2) mask = cv2.bitwise_or(mask1, mask2) kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (7, 7)) mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel, iterations=1) mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel, iterations=2) contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) for cnt in contours: area = cv2.contourArea(cnt) if area < MIN_AREA or area > MAX_AREA: continue perimeter = cv2.arcLength(cnt, True) if perimeter == 0: continue circularity = 4 * np.pi * area / (perimeter * perimeter) if circularity < MIN_CIRCULARITY: continue x, y, w, h = cv2.boundingRect(cnt) cv2.rectangle(frame, (x, y), (x + w, y + h), (0, 255, 0), 2) center_x = x + w // 2 center_y = y + h // 2 cv2.circle(frame, (center_x, center_y), 3, (255, 0, 0), -1) cv2.putText(frame, f"area={int(area)}", (x, max(y - 10, 0)), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (255, 255, 255), 2) cv2.imshow("mask", mask) cv2.imshow("result", frame) if cv2.waitKey(30) & 0xFF == ord("q"): break cap.release() cv2.destroyAllWindows()代码里的circularity是圆度,公式是4 * pi * area / perimeter^2,正圆的圆度是 1,正方形约 0.79,一条细线的值接近 0。所以这里设0.7可以把大部分矩形、三角形和其他杂形挡在门外。MIN_AREA和MAX_AREA需要按视频画面大小调整,如果是在 1080p 全画幅里找半径为 30 像素的小球,面积大约在 2800 像素平方;如果视频里小球只占十几个像素,MIN_AREA要降到 30 以下。跑一遍这段代码后重点看两件事:掩码里是否全是小球,以及圆度阈值有没有把半遮挡的球切掉。
4. 视频中的稳定性:帧间匹配与轻量跟踪
单帧检测跑通后,你会发现视频播放到某个位置时,检测框会忽然消失,下一帧又出现,或者从红色小球跳到背景里的红色广告牌上。原因主要是三方面:运动模糊让轮廓形状偏离圆形,颜色区间在明暗突变时失效,以及两个相近颜色物体同时出现时没有区分身份。解决思路是在逐帧检测的结果上做一次轻量跟踪。
4.1 单帧检测在视频里的典型问题
第一个问题是高频抖动:球运动过快时,waitKey(30)的间隔里位置变化大,模糊导致圆度下降到阈值以下,这一帧就被过滤掉。第二个问题是跳变:两个红色物体都在画面内,上一帧检测框还在左侧球上,这一帧左侧球被遮挡,轮廓没了,右侧区域却有一个相似的红色圆块,于是框跳了过去。第三类是拖尾和反光:小球边缘的暗带会让轮廓不是平滑圆,闭运算未能完全填充时,轮廓内会带一个缺口。
如果直接把每帧的检测结果画出来,人眼只能看到“好像不太稳”,但没法判断问题出在颜色分割还是后续筛选。我建议先在每帧上叠加一个小球运动轨迹,再看轨迹曲线的形状:如果轨迹点突然偏移半个画面宽度,说明跟踪跳变;如果轨迹点周期性缺失,说明掩码或轮廓筛选在部分帧失效。
4.2 用质心最近邻匹配稳住小球 ID
简单好用的做法是:把每帧检测到的轮廓中心作为候选点,与上一帧确认的位置做最近邻匹配。距离小于设定阈值就认为还是同一个球,并更新位置;距离过大就当成新目标。同时维护一个最近几帧的位置队列,用均值做输出,减少中心点抖动。
from collections import deque import numpy as np class BallTracker: def __init__(self, max_dist=60, smooth_frames=5): self.max_dist = max_dist self.previous_position = None self.history = deque(maxlen=smooth_frames) def update(self, centers): if not centers: self.previous_position = None return None if self.previous_position is None: best_idx = 0 else: distances = [ np.linalg.norm(np.array(p) - np.array(self.previous_position)) for p in centers ] best_idx = int(np.argmin(distances)) if distances[best_idx] > self.max_dist: return None self.previous_position = centers[best_idx] self.history.append(self.previous_position) smoothed = np.mean(self.history, axis=0).astype(int) return tuple(smoothed)这段代码每次输入当前帧的所有检测中心,输出当前帧被跟踪小球的平滑位置。max_dist控制了相邻两帧之间小球移动的最大像素距离,这个值要和画面尺寸、视频帧率搭配:25fps 下小球每帧移动 15 像素,max_dist设为 50 足够;如果视频里小球移动很快,max_dist要提高到 120,否则球稍微加速就会被判定为新目标。smooth_frames设 5 表示取最近 5 帧的平均值,输出位置比单帧检测平滑很多,但也会带来约 2 帧延迟,实时控制场景里要权衡。
4.3 限制搜索区域和跳帧控制
进一步解决跳变的方法是限制搜索区域。上一帧已经确认球在(x, y),下一帧大概率在它周围一个矩形邻域内,所以可以把检测范围从整帧缩小到该点附近的一块 ROI。配合max_dist,既能减少其他红色物体的干扰,也能大幅降低处理耗时。
ROI 实现不复杂,在读取帧后做裁剪即可:
roi_size = 120 if tracker.previous_position is not None: cx, cy = tracker.previous_position x1 = max(0, cx - roi_size) y1 = max(0, cy - roi_size) x2 = min(frame.shape[1], cx + roi_size) y2 = min(frame.shape[0], cy + roi_size) roi = frame[y1:y2, x1:x2] else: roi = frame对roi跑颜色检测和轮廓筛选,再把得到的中心点加上(x1, y1)偏移,就还原到原图坐标。注意如果第一帧没能检测到小球,或者球突然跑出 ROI,就会出现持续丢失。所以当在 ROI 内连续 N 帧没有检测结果时,要回退到整帧检测重新初始化。这个N一般设 3~5。
4.4 常见跟踪方案对比
| 方案 | 实现难度 | 对颜色检测的依赖 | 能处理遮挡 | 适用场景 |
|---|---|---|---|---|
| 纯单帧检测 | 低 | 高 | 否 | 画面静止、小球间隔大 |
| 质心最近邻匹配 | 低 | 高 | 短暂遮挡恢复 | 移动较慢、单目标 |
| 卡尔曼滤波 | 中 | 高 | 短时预测补点 | 匀速运动、实时追踪 |
| OpenCV 内置 Tracker | 中 | 低 | 较好 | 需要初始化目标但丢失颜色语义 |
如果项目只需要检测“有没有某种颜色的小球”,用纯单帧检测就够了。但如果要持续输出某个小球的轨迹,我一般把质心匹配作为基线,等遇到快速运动时再换成卡尔曼滤波。卡尔曼滤波的好处是能在检测缺失的几帧里用运动模型预测位置,但参数调起来比质心匹配复杂,而且对突然变向的适应性不好。视频分析场景里,先用匹配稳住坐标,再逐步加预测,是最稳的落地路径。
5. 用一个 trackbar 脚本把 HSV 参数调准
前面给的红色区间只是参考,换一个场地后,光照和背景都会变。常见的参数调试方式是修改代码里的np.array再重新跑,但每改一次就要重新播放一次视频,效率很低。我习惯先做一个带 trackbar 的 HSV 调试窗口,滑动滑块时实时看掩码,调到满意后再把参数写进配置。
下面这段代码创建 6 个 trackbar,分别控制 H、S、V 的上下限,视频画面和掩码会同步更新。
import cv2 import numpy as np win_name = "hsv_adjust" cv2.namedWindow(win_name) cv2.createTrackbar("H_min", win_name, 0, 179, lambda x: None) cv2.createTrackbar("H_max", win_name, 10, 179, lambda x: None) cv2.createTrackbar("S_min", win_name, 120, 255, lambda x: None) cv2.createTrackbar("S_max", win_name, 255, 255, lambda x: None) cv2.createTrackbar("V_min", win_name, 90, 255, lambda x: None) cv2.createTrackbar("V_max", win_name, 255, 255, lambda x: None) cap = cv2.VideoCapture("balls.mp4") while cap.isOpened(): ret, frame = cap.read() if not ret: cap.set(cv2.CAP_PROP_POS_FRAMES, 0) continue hsv = cv2.cvtColor(frame, cv2.COLOR_BGR2HSV) h_min = cv2.getTrackbarPos("H_min", win_name) h_max = cv2.getTrackbarPos("H_max", win_name) s_min = cv2.getTrackbarPos("S_min", win_name) s_max = cv2.getTrackbarPos("S_max", win_name) v_min = cv2.getTrackbarPos("V_min", win_name) v_max = cv2.getTrackbarPos("V_max", win_name) lower = np.array([h_min, s_min, v_min]) upper = np.array([h_max, s_max, v_max]) mask = cv2.inRange(hsv, lower, upper) cv2.imshow(win_name, mask) if cv2.waitKey(30) & 0xFF == ord("q"): break cap.release() cv2.destroyAllWindows()调参时先固定 S 和 V,只动 H 的范围,直到掩码只覆盖小球;再慢慢降低 S_min,把背景里那些不够鲜艳但色相相近的像素剔除;最后抬高 V_min 去掉阴影中的暗色区域。验证是否调准,不要只看单帧,要跑完整个视频统计每帧检测到的小球数量,然后对照画面数出的真实数量。更严谨的做法是录一段包含小球完整运动路径的测试视频,人工标注几帧的真实中心点,和检测中心点算平均像素误差,误差小于小球半径的 10% 就算可用。把最终调好的 H、S、V 区间写进一个 yaml 或 json 文件,检测脚本启动时读取,这样换场景后只需要重新拉一遍 trackbar,不用改代码里写死的区间。
本文还有配套的精品资源,点击获取