news 2026/9/10 10:48:51

OpenCV实战:基于HSV颜色空间的小球检测与跟踪

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
OpenCV实战:基于HSV颜色空间的小球检测与跟踪

简介:基于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~10170~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。这里SV的下限分别取了 120 和 90,目的是把不太鲜艳的浅色墙、白色反光点排除掉。如果背景里恰好有暗红色的物体,可以调高S_min;如果小球本身颜色发暗,可以调低V_minnp.array里的三个值顺序必须是[H, S, V],很多人第一次写反成[B, G, R],结果掩码全黑或者全白。

2.3 常见颜色的 HSV 参考区间

下面这张表是我在普通室内灯光、白色背景下常用的初始区间,可以直接作为起点,换到实际场景后通常只需要微调SV的上下限。

颜色H_minH_maxS_minS_maxV_minV_max备注
红色01012025590255需要配合 170~180 区间
红色(第二区间)17018012025590255与上一行做按位或
绿色358510025580255荧光绿会偏移到 90 左右
蓝色10013012025580255深蓝色需要降低 V_min
黄色2035120255100255接近橙色时 H 边界要扩
白色0180040200255白色主要靠低饱和识别
黑色01800255060高光黑物体容易漏检

这组数值不是固定公式。实际项目里,同一个颜色的 H 中心值可能因为摄像头色彩偏移差出 5~10,所以定区间前最好对现场画面采样。常见做法是把鼠标点击事件接到cv2.setMouseCallback上,点击画面某个像素后打印它的 H、S、V 值,多采十几个点再统计最小值和最大值。这样确定的区间比背表靠谱得多,尤其是球体有高光时,顶部反光点会让 V 值非常高,但 H 值仍然稳定,不会把整个球切掉。

3. 单帧小球检测:从掩码到轮廓的完整管线

有了掩码之后,下一步不是直接找圆形,而是先把掩码里的离散噪声去掉,再找轮廓,最后用面积和圆度筛选出真正的小球。这个流程在每帧上都跑一遍,就能得到所有小球的位置和尺寸。

3.1 检测流程总览

对每一帧,我一般按下面这个顺序处理:

  1. 缩放图像到固定宽度,降低后续计算量。
  2. 高斯模糊,去掉传感器噪点,避免轮廓边缘过于毛糙。
  3. 从 BGR 转 HSV,生成颜色掩码。
  4. 形态学开运算去掉孤立白点,再闭运算补上小球内部的黑洞。
  5. cv2.findContours找外轮廓。
  6. 按轮廓面积、周长计算圆度,筛掉非圆形物体。
  7. 在原图上绘制矩形框和中心点。

第 2 步到第 6 步每一步都有对应的参数,改一个就可能从“多检”变成“漏检”。下面的表格是每个步骤里最值得先调的参数和它影响的结果。

步骤关键参数典型值参数太小参数太大
缩放目标宽度960检测快但小目标丢失检测慢且噪声放大
高斯模糊卷积核(5, 5)轮廓坑坑洼洼小球边界被抹平
形态学开运算内核尺寸(5, 5) 或 (7, 7)背景噪点残留小球被腐蚀掉
形态学闭运算迭代次数1~2小球中心有黑洞邻近物体粘连
面积筛选min_area / max_area按画面尺寸定小噪点混入漏掉真实小球
圆度筛选circularity0.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_AREAMAX_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,不用改代码里写死的区间。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/10 10:48:33

YOLOv9+DeepSort目标跟踪:从原理到调优的完整指南

简介&#xff1a;这是基于YOLOv9与DeepSORT构建的目标检测与多目标跟踪Python源码项目&#xff0c;面向计算机视觉毕业设计、课程项目或实战学习者&#xff0c;解决将检测与跟踪串联落地的核心问题。压缩包共8个文件&#xff0c;包含Python主脚本、Jupyter Notebook交互教程、Y…

作者头像 李华
网站建设 2026/9/10 10:43:06

Flipper 红外代码批量导入:三步搭起万能遥控器

Flipper 红外代码批量导入&#xff1a;三步搭起万能遥控器 【免费下载链接】Flipper Playground (and dump) of stuff I make or modify for the Flipper Zero 项目地址: https://gitcode.com/GitHub_Trending/fl/Flipper 客厅五台设备、五个遥控器&#xff0c;出门却只…

作者头像 李华
网站建设 2026/9/10 10:41:40

CANN/ge获取推理上下文API

GetInferenceContext 【免费下载链接】ge GE&#xff08;Graph Engine&#xff09;是面向昇腾的图编译器和执行器&#xff0c;提供了计算图优化、多流并行、内存复用和模型下沉等技术手段&#xff0c;加速模型执行效率&#xff0c;减少模型内存占用。 GE 提供对 PyTorch、Tenso…

作者头像 李华