☰
OpenCvSharp实战:HSV颜色识别从阈值到轮廓框选全解析
2026/10/21 2:45:04 网站建设 项目流程

简介:面向C#开发者的OpenCV颜色识别示例工程,基于OpenCvSharp封装HSV色彩模型,帮助读者快速掌握色调、饱和度、亮度三个维度的阈值设定与掩码提取方法。配套完整Visual Studio解决方案,包含窗体界面、图像处理逻辑、颜色范围设置模块以及测试图片,可在实际项目中直接参考配色或移植代码。工程共五十九个文件,以C#源码为主(十七个cs),辅以程序集dll、资源resx、配置文件config、可执行exe及调试pdb,整体35.14MB,目录结构清晰,适合新手按入口逐步调试,也适合有经验者快速提取颜色识别功能。目前已有1719人学习下载,资源热度较高。通过HSV转换、InRange掩码、像素级过滤等核心步骤,读者可掌握绿色等特定颜色的识别流程,并理解OpenCV与C#接口的调用差异,为自动化分拣、目标检测等场景打下基础。工程中cs文件覆盖HSV参数设置、主界面交互与图像显示等模块,便于按需拆解复用。

1. 颜色识别不难,难的是把 HSV 阈值调到不误检

拿到一个 OpenCV 颜色识别的项目需求,很多人第一反应是写个 RGB 范围判断就交差。但实际放到现场,红色和橙色分不开、反光区域被当成白色、光照一变所有阈值全部失效——这些才是真正消耗时间的部分。这个项目把 OpenCvSharp 的 HSV 颜色识别做了完整落地:从摄像头取帧、颜色分割、形态学去噪到轮廓框选,全部用 C# 实现,适合想做视觉分拣、机器人巡线、颜色追踪的开发者。它不只是一个 Demo,而是一套能直接改颜色范围、调形态学参数、换视频源的基础框架,读懂了它,二次开发可以少走很多弯路。

2. 项目解剖:从摄像头到颜色分类的完整链路

2.1 整体架构与目录结构

这个项目是基于 OpenCvSharp 4.x 的 Windows 窗体应用,核心链路是:摄像头/视频文件 → Mat 帧 → 转 HSV → 按阈值生成掩膜 → 形态学处理 → 轮廓查找 → 画框标注 → 显示结果。整个结构分为三个层:采集层、处理层、展示层。

OpenCV颜色识别/ ├── CameraCapture.cs // 摄像头封装,负责帧读取和释放 ├── ColorDetector.cs // 核心颜色识别逻辑,封装 HSV 分割 ├── MainForm.cs // 界面显示,含滑动条调参 ├── Program.cs // 程序入口 └── ColorRange.cs // 颜色范围定义类

模块之间的耦合很低,ColorDetector不依赖界面,CameraCapture不关心图像内容。这样做的好处是:后面你想把摄像头换成视频文件、网络流,或者把识别结果改成输出坐标到串口,只需要替换或扩展对应模块,不用动主逻辑。

2.2 为什么用 HSV 而不是 RGB

用 RGB 做颜色识别的坑在于:RGB 三个通道和亮度强相关,同一物体在阴影下和阳光下,R、G、B 值能差出一倍还多。HSV 把色相(Hue)单独拆出来,饱和度和亮度作为辅助维度,这样只要物体的色相稳定,就能靠 H 值准确切出目标颜色。

项目里ColorRange类就是为此设计的,它保存了一个颜色在 HSV 空间的最小/最大三元组:

