简介:这份资源是基于OpenCvSharp的C#颜色识别工程,面向图像处理、自动化视觉与机器人应用开发者,利用HSV颜色空间解决特定颜色提取、过滤与掩码生成问题。压缩包共59个文件,约35.14MB,以17个cs源码文件为核心,配套8个dll运行库、6个resx窗体资源、2个exe可执行程序及jpg/png示例图片,便于直接编译运行与二次学习。项目包含frmMain、frmHSVSet、frmImg等多窗体模块,演示从BGR转HSV、设定色调/饱和度/亮度范围、InRange生成掩码到CopyTo提取目标区域的完整流程,并附OpenCV HSV颜色体系参考图辅助理解颜色区间。已有1719人浏览学习,适合希望快速上手C#环境下OpenCV颜色识别、需要完整WinForms示例工程的开发者。
1. 这个项目不是给你调色用的,是教你“用 HSV 在人堆里捞颜色”
你写 C# 窗体程序,想识别画面里某个颜色的物体,第一反应往往是Color.FromArgb直接比对 RGB。但很快你会发现红色和橙色的边界怎么设都不对,光照一暗整个画面全废。换个思路用 OpenCvSharp 把图像转到 HSV 空间,只对 H 分量设范围,对 S、V 设个宽松区间,就能稳稳把颜色“圈”出来。这个“opencv颜色识别.zip”就是一套 C# 写的颜色识别工程,从摄像头取帧、滑块调参到 HSV 范围提取轮廓,完整流程全在里面。适合刚接触 OpenCvSharp 的 C# 开发者,也适合那些想在 WinForm 里快速接一个颜色检测验证模块的人,拿它当起点改比从零写省事太多。
2. 项目定位与技术选型:为什么 C# 上选 OpenCvSharp 而不是 Python
2.1 从 0 到 1 看这个 zip 里的工程骨架
打开压缩包,先看目录结构。这个项目是按 WinForm + OpenCvSharp 的常见姿势组织的,虽然原作者没保留工程注释,但从文件分布能摸清楚套路:
OpenCV颜色识别/ ├── OpenCV颜色识别.sln ├── OpenCV颜色识别/ │ ├── App.config │ ├── Form1.cs │ ├── Form1.Designer.cs │ ├── Form1.resx │ ├── Program.cs │ ├── Properties/ │ │ ├── AssemblyInfo.cs │ │ ├── Resources.Designer.cs │ │ └── Settings.Designer.cs │ └── packages.config核心逻辑全在Form1.cs里,Designer.cs是窗体布局,Program.cs就是标准入口。packages.config里锁了 OpenCvSharp 的依赖版本引用,visual studio 2019 以上直接还原 NuGet 包就能编译。
2.2 OpenCvSharp vs Python OpenCV:选择背后的实际考量
很多人在图像处理上习惯性选 Python,但如果你是做 windows 桌面工具、设备上位机或者工业检测的客户端,C# 和 OpenCvSharp 的集成优势非常明显:
- 内存管理:OpenCvSharp 的
Mat实现了IDisposable,配合using语句不会像 Python 那样被 GC 延迟搞到内存飙高。 - UI 线程交互:WinForm 的 pictureBox 直接绑定摄像头帧流,不用像 Python 那样折腾 OpenCV 的
imshow窗口,和按钮、滑块的交互也顺手得多。 - 打包部署:C# 编译后一个 exe 加几个 dll 就能拷到工控机上,不用对方装 Python 环境。
而这个项目的核心玩法是 HSV 颜色分割,下面会细讲,这个算法选型本身比语言选型更值得关注。
2.3 HSV 颜色识别的基本原理:为什么 RGB 不好使
RGB 是面向显示设备的颜色空间,R、G、B 三个通道线性叠加,但它对光照变化十分敏感。同一个红色物体,强光下 RGB 可能是 (200, 50, 50),弱光下变成 (100, 25, 25),R 的绝对数值翻倍,直接用阈值切分必然翻车。
HSV 空间把颜色拆成了三个独立维度:
- H(Hue,色相):0 到 180(OpenCV 用 0-180 而不是 0-360),表示颜色属于哪个色系,是 0 度红、60 度绿还是 120 度蓝。
- S(Saturation,饱和度):0 到 255,颜色有多“纯”,越低越接近灰色。
- V(Value,明度):0 到 255,整体亮度,越高越亮。
HSV 的核心优势是:光照变化主要影响 V 分量,而 H 分量相对稳定。比如红色物体,不管光线亮暗,H 值基本在 0 到 10 附近晃动,你只要把 H 的上下限设好,S 和 V 给宽一点,就能稳定追踪。这个项目调参的核心操作就是调节这三个通道的上下限,而不是去敲击 RGB 阈值。
3. 核心模块拆解:一个滑块一个范围,从像素掩膜到外接矩形
3.1 InRange 操作:从图像到二值掩膜的一行代码
颜色识别的基础操作是Cv2.InRange,它把图像中每个像素与设定的Scalar下限和Scalar上限做比较,如果三个分量都在范围内,输出 255(白色),否则输出 0(黑色)。这个项目里对应代码大概长这样:
private void ProcessFrame(Mat frameBgr) { Mat frameHsv = new Mat(); Cv2.CvtColor(frameBgr, frameHsv, ColorConversionCodes.BGR2HSV); Scalar lower = new Scalar(hMin, sMin, vMin); Scalar upper = new Scalar(hMax, sMax, vMax); Mat mask = new Mat(); Cv2.InRange(frameHsv, lower, upper, mask); Cv2.ImShow("Mask", mask); }这段代码先从 BGR 转换到 HSV,注意 OpenCvSharp 里ColorConversionCodes.BGR2HSV的参数顺序不能写反,因为 C# 的 Bitmap 是 BGR 排列而不是 RGB;然后构造Scalar作为上下限,hMin这些值来自窗体上的 trackBar 控件;最后调用InRange生成 mask,再通过ImShow显示出来。
参数说明:H范围在 0 到 180,S和V范围在 0 到 255。很多新手会从网上找 0 到 360 的 HSV 表,放进 OpenCvSharp 里就永远识别不出目标,这是第一个大坑,后面会说。
3.2 滑块调参设计:为什么这个 Project 能省两个小时
项目里最实用的设计是六个 TrackBar 控件,分别控制HMin, HMax, SMin, SMax, VMin, VMax。每次拖动滑块,TrackBar_Scroll事件就会重新读取数值,更新 TextBlock 里的文本,并重新执行ProcessFrame。这种方式比硬编码上下限再重新编译不知道高到哪里去了。
实际调参流程建议这样走:先让目标物体出现在画面正中央,把所有滑块拖到极端,然后慢慢收窄 H 范围,直到 mask 里只留下目标所在的白色区域,再调 S 和 V 过滤掉阴影和高光。这个项目的滑块调参逻辑正好踩中了这个痛点,拿它来标定颜色阈值,最后把识别出来的数值填进正式代码里,整个流程非常顺。
3.3 轮廓查找与外接矩形:从 mask 到最终的可视化结果
拿到二值 mask 以后,下一步是找到目标物体的位置。这个项目里常用Cv2.FindContours从 mask 中提取轮廓,再通过Cv2.BoundingRect得到外接矩形,然后画到原图上。这种流程在颜色识别项目中是标配:
Mat result = frameBgr.Clone(); Point[][] contours; HierarchyIndex[] hierarchy; Cv2.FindContours(mask, out contours, out hierarchy, RetrievalModes.External, ContourModes.Single); foreach (Point[] contour in contours) { double area = Cv2.ContourArea(contour); if (area < 1000) continue; Rect rect = Cv2.BoundingRect(contour); Cv2.Rectangle(result, rect, new Scalar(0, 255, 0), 2); }这里RetrievalModes.External只提取最外层轮廓,适合排除物体内部的孔洞;面积阈值 1000 用来过滤掉噪点。BoundingRect得到的是正矩形,如果目标旋转了 45 度,用MinAreaRect会更准确。从识别到定位的完整链路就是这一条:HSV 转换 → InRange → 轮廓查找 → 矩形绘制。
3.4 摄像头实时采集:VideoCapture 的封装方式
项目里摄像头采集不是硬编码死循环,而是采用 WinForm 的Timer定时器拉流,每隔 33 毫秒抓一帧再交给ProcessFrame处理。这样做的好处是 UI 线程不会被cv2.WaitKey阻塞,因为 OpenCvSharp 的Cv2.WaitKey在有窗体的应用里会卡住消息循环导致窗体假死。
核心代码:
private void Timer_Tick(object sender, EventArgs e) { Mat frame = new Mat(); if (capture.Read(frame)) { ProcessFrame(frame); // 此时 frame 是 BGR,直接转 Bitmap 给 pictureBox var bmp = BitmapConverter.ToBitmap(frame); pictureBox.Image = bmp; } }VideoCapture在 Form_Load 或者按钮事件里初始化,capture.Open(0)默认打开第一个摄像头。需要注意的是capture.Read如果摄像头被占用会一直返回 false,这时要检查是否被其他软件占用,或者换成capture.Open(1)试第二个摄像头的索引。
4. 避坑指南:HSV 范围、视频翻转与 UI 线程的三个血泪教训
4.1 现象:HSV 参数明明和网上查的一模一样,但红色物体就是识别不出来
原因:网上搜到的 HSV 表多半是 0-360 的色相范围,而 OpenCV 按CV_8U存储二进制位,色相要除以 2 映射到 0-180。
解决:查到的 H 值全部除以 2。比如红色是 0-10 或 170-180,你如果写成 0-20 或 340-360,后一个范围直接超出 180,mask 永远全黑。这个项目里 trackBar 的 Maximum 设置成 180 而不是 360 就对了。
4.2 现象:画面左右颠倒,或者上下颠倒,识别位置对不上
原因:部分摄像头镜像或安装角度不同,OpenCvSharp 读出来的原始帧就是反的。
解决:在ProcessFrame之前加一行Cv2.Flip(frame, frame, FlipMode.Y),Y 表示垂直翻转,如果需要镜像用FlipMode.X。我一般建议把它做成一个复选框,调角度时不改代码,直接在界面上切。
4.3 现象:拖动滑块时界面卡顿,像在播放 PPT
原因:滑块回调里频繁调用摄像头读帧,而ProcessFrame里的InRange和FindContours在高分辨率下开销不小,加上BitmapConverter.ToBitmap本身要做一次内存拷贝,如果放在 UI 线程里就会卡。
解决:把摄像头读帧和图像处理放到后台线程,UI 线程只负责显示。常见做法是Task.Run里处理,处理完了用BeginInvoke更新 pictureBox。另一个省事方案是降低分辨率,用capture.Set(VideoCaptureProperties.FrameWidth, 640)和FrameHeight, 480,这两个值在项目注释里没有写,但确实是改完之后最直接有效的。
4.4 现象:程序关闭后摄像头灯还一直亮着,进程杀不掉
原因:VideoCapture没被释放,或者窗体关闭事件里没有调用Dispose。
解决:在 Form_Closing 里加capture.Release()和capture.Dispose(),最好把 capture 的创建放在一个单独的OpenCamera()方法里,关闭时容错判断。如果还是释放不掉,改用using包裹单帧读取。这条对于工业现场特别重要,一个摄像头被占用了,后续其他程序就打不开了。
4.5 现象:识别出来的矩形框疯狂抖动,面积忽大忽小
原因:HSV 阈值范围太宽,背景里和目标颜色接近的像素被一起划分进来。另外摄像头的自动曝光在光线变化时也会导致 V 分量跳动。
解决:要么收窄 S 和 V 的范围,要么对 mask 做一次Cv2.GaussianBlur平滑。如果目标是运动物体,可以考虑在宽高比或面积上做逻辑过滤。这个项目的固定阈值不适用于动态光照,你可以把滑块调到一个合适位置后,追加一个以下代码做滤波:
Mat denoised = new Mat(); Cv2.MedianBlur(mask, denoised, 5); Cv2.MorphologyEx(denoised, denoised, MorphTypes.Close, Mat.Ones(5, 5, MatType.CV_8UC1));5. 完整的图像处理链路与参数调优:在项目里可改的三个位置
5.1 模块职责拆分:哪段代码改颜色范围,哪段代码改形态学处理
上一章讲了避坑,这一章把整条处理链路串起来。项目原代码可能只有一个ProcessFrame大函数,但为了二次开发,我建议把它拆成三个方法:
ConvertToMask(Mat frameHsv, Scalar lower, Scalar upper):负责InRange,只出 mask。FilterMask(Mat mask):负责中值滤波、膨胀、腐蚀等形态学操作。FindAndDrawTargets(Mat frame, Mat mask, Mat result):负责找轮廓并画框。
这样拆分以后,你想加“只识别圆形红色区域”,只需要在FindAndDrawTargets里判断Cv2.MinEnclosingCircle或contourArea / boundingArea的比例就能过滤。
private double Circularity(Point[] contour) { double area = Cv2.ContourArea(contour); double perimeter = Cv2.ArcLength(contour, true); if (perimeter == 0) return 0; return 4 * Math.PI * area / (perimeter * perimeter); }圆形度的值在 0 到 1 之间,正圆是 1,正方形约等于 0.785。把阈值设为 0.8 就能滤掉大多数长方形的干扰物。这是颜色识别里第二个常用过滤条件,项目源里没写,但你改完必然用得上。
5.2 HSV 上下限调参的实战经验:先调 H 再调 S 再调 V
很多人把六个滑块乱拖一通,最后发现什么都识别不到。我建议按顺序来:先把 SMin 和 VMin 都调到 0,SMax 和 VMax 调到 255,只调 H 的上下限,找到能覆盖目标颜色的色相范围;然后慢慢拉高 SMin,把浅灰、白色背景逐渐踢掉;最后调 VMin 去除阴影。这六步看着简单,但顺序错了就会陷入越调越花、越花越调不明白的循环。
在项目代码里你可以加一行控制台输出,每次调整滑块时打印当前范围:
Console.WriteLine($"H: {hMin}-{hMax}, S: {sMin}-{sMax}, V: {vMin}-{vMax}");这样调试完了,把最后一行的数值直接复制进初始化代码里,它就是当前光照条件下最稳的参数。注意光照一变就要重新调,这就是固定阈值方案的天然短板。如果你要做一个自适应的系统,可以在代码里嵌套一个CalcHist统计 H 分量的峰值,然后自动生成范围,但这个选题更进阶,这里先不展开。
5.3 性能优化:用 ROI 缩小处理区域
如果摄像头是 1920×1080 的画面,你要识别的对象只在其中一小块,整个画面跑InRange非常浪费 CPU。一个聪明且省事的做法是把鼠标拖拽选中的区域变成 ROI,只在该区域做颜色识别:
Rect roiRect = new Rect(x, y, width, height); Mat roi = frameHsv[roiRect]; Mat roiMask = new Mat(); Cv2.InRange(roi, lower, upper, roiMask); Cv2.FindContours(roiMask, out contours, out hierarchy, RetrievalModes.External, ContourModes.Single);注意轮廓找到的坐标是相对 ROI 的,画到全图上时每个点的 X 要加上roiRect.X,Y 要加上roiRect.Y。很多新手在这一步忘了平移坐标,结果矩形画到左上角去,排查半天才发现是坐标系没换算。用 ROI 之后,处理时间能从 30 毫秒降到 5 毫秒以内,对于 30fps 的实时识别影响非常明显。
5.4 与 WinForm 集成的注意事项:pictureBox 显示与跨线程更新
项目用BitmapConverter.ToBitmap把Mat转成Bitmap给 pictureBox。如果旋转到后台线程,就需要用pictureBox.Invoke:
pictureBox.BeginInvoke(new Action(() => { pictureBox.SuspendLayout(); pictureBox.Image = bmp.Clone() as Bitmap; pictureBox.ResumeLayout(); }));这里每次ToBitmap其实已经产生了新 Bitmap 对象,直接赋给 pictureBox 没问题,但旧的 Bitmap 要手动Dispose,否则内存会一直涨。这个项目原始代码里未必有专门释放,但跑久了会内存溢出。我在自己改的版本里用一个oldBitmap变量记住前一张图,赋值前先if (oldBitmap != null) oldBitmap.Dispose(),一个晚上连续跑 24 小时都不崩。
6. 二次开发实战:如何把这个 Demo 改成一个可用的颜色分拣工具
6.1 第一个改造:增加颜色预设下拉框
HSV 调参每次从零开始太痛苦了,我做的第一件事是加一个ComboBox,预设红色、绿色、蓝色、黄色四组范围,切换时自动设置滑块位置并重新识别。这需要用字典存预设值:
Dictionary<string, (int HMin, int HMax, int SMin, int SMax, int VMin, int VMax)> presets = new Dictionary<string, (int, int, int, int, int, int)> { { "红色", (0, 10, 100, 255, 100, 255) }, { "绿色", (35, 77, 50, 255, 50, 255) }, { "蓝色", (100, 130, 50, 255, 50, 255) }, { "黄色", (20, 35, 100, 255, 100, 255) } };下拉框切换时用TryGetValue找到对应元组,一次赋值六个滑块。这种做法在工业场景非常实用,操作员不用理解 HSV,只选一个“红色”,设备就按红色阈值跑。
6.2 第二个改造:把识别结果输出成串口指令或日志文件
如果你要把“识别到红色物体”当成一个事件,可以在这里增加一个事件回调:
public event Action<string> OnColorDetected; private void DetectColor(Mat mask) { double totalPixels = mask.Rows * mask.Cols; double whitePixels = Cv2.CountNonZero(mask); double ratio = whitePixels / totalPixels; if (ratio > 0.2) { OnColorDetected?.Invoke("red"); } }CountNonZero统计的是非零像素数,也就是 mask 中被标记为目标颜色的面积占比。阈值 0.2 表示目标颜色至少占画面 20% 才认为检测到。你可以把这个事件接到串口上,发送一个字节给单片机,也可以写进日志数据库。这样一个简单的颜色识别模块就变成了一个分拣系统的主感知识别器。
6.3 第三个改造:验证颜色识别稳定性的一种方法
改完代码不能只肉眼看,我建议做一个自动化验证:写一个循环,每 100 帧统计一次识别成功率,做法是把当前帧的 mask 保存下来,人工标记目标区域,然后计算 IoU:
double IoU(Mat maskA, Mat maskB) { Mat intersection = new Mat(); Cv2.BitwiseAnd(maskA, maskB, intersection); Mat union = new Mat(); Cv2.BitwiseOr(maskA, maskB, union); return Cv2.CountNonZero(intersection) / (double)(Cv2.CountNonZero(union) + 1e-6); }如果 IoU 稳定在 0.7 以上,说明阈值范围足够契合。如果忽高忽低,就要考虑加形态学操作或者换成多阈值融合方案。这个验证方法不需要额外硬件,跑现有摄像头就够了。从那以后我每次改完 HSV 参数都会强制走一遍 IoU 验证,不去“感觉差不多就行”地凑合,靠肉眼调参翻车真不少,希望帮到你。
本文还有配套的精品资源,点击获取