简介:这份资源面向计算机视觉入门与进阶学习者,聚焦视频场景下的小球目标检测与颜色分类任务,适合希望理解传统图像处理流程、又不想从零搭建环境的开发者。包内共3个文件,包含1个Python主程序、1张效果预览图和1段测试视频,压缩包约320KB,体量轻巧,简单配置OpenCV环境即可直接运行。代码由作者独立编写并亲测调试通过,核心运用形状轮廓检测与色彩模型完成小球的定位与颜色判别,输出效果类似YOLO算法检测后的标注框,便于直观对比传统方法与深度学习方案的差异。目前已有1871人学习下载,说明该案例在入门实践中具有一定参考价值。读者可借此掌握视频逐帧读取、轮廓筛选、颜色空间转换与结果可视化等关键环节,并基于现有脚本快速替换素材、调整阈值参数,形成自己的检测小项目。
1. 用 opencv-python 做视频小球及颜色检测:从能跑到跑稳的那条线
很多人第一次用 opencv-python 做视频小球及颜色检测,代码不到五十行就能在屏幕上画出一个框,于是觉得这事已经结束了。真正拿到现场去用,才发现问题全在后面:换个灯光颜色就飘,球滚快一点框就丢,背景里出现一件相近颜色的衣服,检测结果直接跑偏。这个标题要解决的不是「能不能检测到」,而是「在真实视频流里能不能稳定地检测到,并且把颜色判对」。它适合两类人:一类是刚学完 OpenCV 基础、想找一个完整小玩意练手的开发者;另一类是已经写过一版、但被误检和抖动折磨过、想把方案做扎实的工程师。整条链路其实就四步:读视频帧、转到合适的颜色空间、按颜色阈值生成掩膜、在掩膜上找轮廓并判定小球。听起来简单,但每一步都有参数和边界,下面按能复现的顺序拆开讲。
2. 颜色空间与阈值:为什么 HSV 比 RGB 更适合做小球检测
2.1 RGB 做阈值检测的三个硬伤
用 RGB 直接卡阈值,最直观的问题是三个通道耦合太紧。一个红色小球在强光下,R 通道可能到 255,G 和 B 也被环境光抬起来;在阴影里,R 掉到 120,G 和 B 跟着掉。你没法用一组固定的 R、G、B 范围同时覆盖亮部和暗部,因为亮度变化会同时改变三个通道的值,而不是只改一个。第二个硬伤是光照色温。同一颗球,白炽灯下偏黄,日光灯下偏青,RGB 三个通道的比例整体漂移,阈值区间要跟着重调。第三个硬伤是阈值调试没有直觉。你看到球是红色,但红色对应的 R 下限该设 180 还是 200,G 上限该设 80 还是 100,全靠试,试出来的参数换一个场景就废。
HSV 把颜色信息(H 色调)和亮度信息(V 明度)拆开了。色调描述「这是什么颜色」,明度描述「有多亮」,饱和度描述「颜色有多纯」。调阈值时你可以先固定 H 的范围把颜色框住,再用 S 和 V 的范围把过暗、过曝、发灰的像素排除掉。这个拆分让参数有了物理含义,也让同一套 H 范围在不同光照下更容易复用。
2.2 用 opencv-python 把 BGR 帧转成 HSV 并生成掩膜
下面这段代码是整条链路的核心:读一帧、转 HSV、按阈值生成二值掩膜。注意 OpenCV 读进来的默认是 BGR 顺序,不是 RGB,转换时用cv2.COLOR_BGR2HSV。
import cv2 import numpy as np cap = cv2.VideoCapture(0) # 0 表示默认摄像头,也可换成视频文件路径 cap.set(cv2.CAP_PROP_FRAME_WIDTH, 640) cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 480) # 红色在 HSV 里跨了 0 和 180 两端,所以要分两段 lower_red1 = np.array([0, 120, 70]) upper_red1 = np.array([10, 255, 255]) lower_red2 = np.array([170, 120, 70]) upper_red2 = np.array([180, 255, 255]) while True: ret, frame = cap.read() if not ret: break # 高斯模糊压噪点,核必须是奇数 blurred = cv2.GaussianBlur(frame, (5, 5), 0) hsv = cv2.cvtColor(blurred, cv2.COLOR_BGR2HSV) mask1 = cv2.inRange(hsv, lower_red1, upper_red1) 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) mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel) cv2.imshow('mask', mask) cv2.imshow('frame', frame) if cv2.waitKey(1) & 0xFF == ord('q'): break cap.release() cv2.destroyAllWindows()逻辑说明:先模糊再转 HSV,模糊放在前面是因为在 BGR 上做高斯模糊计算量比在 HSV 上小,而且能减少传感器噪点对色调的干扰。inRange对每个像素做区间判断,落在区间内输出 255,否则输出 0,得到的就是二值掩膜。红色分两段是因为 HSV 的 H 通道在 OpenCV 里范围是 0 到 180,红色绕了一圈,0 到 10 和 170 到 180 都是红。
参数说明:lower_red1里的 120 是饱和度下限,低于它的像素被认为是发灰的,不算红色;70 是明度下限,低于它的像素太暗,也不参与判定。这两个值不是固定的,光照弱的时候明度下限要往下调,比如调到 50;光照强、反光严重的时候饱和度下限要往上提,比如提到 150,否则白色高光会被误判成红色。高斯核(5, 5)适合 640x480 的分辨率,分辨率更高时可以用(7, 7),但核越大边缘越糊,小球边界会变钝。
2.3 阈值到底怎么调:一套可复用的标定流程
不要凭感觉拖滑块。我一般用下面这个流程把阈值定下来。第一步,把小球放在实际使用场景里,截一帧保存成图片。第二步,用取色工具在球体上取五个点,记录 H、S、V 值,取 H 的最小值和最大值作为 H 范围的初值,S 和 V 取五个点里的最小值再往下留 20 的余量。第三步,把背景里最接近球色的区域也取五个点,看它们的 H 值是否落在你定的范围内,如果落进去了,说明 H 范围太宽,要收窄。第四步,把阈值代进上面的代码,看掩膜里球体是不是一个完整的实心块,背景是不是全黑。第五步,在视频流里跑一遍,观察球从亮处滚到暗处时掩膜有没有断裂,断了就把 V 下限往下调。
提示:调阈值时把掩膜窗口和原图窗口并排看,比只看检测结果框要快得多,因为你能直接看到哪些背景像素被误判了。
3. 从掩膜到小球:轮廓筛选与圆心定位的工程做法
3.1 轮廓提取与面积筛选
掩膜出来之后,下一步是找轮廓。cv2.findContours在 OpenCV 4.x 里返回两个值:轮廓列表和层级信息。拿到轮廓后不能直接用,因为掩膜里可能有噪点形成的小轮廓,也可能有球体反光造成的断裂轮廓。筛选逻辑是:先按面积过滤掉太小的,再按圆度过滤掉形状不对的。
contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) for cnt in contours: area = cv2.contourArea(cnt) if area < 300: # 面积太小,大概率是噪点 continue perimeter = cv2.arcLength(cnt, True) if perimeter == 0: continue circularity = 4 * np.pi * area / (perimeter * perimeter) if circularity < 0.6: # 圆度太低,不是球 continue (x, y), radius = cv2.minEnclosingCircle(cnt) center = (int(x), int(y)) radius = int(radius) cv2.circle(frame, center, radius, (0, 255, 0), 2) cv2.circle(frame, center, 3, (255, 0, 0), -1)逻辑说明:RETR_EXTERNAL只取最外层轮廓,避免球体内部纹理产生嵌套轮廓。CHAIN_APPROX_SIMPLE压缩轮廓点,省内存。圆度公式4πA/P²在完美圆形时等于 1,越接近 1 越圆。minEnclosingCircle返回能包住轮廓的最小圆的圆心和半径,比boundingRect更适合圆形目标,因为球在画面里旋转时外接矩形会变,但最小外接圆基本不变。
参数说明:面积下限 300 是针对 640x480 分辨率、球直径大约 30 像素的情况。如果你的球更小,比如直径 15 像素,面积下限要降到 80 左右;球更大就往上提。圆度下限 0.6 是一个偏宽松的值,因为运动模糊和掩膜边缘锯齿会把圆度拉低,设到 0.8 以上容易丢检。如果场景里没有其他圆形干扰物,可以降到 0.5 提高召回。
3.2 多帧稳定:用轨迹连续性压掉单帧误检
单帧检测再准,视频里也会偶尔冒出一帧误检。解决办法是利用帧间连续性:小球在相邻帧之间的位置不会跳变太大。我一般维护一个简单的轨迹缓存,记录最近几帧的圆心,如果当前帧检测到的圆心和上一帧的距离超过阈值,就认为这一帧不可信,用上一帧的位置代替或者直接跳过。
prev_center = None max_jump = 80 # 相邻帧圆心最大允许位移,单位像素 # 在检测循环内部 if prev_center is not None: dx = center[0] - prev_center[0] dy = center[1] - prev_center[1] if (dx * dx + dy * dy) ** 0.5 > max_jump: center = prev_center # 跳变太大,沿用上一帧 prev_center = center逻辑说明:max_jump的取值和帧率、球的实际运动速度有关。30 帧每秒、球在画面里每秒移动不超过 200 像素时,相邻帧位移大约 7 像素,设 80 已经很宽松,能容忍偶尔的检测延迟。如果球速很快,这个值要相应放大,但放得太大就失去了过滤误检的作用。
参数说明:这个方案假设画面里只有一个球。如果有多球,需要先做轮廓匹配再各自维护轨迹,复杂度会上一个台阶。单球场景下,这个几行的缓存就能把误检率压下去一大截,属于性价比很高的做法。
3.3 颜色判定:从掩膜反推小球颜色
检测到小球位置之后,颜色判定有两种做法。第一种是直接用你设定的阈值颜色作为结果,因为掩膜就是按那个颜色生成的,这适合已知球色的场景。第二种是从原图里取圆心周围一小块区域,算 HSV 均值,再映射到颜色名称,这适合球色会变的场景。
hsv_roi = hsv[max(0, center[1]-10):center[1]+10, max(0, center[0]-10):center[0]+10] h_mean = np.mean(hsv_roi[:, :, 0]) s_mean = np.mean(hsv_roi[:, :, 1]) v_mean = np.mean(hsv_roi[:, :, 2]) if s_mean < 50 or v_mean < 40: color_name = 'unknown' elif h_mean < 10 or h_mean > 170: color_name = 'red' elif h_mean < 35: color_name = 'yellow' elif h_mean < 85: color_name = 'green' elif h_mean < 130: color_name = 'blue' else: color_name = 'purple'逻辑说明:取圆心周围 20x20 的区域做均值,比取单像素稳,因为单像素可能落在高光或阴影上。先判断饱和度和明度,太低就归为 unknown,避免把灰色或黑色误判成某种颜色。H 的分段是按 OpenCV 的 0 到 180 范围划的,红色跨两端所以单独处理。
参数说明:ROI 大小 20x20 适合半径 15 像素以上的球,球更小就缩小到 10x10,否则 ROI 会超出球体范围,把背景颜色算进来。饱和度下限 50 和明度下限 40 是经验值,实际用的时候对着灰色物体跑一遍,看会不会被误判,会就往上提。
4. 避坑与排查:小球检测翻车的五个真实原因
4.1 掩膜里球体中间有空洞
现象:掩膜显示球体是一个环,中间是黑的,导致轮廓面积偏小甚至断裂。原因:球体表面有高光反光,高光区域饱和度骤降,被饱和度下限过滤掉了。解决:把饱和度下限从 120 降到 80 左右,或者对掩膜先做闭运算再找轮廓,闭运算的核可以设大一点,比如(7, 7)。如果高光特别严重,考虑在镜头前加偏振片,这是硬件层面的解法。
4.2 背景里相近颜色物体被误检
现象:画面里出现一件红色衣服或红色包装盒,检测框同时出现在球和背景物体上。原因:H 范围设得太宽,把相近色调都包进来了。解决:收窄 H 范围,同时提高饱和度下限。另一个办法是加面积上限,球在画面里的面积不会超过某个值,超过就排除。还可以用位置约束,如果球只在画面中间区域活动,就把边缘区域的轮廓直接忽略。
4.3 球快速滚动时检测框闪烁或丢失
现象:球慢慢滚的时候检测稳定,一加速就丢帧。原因:运动模糊导致球体边缘糊掉,掩膜面积缩小,低于面积下限被过滤。解决:降低面积下限,同时把高斯模糊的核从(5, 5)降到(3, 3),减少额外的模糊。如果相机支持,提高快门速度比调参数更有效。另外,前面提到的轨迹缓存能在这时候兜底,丢一两帧不影响整体。
4.4 换一个房间就要重调阈值
现象:在实验室调好的参数,搬到走廊就失效。原因:色温变了,HSV 里的 H 值整体偏移。解决:不要指望一套参数打天下。我的做法是准备两组参数,一组偏暖光,一组偏冷光,启动时用画面里已知的白色区域做一次白平衡校正,或者干脆在代码里加一个按键切换参数组的逻辑。更彻底的做法是用自适应阈值,但这会引入额外复杂度,小球检测这个场景不太值得。
4.5 帧率越跑越低
现象:刚开始跑很流畅,跑几分钟后画面卡顿。原因:每帧都在做全图inRange和形态学操作,分辨率高的时候计算量不小;另外imshow开太多窗口也会拖慢。解决:把分辨率降到 640x480 或更低,检测够用就行。形态学操作的核不要超过(7, 7)。如果只是调试,把掩膜窗口关掉,只显示原图和检测结果。还有一个容易忽略的点:cap.read()返回的帧如果不做处理直接丢弃,缓冲区会堆积,可以设置cap.set(cv2.CAP_PROP_BUFFERSIZE, 1)减少缓冲。
5. 进阶技巧:用颜色检测做多球追踪与性能压榨
单球跑通之后,往上走有两个方向。一个是多球追踪,一个是性能优化。多球追踪的难点不在检测,而在匹配:这一帧检测到三个球,上一帧也有三个球,怎么知道哪个是哪个。简单做法是按圆心距离做最近邻匹配,给每个球维护一个 ID,新出现的球分配新 ID,连续几帧没匹配上的 ID 回收。这个逻辑用字典就能实现,不需要引入复杂的追踪库。
tracks = {} # id -> center next_id = 0 def match_tracks(detections, tracks, max_dist=60): global next_id used = set() for det in detections: best_id, best_dist = None, max_dist for tid, tcenter in tracks.items(): if tid in used: continue d = ((det[0]-tcenter[0])**2 + (det[1]-tcenter[1])**2) ** 0.5 if d < best_dist: best_dist, best_id = d, tid if best_id is not None: tracks[best_id] = det used.add(best_id) else: tracks[next_id] = det next_id += 1 return tracks逻辑说明:对每个检测结果,在已有轨迹里找距离最近且没被占用的,匹配上就更新位置,匹配不上就新建轨迹。max_dist控制匹配半径,太小会导致 ID 频繁切换,太大会把两个靠近的球合并成一条轨迹。实际用的时候,球之间最近距离要大于max_dist才不会串。
性能优化方面,最有效的一招是降低检测频率。不需要每帧都做颜色检测,可以每三帧检测一次,中间帧用轨迹预测补位。预测用简单的匀速模型就够:当前位置加上上一帧的位移。这样 CPU 占用能降一半以上,对树莓派这类设备很关键。另一招是把 ROI 限制在上一帧检测框周围的一小块区域,只在这个区域里做inRange,而不是全图。球运动连续的时候,这个做法能把计算量再压一个数量级。
注意:ROI 限制的前提是球不会突然消失再出现在远处。如果场景里有遮挡导致球消失几帧,ROI 会跟丢,这时候要退回到全图检测重新捕获。
最后说一个我自己的习惯:每次调完参数,把当前场景截一帧存下来,参数写在文件名里。下次换场景翻车的时候,回头看看之前哪些参数在什么光照下work过,比重新试一遍快得多。这个方案从代码量看是个小玩意,但参数和边界的坑一个不少,把上面这些跑一遍,基本就能从「能跑」走到「跑稳」了。希望帮到你。
本文还有配套的精品资源,点击获取