public class ColorRange { public Scalar Lower { get; set; } // HSV 下界 public Scalar Upper { get; set; } // HSV 上界 public string Name { get; set; } // 颜色名称,用于标注 public ColorRange(string name, Scalar lower, Scalar upper) { Name = name; Lower = lower; Upper = upper; } }

这段代码是后续所有识别的基础。Lower和Upper直接传给Cv2.InRange,OpenCV 会逐像素判断:如果像素的 H、S、V 三个值都在这个区间内,掩膜对应位置就被置为白色(255),否则是黑色(0)。也正因为这个设计,颜色的精细化调整不需要改代码,只改这两个Scalar参数就行。

2.3 主流程:从原始帧到掩膜

ColorDetector的Detect方法是整个项目的心脏,它按顺序完成颜色识别链条中的每一步。下面这段是它的主干实现:

public Mat Detect(Mat frame, ColorRange range) { // 1. 高斯模糊,降低摄像头噪声和颜色跳变 Mat blurred = new Mat(); Cv2.GaussianBlur(frame, blurred, new Size(5, 5), 0); // 2. 从 BGR 转到 HSV,OpenCV 默认以 BGR 存储 Mat hsv = new Mat(); Cv2.CvtColor(blurred, hsv, ColorConversionCodes.BGR2HSV); // 3. 按阈值生成二值掩膜 Mat mask = new Mat(); Cv2.InRange(hsv, range.Lower, range.Upper, mask); // 4. 开操作去噪,先腐蚀后膨胀,去掉孤立白点 Mat kernel = Cv2.GetStructuringElement(MorphShapes.Ellipse, new Size(5, 5)); Mat morphed = new Mat(); Cv2.MorphologyEx(mask, morphed, MorphTypes.Open, kernel); // 5. 闭操作填充空洞,把同一物体断裂的部分连起来 Mat closed = new Mat(); Cv2.MorphologyEx(morphed, closed, MorphTypes.Close, kernel); return closed; }

我标注的几步里,最容易忽略的是第 1 步高斯模糊。摄像头采集到的像素往往存在邻域抖动,如果直接取 HSV,掩膜会出现密集的小噪点;模糊之后相邻像素趋于一致,后面的形态学操作才能起作用。第 4、5 步的核大小直接决定处理效果:核太小,噪点去不干净;核太大,会吞掉小目标,一般物体面积在画面里占比不小时,用 5x5 椭圆核就够了。

3. 核心模块拆解:轮廓查找与目标框选

3.1 从掩膜到轮廓

拿到干净的掩膜后,下一步是指出「哪里有这个颜色」。OpenCvSharp 里用FindContours查找白色区域的边缘,再通过BoundingRect得到外接矩形。这部分涉及到一个容易被忽略的坑:FindContours在版本演进中返回值变了,老代码里常见的Cv2.FindContours重载在新版本下需要传输出参数out才能拿到Point[][]和HierarchyIndex[]。

public List<Rect> GetBoundingBoxes(Mat mask) { // 查找所有外轮廓 Point[][] contours; HierarchyIndex[] hierarchy; Cv2.FindContours(mask, out contours, out hierarchy, RetrievalModes.External, ContourApproximationModes.ApproxSimple); List<Rect> boxes = new List<Rect>(); foreach (Point[] contour in contours) { // 面积过滤,去掉小于 500 像素的噪声块 double area = Cv2.ContourArea(contour); if (area < 500) continue; Rect rect = Cv2.BoundingRect(contour); boxes.Add(rect); } return boxes; }

面积过滤这一步是血泪经验。相机里经常出现一小片高光、皮屑、灰尘的影子会被误判成目标颜色,加了ContourArea下限后,这些零碎区域直接被跳过。实际项目里,下界值要根据摄像头的安装位置和物体距离来调:镜头离目标近,面积下限可以设大一些;离得远,则需要调小,否则小目标会被丢掉。

3.2 绘制结果

拿到矩形框后,在原始帧上直接画线并标注颜色名称。注意这里不是在掩膜上画,因为掩膜已经被二值化,没有原始纹理信息。

public Mat DrawResults(Mat frame, List<Rect> boxes, string colorName) { foreach (Rect box in boxes) { // 画矩形框,颜色用 BGR,厚度 2 Cv2.Rectangle(frame, box, new Scalar(0, 255, 0), 2); // 在框顶部写文字 string label = $"{colorName} {box.Width}x{box.Height}"; Cv2.PutText(frame, label, new Point(box.X, box.Y - 5), HersheyFonts.HersheySimplex, 0.5, new Scalar(0, 255, 0), 1, LineTypes.Line8); } return frame; }

画框逻辑里,HersheySimplex是 OpenCV 内置字体,不会依赖系统字体库,所以发布到别的机器也不会出现文字乱码。文字位置放在矩形框的左上角偏上,如果Y - 5小于 0(比如目标在画面最顶端),文字会被截断,常见做法是加一个Math.Max(0, box.Y - 5)的夹取。

3.3 界面与预览线程

主窗体MainForm里跑着一个Timer或者后台线程,定时读取摄像头帧并调用Detect和DrawResults。关键在于:Cv2.ImShow不能在非 UI 线程直接调用,这个项目用的是PictureBox显示,每次从 Mat 转 Bitmap 时会有一次像素拷贝。这个拷贝是帧率瓶颈之一,常见优化是复用 Bitmap 对象,避免每次 new 造成 GC 压力:

private Bitmap _buffer; private void UpdateFrame(Mat frame) { if (_buffer == null || _buffer.Width != frame.Width || _buffer.Height != frame.Height) { _buffer = new Bitmap(frame.Width, frame.Height); } // Mat 到 Bitmap 的转换,OpenCvSharp 直接提供扩展方法 Bitmap bmp = OpenCvSharp.Extensions.BitmapConverter.ToBitmap(frame); pictureBox.Image?.Dispose(); pictureBox.Image = bmp; }

这里有一个取舍:直接ToBitmap每次都产生新 Bitmap,简单但频繁分配内存;复用_buffer则需要处理 Mat 内存对齐问题。对于 640x480 的分辨率,其实直接 ToBitmap 就够用,只有在 1080p 以上才需要真正意义的共享内存优化。

4. 跑通项目:环境配置与运行参数

4.1 环境准备

这个项目用的技术栈是 .NET Framework 或 .NET 6/8,取决于你的 OpenCvSharp 版本。推荐直接用 Visual Studio 打开.csproj,通过 NuGet 安装 OpenCvSharp4 和 OpenCvSharp4.runtime.win。需要注意:运行时包必须安装,否则打开会报DllNotFoundException,因为 OpenCvSharp 是 P/Invoke 封装,找不到原生 dll 就会砸掉。

如果你的机器上有多个 OpenCvSharp 版本残留,NuGet 还原时容易冲突。我一般会先在项目里清一遍packages目录,再重新还原,比较干净。

4.2 运行步骤与界面操作

  1. 打开MainForm.cs,找到摄像头初始化代码,默认是Cv2.VideoCapture(0),也就是第一个 USB 摄像头。如果你的摄像头索引不是 0,改成 1、2 试试。
  2. 在界面加载事件里设置默认颜色范围,比如红色识别,需要给ColorRange传两组值,因为红色在 HSV 中跨越 0 和 180 两端,项目里通常用两个 range 做 OR 合并。
  3. 运行程序后,画面左侧是原始视频,右侧是掩膜结果,下方滑动条分别控制 H、S、V 的上下界。
  4. 拖动滑动条,观察掩膜中目标区域是否完整、背景是否被误选。调完之后点击「保存参数」,会生成一个config.xml或 JSON 文件,记录当前阈值。

4.3 参数调优:滑动条调试

滑动条是这个项目最实用的部分,它把 HSV 阈值从「改代码-编译-运行」循环里解放出来。OpenCvSharp 里滑动条绑定的方法非常简单,关键点在于它是静态回调,需要把当前帧和掩膜都存成类字段,回调里才能实时刷新预览。

private void TrackbarHMin_ValueChanged(int pos) { colorRange.Lower = new Scalar(pos, colorRange.Lower[1], colorRange.Lower[2]); UpdatePreview(); }

调参是有套路的,别乱拖。先固定 S 和 V 为最大范围,只调 H 把目标颜色的色相圈出来;然后逐步缩小 S 下限,把低饱和度的背景排除;最后调 V,应对过曝或过暗。每一步都看右侧掩膜,而不是看最终画框效果,因为画框的轮廓过滤会掩盖小的阈值错误。

5. 避坑指南:HSV 颜色识别那些玄学问题

5.1 红色分量的回绕问题

现象:设置红色 HSV 阈值(0, 100, 100)到(10, 255, 255),识别出的红色目标偶尔缺失,尤其当物体颜色偏深红或粉红时。原因:HSV 色相 H 是一个环形,红色分布在 0 附近,既包括 0-10,也包括 170-180。如果你只设置了下限 0、上限 10,那么偏紫红色(H 接近 170)的像素全部被丢弃。解决:对红色做两次InRange,一次覆盖(0, minS, minV)到(10, maxS, maxV),另一次覆盖(170, minS, minV)到(180, maxS, maxV),然后用Cv2.BitwiseOr合并两张掩膜。

Mat mask1 = new Mat(); Mat mask2 = new Mat(); Cv2.InRange(hsv, new Scalar(0, 100, 100), new Scalar(10, 255, 255), mask1); Cv2.InRange(hsv, new Scalar(170, 100, 100), new Scalar(180, 255, 255), mask2); Cv2.BitwiseOr(mask1, mask2, mask);

5.2 环境光变化导致识别不稳定

现象:上午调好的阈值,下午太阳角度一变,掩膜里出现大量噪点,或者目标区域明显缩小。原因:家用摄像头通常有自动白平衡和自动曝光,它会根据环境动态调整色彩增益,导致同一块颜色在不同时刻的 HSV 值漂移。解决:如果现场是固定光源,把摄像机的自动曝光关闭,固定曝光时间。如果是户外场景,不能关闭自动曝光,就要在算法层增大 S 和 V 的容差,并把高斯模糊的核从 5x5 改成 7x7。注意模糊核增大后,面积过滤下限也要相应提高。

5.3 OpenCvSharp 的 Mat 与 Bitmap 转换异常

现象:程序运行到BitmapConverter.ToBitmap时提示ArgumentException: Parameter is not valid。原因:Mat 的通道数或位深不满足 Bitmap 要求,常见于把灰度掩膜Mat直接 ToBitmap,但 Bitmap 需要 8UC3 或 8UC1 格式,有时你会拿到一个 8UC4 的 BGRA Mat,转换器不支持。解决:先在下游强制Cv2.CvtColor成ColorConversionCodes.GRAY2BGR或确认是 8UC3;另外检查 Mat 是不是空对象,frame.Empty()时不要进入转换逻辑。我习惯在转换前加个断言。

5.4 摄像头帧率低到没法用

现象:识别结果像是幻灯片,每秒只有 3-4 帧。原因:没有限制PictureBox.Image的 Disposal 频率,每次更新都触发 UI 重绘,加上Mat频繁分配,垃圾回收把主线程拖住了。解决:把预览更新降到 30fps 以内,用一个Stopwatch控制,只有超过 33ms 才刷新界面;同时将分辨率从 1920x1080 降到 640x480,处理时间能缩短到原来的三分之一。

5.5 形态学核大小对不同距离的目标不通用

现象:目标在近处时识别正常,人后退两步,目标消失了。原因:固定 5x5 的形态学核在远距离时会把小物体整个腐蚀掉,因为远处目标像素面积小,一次腐蚀就没了。解决:不要用固定核,按轮廓面积动态决定是否做开操作,或者把面积过滤与形态学核解耦——小目标不做开操作,只做闭操作。更稳的办法是先做连通域标记,然后用面积过滤,而不是先形态学再面积。

6. 二次开发思路:从单色识别到多目标与输出

6.1 多颜色并行检测

当前项目一次只识别一种颜色,实际分拣场景往往要求同时框出红、蓝、绿。常见做法是定义一个List<ColorRange>,对每一帧做循环检测,把不同颜色的掩膜合并到一张图里,绘制时用不同颜色的框。注意合并掩膜不能用简单加和,用两张图填充不同灰度值(如蓝=100,绿=200),最后按像素值区分颜色,否则轮廓查找会把相邻颜色合并。

Mat combinedMask = new Mat(mask.Size(), MatType.CV_8UC1, Scalar.All(0)); // 蓝色填充 100,绿色填充 200 Cv2.InRange(hsv, blueRange.Lower, blueRange.Upper, maskBlue); combinedMask.SetTo(100, maskBlue); Cv2.InRange(hsv, greenRange.Lower, greenRange.Upper, maskGreen); combinedMask.SetTo(200, maskGreen);

6.2 输出目标坐标到外部设备

识别框坐标本身只是起点,机器人抓取需要把像素坐标换算成相机坐标系下的物理位置。这里有个常用技巧:在画面上取四个标定点,用GetPerspectiveTransform做一个单应性矩阵,把像素点投影到实际平面坐标。C# 侧计算完成后,通过 SerialPort 输出X:123,Y:456这样的文本帧,下位机解析即可。

6.3 接入视频文件与网络流

VideoCapture的参数可以传文件路径,也可以传网络流地址。改造成本非常低,只需要把构造参数从整数索引改成字符串。要注意的是网络流解码耗时比本地文件高很多,必须把处理线程和 UI 线程彻底分开,否则一个网络抖动就卡死界面。

// 摄像头 using (var capture = new VideoCapture(0)) // 视频文件 using (var capture = new VideoCapture("test.mp4")) // RTSP 网络流 using (var capture = new VideoCapture("rtsp://192.168.1.100:554/stream1"))

改造完后,建议用一段带有复杂背景的视频做回归测试,观察掩膜中是否有环境物体被误判。我自己的习惯是每次改完阈值或形态学参数,都先跑一段录制好的视频回放,而不是直接拿摄像头试,因为回放可以反复比对参数效果。从那以后我每次调完参数都强制走一遍回放流程,省下的现场调试时间不是一点点。希望这个项目的拆解对你有所帮助,跑起来后再动手改几个参数,你会比看文档学得快得多。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询