
颜色跟踪这件事说穿了就是让程序在一段视频里持续回答一个问题目标那个颜色的东西这一帧跑到哪儿去了。听起来简单真做起来你会发现坑一个接一个——光照一变颜色就飘、背景里冒出一块相似色就丢目标、框忽大忽小像在抽搐。我前后在几个小项目里反复折腾过这套东西从最早的逐帧阈值硬调到后来把整条链路拆开逐段优化踩的坑足够写一篇长文。这篇就把我实际在用的思路和代码摊开讲从颜色空间的选择、阈值的确定、噪声的清理到轮廓筛选、目标锁定和平滑输出每一段都说明白为什么这么做以及不做会出什么问题。适合已经会用Python跑起OpenCV、想把这套东西真正用起来的人也适合刚接触计算机视觉、想找一个能快速看到效果又不太玄乎的切入点的人。整篇的代码都可以直接跑参数我会给出调节的方向而不是死值因为不同摄像头、不同光照下死值是没意义的。1. 先把颜色空间这件事定下来很多人上手第一反应是拿RGB三个通道分别卡阈值写出来大概是if r100 and g80 and b80这种。能跑但你很快会崩溃因为RGB三个分量是强耦合的你把灯调亮一点三个值一起往上走你把摄像头白平衡自动调一下整片颜色集体偏移。换句话说RGB描述的是设备看到的电压不是人眼感知的颜色。做颜色跟踪第一步就该把图像转到对光照相对不敏感、且能把颜色和亮度拆开的空间里去。1.1 为什么HSV是默认答案HSV把颜色拆成三个维度H是色相也就是这是什么颜色用角度表示0到180OpenCV里为了塞进8位整数把360度砍了一半S是饱和度表示颜色有多纯V是明度表示有多亮。这个拆分的价值在于你关心的这是红色还是绿色完全由H决定而光照强弱主要影响V颜色浓淡主要影响S。于是你可以对H卡一个比较窄的区间锁定颜色种类对S和V放宽一些容忍光照变化。这就是为什么几乎所有颜色跟踪的教程都用HSV——不是它多先进是它的维度语义和我们的需求天然对齐。我实际用下来H通道的稳定性远好于RGB的任何组合。同一块红色物体在室内暖光和窗边冷光下RGB值能差出一大截但H值基本稳定在一个不大的范围内。这就是我们要的红利。1.2 转换代码与一个容易忽略的细节转换本身一行就够import cv2 import numpy as np cap cv2.VideoCapture(0) while True: ret, frame cap.read() if not ret: break hsv cv2.cvtColor(frame, cv2.COLOR_BGR2HSV) # 后续处理注意COLOR_BGR2HSV而不是COLOR_RGB2HSV。OpenCV读进来的默认是BGR顺序你要是按RGB去转H通道会整体错位表现为你明明框的是红色程序却认成了蓝色系。这个错误我见过不止一个人犯而且因为画面还能出东西排查起来很费时间。判断方法很简单转换后把H通道单独显示出来看看红色物体在H通道里应该呈现一个明显的高值或低值区域红色跨越0/180边界这点后面细说。1.3 红色为什么是个特殊角色大部分颜色在H轴上是一段连续区间比如绿色大概在35到85之间蓝色在100到130之间你卡一个lower和upper就完事。但红色尴尬的地方在于它横跨了H轴的0和180两端——纯红在0附近偏紫的红在170以上。如果你只卡[0, 100, 100]到[10, 255, 255]那偏紫的那部分红就漏了。正确做法是做两个掩膜再合并mask1 cv2.inRange(hsv, np.array([0, 100, 100]), np.array([10, 255, 255])) mask2 cv2.inRange(hsv, np.array([170, 100, 100]), np.array([180, 255, 255])) mask cv2.bitwise_or(mask1, mask2)这个双区间合并的思路凡是色相跨越0边界的颜色都得用。判断你的目标色是否跨界看它在标准色环上的位置就行红色系基本都逃不掉。2. 阈值不是猜出来的是量出来的新手最常见的做法是网上抄一组lower/upper跑起来发现要么框不住要么满屏都是。原因很简单别人那组值是在他的摄像头、他的光照、他的物体上量出来的跟你没有可比性。阈值必须针对你的实际场景去测而测的方法决定了你后面调试的效率。2.1 用滑动条现场标定我习惯写一个带Trackbar的标定脚本六个滑动条分别控制H、S、V的上下界实时看掩膜效果。这比反复改代码重启快一个数量级def nothing(x): pass cv2.namedWindow(track) cv2.createTrackbar(Hl, track, 0, 180, nothing) cv2.createTrackbar(Hh, track, 180, 180, nothing) cv2.createTrackbar(Sl, track, 0, 255, nothing) cv2.createTrackbar(Sh, track, 255, 255, nothing) cv2.createTrackbar(Vl, track, 0, 255, nothing) cv2.createTrackbar(Vh, track, 255, 255, nothing) while True: ret, frame cap.read() hsv cv2.cvtColor(frame, cv2.COLOR_BGR2HSV) lo np.array([cv2.getTrackbarPos(Hl,track), cv2.getTrackbarPos(Sl,track), cv2.getTrackbarPos(Vl,track)]) hi np.array([cv2.getTrackbarPos(Hh,track), cv2.getTrackbarPos(Sh,track), cv2.getTrackbarPos(Vh,track)]) mask cv2.inRange(hsv, lo, hi) cv2.imshow(mask, mask) cv2.imshow(frame, frame) if cv2.waitKey(1) 0xFF 27: break标定的顺序有讲究先把S和V的下界拉到很低、上界拉满只调H的上下界让目标颜色在掩膜里亮起来、背景尽量暗下去H定住之后再逐步抬高S下界去掉灰白色的干扰抬高V下界去掉暗部噪声。这个顺序能让你每一步只面对一个变量不至于六个条一起动最后不知道哪个起了作用。2.2 一个反直觉的经验宁窄勿宽很多人怕漏检把区间开得很宽结果背景里一堆相近色全被框进来后面靠轮廓筛选去救越救越乱。我的经验是H区间宁可窄一点先把最纯的那部分目标锁死漏掉边缘一点没关系因为后面还有形态学膨胀可以把目标补回来。反过来区间开宽了引入的假阳性靠算法是很难干净剔除的——你没法从形状上区分目标和长得像目标的干扰物。所以标定的原则是在目标主体能稳定亮起来的前提下H区间取尽可能窄。2.3 记录不同光照下的阈值漂移标定完不是就完事了。我一般会在一天里不同时段、开灯关灯、拉窗帘几个状态下各测一次把H的中心值记下来看它漂多少。如果漂动在正负5以内那固定阈值够用如果漂动超过10说明你的场景光照变化剧烈固定阈值迟早翻车这时候要么加自动白平衡锁定要么考虑后面第5节讲的动态调整思路。这个测量花不了十分钟但能帮你提前判断方案要不要升级避免上线后才发现问题。3. 掩膜出来之后脏东西得清干净inRange出来的掩膜从来不是干净的。传感器噪声、压缩伪影、背景里的杂色都会在掩膜上留下零散的白点。你要是直接拿这个掩膜去找轮廓会得到几十上百个小轮廓程序根本不知道哪个是目标。清理这一步不能省而且顺序有讲究。3.1 开运算去噪、闭运算补洞形态学操作里开运算是先腐蚀后膨胀作用是去掉小的白色噪点闭运算是先膨胀后腐蚀作用是填掉目标内部的小黑洞。两个配合用kernel np.ones((5, 5), np.uint8) mask cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel) mask cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel)核的大小要跟你的目标尺寸匹配。目标在画面里占一两百像素5x5的核合适目标很小只有几十像素核就得缩到3x3否则开运算直接把目标本身腐蚀没了。我吃过这个亏一个远处的小球用7x7的核一做开运算掩膜上干干净净——目标也被当成噪声清掉了。判断核大小是否合适的土办法是看处理前后目标区域的面积变化掉太多就是核大了。3.2 高斯模糊放在哪一步有人喜欢在转HSV之前先对原图做高斯模糊有人喜欢在掩膜之后做。我的做法是在inRange之前对HSV图做一次轻度模糊hsv cv2.GaussianBlur(hsv, (5, 5), 0)原因是模糊能平滑掉单个像素的突变让inRange的边界判断更稳减少椒盐噪声的产生。放在掩膜之后做模糊意义不大因为掩膜是二值的模糊完还得再阈值化一次多此一举。核用5x5、sigma让OpenCV自己算就行别开太大大了会把小目标的边界糊掉。3.3 别急着上高级滤波我见过有人一上来就用双边滤波、均值漂移这些觉得高级。实际上对于颜色跟踪这种场景简单的高斯加形态学已经够用高级滤波带来的计算开销在实时视频里是实打实的帧率损失。除非你的场景噪声特别顽固比如低照度摄像头否则没必要。工程上够用就好把算力留给后面的逻辑。4. 从一堆轮廓里挑出真正的目标清理完的掩膜上可能还剩几个候选区域接下来要从中选出最可能是目标的那一个。这一步的筛选策略直接决定了跟踪的稳定性也是最能体现经验的地方。4.1 面积筛选是第一道闸先按轮廓面积过滤太小的当噪声扔掉太大的可能是整片背景误判也扔掉contours, _ cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) candidates [c for c in contours if 500 cv2.contourArea(c) 50000]这两个数字要根据你的实际画面分辨率调。640x480下一个手掌大的目标面积大概在几千到一万多所以下界设500能滤掉大部分噪点上界设50000能挡掉整片误判。注意RETR_EXTERNAL只取最外层轮廓因为我们要的是目标的外接框内部空洞不需要。4.2 用宽高比排除形状不对的干扰颜色筛选没法区分红色的球和红色的方形贴纸但形状可以。算每个候选轮廓的外接矩形宽高比把明显不符合目标形状的剔掉def filter_by_ratio(candidates, target_ratio1.0, tol0.5): result [] for c in candidates: x, y, w, h cv2.boundingRect(c) if h 0: continue ratio w / h if abs(ratio - target_ratio) tol: result.append(c) return result目标是个球target_ratio就设1.0容差0.5意味着宽高比在0.5到1.5之间都接受。这个筛选对付背景里有一块同色的长条形物体特别有效。但要注意目标被部分遮挡时外接框的宽高比会变容差设太紧会把真目标也筛掉所以tol别小于0.3。4.3 多目标时怎么选面积最大还是离上一帧最近如果筛完还剩多个候选得有个取舍规则。两种常见策略选面积最大的或者选离上一帧目标位置最近的。前者简单适合画面里目标本来就是最大同色物体的场景后者更稳适合有干扰物但目标运动连续的场景。我一般用后者因为它利用了时间连续性这个免费的信息def pick_nearest(candidates, last_center): if last_center is None: return max(candidates, keycv2.contourArea) best, best_dist None, float(inf) for c in candidates: M cv2.moments(c) if M[m00] 0: continue cx, cy M[m10]/M[m00], M[m01]/M[m00] d (cx-last_center[0])**2 (cy-last_center[1])**2 if d best_dist: best, best_dist c, d return best第一帧没有历史位置就退回面积最大策略。这个有历史用历史、没历史用启发式的模式在跟踪类任务里很通用。4.4 质心计算的一个坑用cv2.moments算质心时如果轮廓面积m00为0会除零。虽然面积筛选已经挡掉了大部分但边界情况还是可能漏进来所以上面代码里加了if M[m00] 0: continue。这种防御性判断在实时循环里不能省一次除零异常就能让整个程序崩掉而你在调试时未必能立刻定位到是哪个轮廓惹的祸。5. 让框稳下来平滑与丢失处理到这一步你每帧都能得到一个目标框了但直接画出来会看到框在抖——因为掩膜的边缘每帧都在微小变化质心和尺寸跟着跳。直接用这个框去做后续控制比如让摄像头云台跟随机械结构会跟着抖。所以输出前必须做平滑同时要处理目标丢失的情况。5.1 指数平滑比均值滤波更适合实时滑动平均需要缓存N帧有延迟指数平滑只需要记住上一帧的值一行搞定alpha 0.6 if smooth_center is None: smooth_center (cx, cy) else: smooth_center (alpha*cx (1-alpha)*smooth_center[0], alpha*cy (1-alpha)*smooth_center[1])alpha越大越跟手但越抖越小越平滑但越滞后。0.5到0.7之间是个常用区间具体看你的目标运动速度快速运动的目标alpha取大些慢速的取小些。这个参数没有理论最优只能实测调。我一般先在慢速场景调到不抖再拿到快速场景看滞后能不能接受两头折中。5.2 目标丢失不能立刻清零如果某一帧没找到候选轮廓不代表目标真的消失了可能只是被短暂遮挡或者掩膜恰好断了一下。立刻把框清掉会导致画面闪烁。我的做法是设一个丢失计数器连续N帧找不到才判定丢失if best is None: lost_count 1 if lost_count 10: smooth_center None last_center None else: lost_count 0 # 正常更新10帧在30fps下大概是三分之一秒足够扛过大部分瞬时遮挡。这个阈值也别设太大太大了目标真丢了程序还在原地画框看起来像卡死。5.3 框的尺寸也要平滑很多人只平滑了中心点忘了宽高。结果中心稳了框的大小还在跳。宽高同样用指数平滑而且宽高变化通常比中心慢alpha可以取小一点比如0.4让尺寸更稳。画框的时候用平滑后的中心加减平滑后的半宽高出来的框就顺眼了。6. 完整链路串起来与实测调参顺序把前面各段拼成一个完整脚本顺便说下调参的推荐顺序这个顺序能帮你少走弯路。6.1 完整可运行代码import cv2 import numpy as np cap cv2.VideoCapture(0) kernel np.ones((5, 5), np.uint8) last_center None smooth_center None smooth_size None lost_count 0 alpha_pos, alpha_size 0.6, 0.4 while True: ret, frame cap.read() if not ret: break hsv cv2.cvtColor(frame, cv2.COLOR_BGR2HSV) hsv cv2.GaussianBlur(hsv, (5, 5), 0) mask1 cv2.inRange(hsv, np.array([0, 100, 100]), np.array([10, 255, 255])) mask2 cv2.inRange(hsv, np.array([170, 100, 100]), np.array([180, 255, 255])) mask cv2.bitwise_or(mask1, mask2) mask cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel) mask cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel) contours, _ cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) candidates [c for c in contours if 500 cv2.contourArea(c) 50000] best None if candidates: if last_center is None: best max(candidates, keycv2.contourArea) else: best_dist float(inf) for c in candidates: M cv2.moments(c) if M[m00] 0: continue cx, cy M[m10]/M[m00], M[m01]/M[m00] d (cx-last_center[0])**2 (cy-last_center[1])**2 if d best_dist: best, best_dist c, d if best is None: lost_count 1 if lost_count 10: smooth_center None smooth_size None last_center None else: lost_count 0 x, y, w, h cv2.boundingRect(best) cx, cy x w/2, y h/2 last_center (cx, cy) if smooth_center is None: smooth_center (cx, cy) smooth_size (w, h) else: smooth_center (alpha_pos*cx (1-alpha_pos)*smooth_center[0], alpha_pos*cy (1-alpha_pos)*smooth_center[1]) smooth_size (alpha_size*w (1-alpha_size)*smooth_size[0], alpha_size*h (1-alpha_size)*smooth_size[1]) if smooth_center is not None: sw, sh smooth_size x1 int(smooth_center[0] - sw/2) y1 int(smooth_center[1] - sh/2) cv2.rectangle(frame, (x1, y1), (x1int(sw), y1int(sh)), (0, 255, 0), 2) cv2.imshow(tracking, frame) cv2.imshow(mask, mask) if cv2.waitKey(1) 0xFF 27: break cap.release() cv2.destroyAllWindows()6.2 调参顺序从掩膜到平滑逐层往下调参千万别一上来就动平滑参数因为平滑是在掩盖问题而不是解决问题。正确顺序是先看掩膜掩膜干净了再往下走。具体是第一步调H区间让目标亮、背景暗第二步调S、V下界去灰去暗第三步看形态学核大小目标别被腐蚀没第四步看面积筛选上下界候选只剩目标第五步才动alpha调平滑。每一层调好再进下一层这样出问题时你能确定是哪一层的问题。反过来如果一开始就调alpha掩膜里一堆噪声你把alpha调到0.1框是稳了但那是稳在一个错误的位置上毫无意义。6.3 帧率不够时先砍哪里如果跑起来帧率低优化顺序是先降分辨率640x480足够颜色跟踪用1080p纯属浪费再砍高斯模糊或者把核从5x5降到3x3最后才考虑简化形态学。别去优化findContours那通常不是瓶颈。实测下来分辨率从1080p降到480p帧率能翻两三倍这是性价比最高的一刀。7. 几个真实场景里才会暴露的问题前面讲的都是标准流程但实际用起来有些问题只有真跑起来才会遇到这里挑几个我印象深的说说。7.1 自动曝光和自动白平衡在捣乱摄像头默认开着自动曝光和自动白平衡画面亮度会随环境缓慢变化你的固定阈值过一会儿就失效了。表现是刚开始框得好好的几分钟后框开始飘或者丢。解决办法是在打开摄像头后把这两个自动项关掉手动锁定cap.set(cv2.CAP_PROP_AUTO_EXPOSURE, 0.25) cap.set(cv2.CAP_PROP_AUTO_WB, 0)不同摄像头支持程度不一样有的设了没反应那就只能接受阈值漂移定期重新标定或者用第5节说的动态调整思路。这个坑很隐蔽因为问题不是立刻出现而是运行一段时间后才显现容易误判成算法问题。7.2 反光和高光把目标打穿目标表面如果有反光高光区域的V值会冲到255而S值掉到很低inRange会把这块判成非目标掩膜上目标中间出现一个洞。闭运算能补一部分但洞太大补不上。这时候要么降低V上界让高光也纳入但会引入更多背景要么接受这个洞——因为外接框是基于整个轮廓的中间有洞不影响框的位置。我一般选后者因为补洞的代价往往大于收益。7.3 目标快速移动时的运动模糊目标动得快帧间会有运动模糊颜色被拉花H值偏移掩膜断成几块。这时候面积筛选可能把碎块都滤掉表现为快速移动时丢目标。缓解办法是提高快门速度如果能控制摄像头的话或者把面积下界调低一点容忍碎块再靠离上一帧最近的策略把碎块拼回目标附近。根本上这是个物理限制算法只能缓解不能消除。7.4 相似色干扰物的处理边界如果画面里有一个和目标颜色几乎一样、形状也差不多的干扰物纯颜色加形状的筛选是分不开的。这时候要么引入运动信息干扰物不动目标动用帧差辅助要么引入位置先验目标大概率在画面某个区域。但这些都是加复杂度要看你的场景值不值得。我的建议是如果干扰物是静态的优先考虑调整摄像头角度避开它这比写算法便宜得多。工程上能用物理手段解决的别用算法硬扛。颜色跟踪这套东西核心链路其实不复杂难的是每一环的边界条件和对实际场景的适配。我上面给的参数都是起点不是终点真正跑起来还得根据你的摄像头、光照、目标去微调。但只要你按先掩膜后筛选再平滑这个层次去调每一步都知道自己在改什么、为什么改就不会陷入那种改了半天不知道哪里起作用的混乱里。这套代码我在几个小项目里直接复用换个颜色阈值就能跟踪别的东西算是把一次折腾变成了长期能用的工具。