简介:这份资源面向具备一定C#基础、希望在桌面端实现图像颜色筛选与识别的开发者,围绕OpenCV的HSV色彩模型展开,重点演示如何借助OpenCvSharp在C#环境中完成颜色识别。包内以17个cs源码文件为核心,配合8个dll依赖库、6个resx与7个resources资源文件,以及sln解决方案、csproj工程和config配置,构成可直接编译运行的完整示例工程,压缩包约35.14MB,共59个文件。内容涵盖RGB转HSV、按色调与饱和度设定上下限、通过InRange生成二值掩码并提取目标颜色区域等关键环节,还附带HSV颜色体系示意图与示例图片,便于对照理解色调区间取值。已有1722人学习下载,适合需要快速上手C#颜色识别、做物体检测或自动化分拣原型的读者参考,也可作为课程实验与项目练手的现成素材。
1. 从一张截图说起:这个颜色识别压缩包到底能干什么
前阵子帮朋友做一个分拣小工具,摄像头对着传送带,要求把红、绿、蓝三种颜色的物料分开计数。他丢过来一个压缩包,名字很朴素,就叫opencv颜色识别.zip。我原本以为里面是一堆零散脚本,解压后才发现是一套能直接跑起来的 C# 工程,核心就是 OpenCV 的 HSV 颜色识别,配合 OpenCvSharp 做 .NET 侧的封装调用。这类需求在工业分拣、桌面小工具、教学演示里非常常见,难点从来不是「能不能识别」,而是光照一变、阈值一飘,识别结果就开始玄学。这个包的价值在于,它把 HSV 色彩空间转换、阈值区间设定、掩膜生成、轮廓提取这一整条链路都落成了可编译的代码,新手能照着跑通,熟手能直接拿阈值配置和轮廓过滤逻辑去改。适合谁?做 C# 桌面端、需要接摄像头做颜色判断、又不想从零啃 OpenCV 文档的开发者。
2. 工程结构与技术选型:为什么是 OpenCvSharp 而不是原生 C++
2.1 目录骨架与依赖关系
解压后先别急着双击运行,花两分钟把目录看一遍,后面改代码会省很多事。这类工程的典型结构大致是这样,不同版本可能略有出入,但核心文件跑不掉:
opencv颜色识别/ ├── ColorRecognition.sln # 解决方案入口 ├── ColorRecognition/ │ ├── Program.cs # 控制台/窗体启动入口 │ ├── ColorDetector.cs # 颜色识别核心类 │ ├── Config/ │ │ └── hsv_ranges.json # HSV 阈值配置(可外置) │ ├── Assets/ │ │ └── sample.jpg # 测试图片 │ ├── ColorRecognition.csproj # 项目文件,含包引用 │ └── bin/Debug/ # 编译输出 └── README.md依赖上最关键的是OpenCvSharp4和它的运行时包OpenCvSharp4.runtime.win。很多人第一次跑翻车就翻在这里:只装了OpenCvSharp4,编译能过,一运行就报DllNotFoundException: OpenCvSharpExtern。原因是托管封装和原生动态库是分开的两个包,必须一起引。常见做法是在.csproj里显式写清楚:
<ItemGroup> <!-- 托管封装,提供 C# API --> <PackageReference Include="OpenCvSharp4" Version="4.9.0.20240103" /> <!-- Windows 原生运行时,缺了它运行期找不到 OpenCvSharpExtern.dll --> <PackageReference Include="OpenCvSharp4.runtime.win" Version="4.9.0.20240103" /> </ItemGroup>版本号两个包必须对齐,混用不同版本是另一个高频坑。参数说明:OpenCvSharp4负责Mat、Cv2这些类型,runtime.win只负责把原生 dll 拷到输出目录,不写任何代码。如果你部署到 Linux,要换成对应的 runtime 包,别照搬 win 版。
2.2 为什么选 HSV 而不是 RGB
这是整个项目最该先想明白的一点。RGB 三个通道都在表达亮度,光照稍微一变,三个值一起漂,你昨天调好的阈值今天就失效。HSV 把色相(H)、饱和度(S)、明度(V)拆开,颜色种类主要由 H 决定,光照影响主要落在 V 上,所以调阈值时可以「锁 H、放宽 S/V」,鲁棒性高一个档次。OpenCV 里 H 的取值范围是 0~179(不是 0~360,因为 8 位存储除以 2),这一点新手极容易搞错,把 360 度的色相值直接填进去,红色永远识别不到。
| 颜色 | H 范围(OpenCV) | S 建议 | V 建议 |
|---|---|---|---|
| 红(低区) | 0~10 | 100~255 | 80~255 |
| 红(高区) | 160~179 | 100~255 | 80~255 |
| 绿 | 40~80 | 80~255 | 60~255 |
| 蓝 | 100~130 | 100~255 | 60~255 |
红色要分两段是因为色相环在 0 附近是首尾相接的,0~10 和 160~179 其实是一家人,做掩膜时得把两段结果按位或起来,这个细节后面代码里会体现。
3. 核心识别链路拆解:从 BGR 到轮廓的每一步
3.1 图像读取与色彩空间转换
先看入口这段,负责把输入图或摄像头帧转成 HSV:
using OpenCvSharp; public class ColorDetector { // 输入 BGR 图,输出 HSV 图 public Mat ConvertToHsv(Mat bgr) { if (bgr.Empty()) throw new ArgumentException("输入图像为空"); var hsv = new Mat(); // BGR -> HSV,注意 OpenCV 默认读进来就是 BGR 顺序 Cv2.CvtColor(bgr, hsv, ColorConversionCodes.BGR2HSV); return hsv; } }逻辑说明:Cv2.CvtColor是整条链路的起点,ColorConversionCodes.BGR2HSV这个枚举别写错,写成RGB2HSV会导致红蓝通道对调,识别结果全乱。参数上bgr必须是三通道 8 位图,如果是灰度图或四通道 PNG,先CvtColor转成 BGR 再进来。我一般会在方法开头加一句bgr.Empty()判断,摄像头掉线时帧可能是空的,不加这层保护后面CvtColor直接抛异常,日志里还看不出是设备问题。
3.2 阈值区间与掩膜生成
拿到 HSV 图后,用InRange把目标颜色抠出来:
public Mat BuildMask(Mat hsv, Scalar lower, Scalar upper) { var mask = new Mat(); // lower/upper 是 (H, S, V) 三元组,H 上限 179 Cv2.InRange(hsv, lower, upper, mask); return mask; } // 红色需要两段合并的写法 public Mat BuildRedMask(Mat hsv) { var lowMask = new Mat(); var highMask = new Mat(); Cv2.InRange(hsv, new Scalar(0, 100, 80), new Scalar(10, 255, 255), lowMask); Cv2.InRange(hsv, new Scalar(160, 100, 80), new Scalar(179, 255, 255), highMask); var redMask = new Mat(); // 两段按位或,合并成完整红色区域 Cv2.BitwiseOr(lowMask, highMask, redMask); return redMask; }逻辑说明:InRange对每个像素做区间判断,落在[lower, upper]内输出 255,否则 0,得到一张黑白掩膜。参数Scalar的三个分量顺序是 H、S、V,别和 BGR 搞混。红色那段BitwiseOr是关键,只取低区会漏掉偏紫的红,只取高区会漏掉偏橙的红,两段合并才完整。这里有个血泪经验:Scalar的 H 分量如果填了 180 以上,OpenCV 不会报错,但结果恒为空,排查起来很费劲,建议在配置加载时做一次范围校验。
3.3 形态学去噪与轮廓提取
掩膜出来往往带毛刺和小噪点,直接找轮廓会数出一堆碎片:
public Point[][] FindColorContours(Mat mask, int minArea = 500) { // 开运算:先腐蚀后膨胀,去掉小噪点 var kernel = Cv2.GetStructuringElement(MorphShapes.Rect, new Size(5, 5)); Cv2.MorphologyEx(mask, mask, MorphTypes.Open, kernel); // 找外轮廓 Cv2.FindContours(mask, out Point[][] contours, out _, RetrievalModes.External, ContourApproximationModes.ApproxSimple); // 按面积过滤,太小的当噪声丢掉 var valid = contours.Where(c => Cv2.ContourArea(c) >= minArea).ToArray(); return valid; }逻辑说明:MorphTypes.Open开运算对白色前景先腐蚀再膨胀,能把孤立的小白点抹掉,kernel尺寸 5×5 是经验值,噪点多就加大到 7×7,但别太大否则小目标也被吃掉。RetrievalModes.External只取最外层轮廓,适合分拣场景里一个物料一个轮廓的情况;如果目标有孔洞需要内轮廓,换成Tree。minArea是最实用的过滤参数,摄像头分辨率越高这个值要跟着调,1080p 下 500 像素面积基本能滤掉大部分噪点。ContourArea返回的是像素面积,不是物理面积,要换算实际尺寸得乘上标定系数。
3.4 结果标注与输出
最后把识别到的轮廓画回原图,方便肉眼验证:
public Mat DrawResult(Mat bgr, Point[][] contours) { var output = bgr.Clone(); foreach (var contour in contours) { // 外接矩形,绿色框标注 var rect = Cv2.BoundingRect(contour); Cv2.Rectangle(output, rect, new Scalar(0, 255, 0), 2); // 在框上方写面积,便于调试阈值 Cv2.PutText(output, $"{(int)Cv2.ContourArea(contour)}", new Point(rect.X, rect.Y - 5), HersheyFonts.HersheySimplex, 0.5, new Scalar(0, 255, 0), 1); } return output; }逻辑说明:BoundingRect取轮廓的最小外接正矩形,画框用Scalar(0,255,0)是 BGR 顺序的纯绿。PutText把面积标出来,调阈值时盯着这个数字看,比盯着画面猜靠谱得多。HersheyFonts是 OpenCV 内置字体,不支持中文,要显示中文得另接 GDI+ 或 SkiaSharp 渲染,这是 C# 侧常见的二次开发点。
4. 跑起来与调参:从样例图到摄像头实时识别
4.1 编译运行的最小步骤
拿到包先跑通静态图,别一上来就接摄像头,变量太多容易懵。步骤大致是:用 Visual Studio 打开.sln,确认目标框架(常见是 .NET 6 或 .NET Framework 4.7.2,看 csproj 里的TargetFramework),还原 NuGet 包,把Assets/sample.jpg的路径改成你本机的绝对路径或相对输出目录的路径,然后 F5。如果样例图能弹出窗口并画出绿框,说明原生库加载、色彩转换、轮廓提取整条链路都通了。这一步过了再动摄像头,问题范围就小很多。
4.2 摄像头接入与帧循环
实时识别和静态图差别就在一个循环:
using var capture = new VideoCapture(0); // 0 是默认摄像头索引 if (!capture.IsOpened()) throw new Exception("摄像头打开失败,检查索引或被占用"); var detector = new ColorDetector(); using var window = new Window("color-detect"); while (true) { using var frame = new Mat(); if (!capture.Read(frame) || frame.Empty()) continue; // 掉帧直接跳过,别让循环崩掉 var hsv = detector.ConvertToHsv(frame); var mask = detector.BuildRedMask(hsv); var contours = detector.FindColorContours(mask); var result = detector.DrawResult(frame, contours); window.ShowImage(result); if (Cv2.WaitKey(1) == 27) break; // ESC 退出 }逻辑说明:VideoCapture(0)的 0 是设备索引,笔记本外接摄像头可能是 1 或 2,打不开就挨个试。capture.Read返回 false 或帧为空时用continue跳过,而不是break,否则摄像头偶发抽风整个程序就退了。WaitKey(1)既处理窗口事件又控制帧率,参数是毫秒,设 0 会卡住等按键。using声明确保Mat及时释放,OpenCV 的Mat持有非托管内存,不释放跑久了内存会涨,这是 C# 接 OpenCV 最容易忽略的一点。
4.3 阈值怎么调才不玄学
调参别在代码里硬改数字反复编译,把阈值外置成 JSON,改完重启或热加载都行:
{ "red": { "lower1": [0, 100, 80], "upper1": [10, 255, 255], "lower2": [160, 100, 80], "upper2": [179, 255, 255] }, "green": { "lower": [40, 80, 60], "upper": [80, 255, 255] }, "blue": { "lower": [100, 100, 60], "upper": [130, 255, 255] } }调参顺序建议是:先固定 S、V 到较宽范围(比如 S 80~255、V 60~255),只调 H 把颜色种类框住;再收 S 下限排除灰白区域;最后调 V 下限排除暗部。每调一次看掩膜窗口,白色区域就是被选中的像素,比看最终结果直观。光照偏黄的车间里,红色 H 上限可能要压到 8,绿色整体往低挪,这些都得现场试,没有万能值。
5. 避坑与排查:那些让我加班到深夜的坑
5.1 运行报 DllNotFoundException
现象:编译通过,一运行就抛DllNotFoundException: OpenCvSharpExtern。原因:只引了OpenCvSharp4托管包,没引runtime.win原生包,或者两个包版本不一致导致原生 dll 没被正确拷贝。解决:在 csproj 里补齐 runtime 包并对齐版本,清理bin/obj重新还原;发布时确认输出目录里有OpenCvSharpExtern.dll,没有就是拷贝环节出了问题。
5.2 红色总是识别不全
现象:偏橙的红能识别,偏紫的红漏掉,或者反过来。原因:红色在 HSV 色相环上跨 0 点,单段InRange覆盖不全。解决:按 3.2 节拆成低区和高区两段,BitwiseOr合并。顺带检查 H 是否误用了 0~360 的取值,OpenCV 里上限是 179。
5.3 摄像头帧率越跑越低、内存持续上涨
现象:跑几分钟后画面卡顿,任务管理器里内存一路涨。原因:循环里创建的Mat没释放,非托管内存泄漏。解决:所有new Mat()用using包裹,或在循环末尾显式Dispose();DrawResult里bgr.Clone()出来的图也要释放。我一般会在循环里加个帧计数,每 100 帧打印一次内存占用,涨得明显就回头查释放。
5.4 换台机器阈值全失效
现象:开发机上好好的,部署到现场识别率暴跌。原因:现场光源色温、摄像头白平衡和开发环境不同,HSV 分布整体偏移。解决:到现场重新采样调阈值,别指望一套参数通吃;条件允许的话加一个白平衡校准步骤,或者用一张标准色卡先做参考。这也是为什么阈值要外置成配置,现场改配置比重新编译发版快得多。
5.5 轮廓数量比实际物料多
现象:一个物料被识别成两三个框。原因:物料表面有反光或纹理,掩膜被切成几块;或者形态学开运算核太小,噪点没去干净。解决:加大开运算核到 7×7 或改用闭运算先连通再开运算;对轮廓做面积和长宽比双重过滤;反光严重的场景考虑换漫射光源,这属于硬件层面的后悔药,软件只能缓解。
6. 二次开发:把阈值配置和识别逻辑做成可复用的服务
真要把它用进项目,第一件事是把ColorDetector从「写死红色」改成「按配置识别任意颜色」。我的做法是定义一个颜色配置类,把每种颜色的多段 HSV 区间、最小面积、显示颜色都收进去,识别时遍历配置返回带标签的结果:
public record ColorRange(string Name, Scalar[] Lowers, Scalar[] Uppers, int MinArea); public List<(string Name, Rect Box)> DetectAll(Mat bgr, List<ColorRange> ranges) { var hsv = ConvertToHsv(bgr); var results = new List<(string, Rect)>(); foreach (var r in ranges) { using var mask = new Mat(); // 多段区间逐段 InRange 后按位或 for (int i = 0; i < r.Lowers.Length; i++) { using var seg = new Mat(); Cv2.InRange(hsv, r.Lowers[i], r.Uppers[i], seg); if (i == 0) seg.CopyTo(mask); else Cv2.BitwiseOr(mask, seg, mask); } foreach (var c in FindColorContours(mask, r.MinArea)) results.Add((r.Name, Cv2.BoundingRect(c))); } return results; }这样加一种颜色只改配置不改代码,红色那种多段区间也天然支持。验证方法很直接:准备一组覆盖各颜色的样例图,跑DetectAll断言返回的标签和数量,做成单元测试,以后调阈值先跑测试再上现场,比肉眼盯画面靠谱。另一个实用技巧是把掩膜和结果图都存盘,出问题时回看是哪一步丢的,比在日志里猜强。
还有个我踩过的坑值得说:Mat在using作用域里被CopyTo到外部后,外部拿到的是副本没问题,但如果直接把mask引用传出去,作用域结束就释放了,后续访问会抛异常。所以对外返回的图一律Clone()或CopyTo到新对象。从那以后我每次写涉及Mat的方法,都强制走一遍「谁创建谁释放、对外只给副本」的检查,省了不少排查时间。希望帮到你。
本文还有配套的精品资源,点击获取