ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

C#图像处理入门:OpenCvSharp环境搭建与图片灰度化实战

C#图像处理入门:OpenCvSharp环境搭建与图片灰度化实战 最近在搞C#桌面端的图像处理折腾了一圈最后还是选了OpenCvSharp。这库用起来确实顺手但网上中文资料比较零散尤其是从零开始的环境搭建和基础操作很多文章都一笔带过。我把自己踩过的坑和整理好的笔记发出来给刚入门的兄弟姐妹们做个参考。这篇笔记主要讲最基础的三件事怎么打开一张图片、怎么把彩色图转成灰度图、怎么把处理结果保存到本地。虽然都是入门操作但里面藏着不少细节比如路径编码问题、Mat内存释放、保存质量参数这些搞不明白后面写复杂项目的时候会非常痛苦。先说明一下我的开发环境是Visual Studio 2022 .NET 6.0OpenCvSharp用的是4.7.0版本。环境不同问题可能不太一样但核心逻辑是通用的。1. 为什么要用OpenCvSharp而不是别的方案做C#图像处理大体上有三条路直接调原生OpenCV的C接口痛苦程度极高、用Emgu CV这类封装库、或者用OpenCvSharp。我之所以选OpenCvSharp主要是看中它这几个特点。1.1 封装风格更贴近原生OpenCVOpenCvSharp基本上是把C版OpenCV的API原样搬到了C#里类名、方法名、参数顺序都高度一致。这意味着什么意味着你在网上搜到的绝大多数OpenCV代码尤其是Python写的那些都能比较容易地把思路平移过来只需要把Python风格的调用改成C#语法就行。比如Python里写img cv2.imread(test.jpg) gray cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) cv2.imwrite(gray.jpg, gray)用OpenCvSharp写Mat img Cv2.ImRead(test.jpg); Mat gray new Mat(); Cv2.CvtColor(img, gray, ColorConversionCodes.BGR2GRAY); Cv2.ImWrite(gray.jpg, gray);结构几乎一一对应这对需要经常参考官方文档和社区代码的开发者来说学习成本能省下一大截。1.2 内存管理机制更适合C#开发者用过其他某些封装库的朋友应该深有体会有些库在图像对象释放上非常折腾稍不留神就内存泄漏。OpenCvSharp的Mat类实现了IDisposable接口可以用using语句管理也可以直接依赖垃圾回收机制。当然最佳实践还是手动释放这个后面细说。核心的图片容器Mat在OpenCvSharp里就是图像数据的载体理解了Mat的底层结构很多操作就好懂了。Mat本质上是一个多维数组存着像素数据和元信息元信息里最关键的就是通道数和数据类型。1.3 部署相对简单OpenCvSharp发布包会自动带上所需的原生DLL文件不像某些库需要你手动配置一大堆环境变量。对于做桌面工具、快速原型验证的场景来说这一点非常省心。2. 环境准备五步搭好开发环境动手写代码之前先把环境配好。这一步卡住了不少人尤其是刚接触NuGet包管理的朋友。2.1 创建项目并安装NuGet包打开Visual Studio创建一个控制台应用或者Windows Forms应用都可以。建议先从控制台应用开始排除UI干扰专心搞懂图像处理的逻辑。创建好项目之后右键项目名称选择“管理NuGet程序包”在浏览选项卡里搜索OpenCvSharp4会有好几个相关包我的建议是装这两个OpenCvSharp4主库包含核心APIOpenCvSharp4.runtime.winWindows平台的原生运行库提示OpenCvSharp4.runtime.win这个包一定要装很多人只装了主包结果一运行就报错DllNotFoundException就是这个原因。我用的是4.7.0版本目前最新稳定版功能都差不多直接用最新版就行。2.2 确认目标平台设置装完包之后有一个很多人不知道的坑默认的目标平台。新创建的项目如果选的是AnyCPU运行的时候可能会出现奇怪的问题建议直接改成x64。右键项目选择“属性”找到“生成”选项卡把“平台目标”改成x64。这个操作虽然简单但能避免很多莫名其妙的运行时报错。2.3 验证安装是否成功写一个最简单的验证程序在Main方法里输出版本号using OpenCvSharp; Console.WriteLine(OpenCvSharp.NativeMethods.Version);如果能正常打印出版本号说明环境已经没问题了可以开始写正规的代码了。3. 图片读取ImRead的前世今生与隐藏细节图片读取是整个图像处理流程的第一步这一步如果出了问题后面全都白搭。很多朋友在读取图片时经常遇到读出来的Mat是空的半天找不到原因。3.1 ImRead核心用法OpenCvSharp读取图片用的是Cv2.ImRead方法最常见的有两种重载形式// 方式一默认按彩色图加载 Mat img1 Cv2.ImRead(D:\images\test.jpg); // 方式二指定读取模式 Mat img2 Cv2.ImRead(D:\images\test.jpg, ImreadModes.Grayscale);方式一默认会以BGR三通道的彩色图方式加载图片即使原图是灰度图也会被转成三通道输出。方式二如果指定了Grayscale模式加载出来的就是单通道灰度图后续处理可能需要多注意通道数。ImreadModes枚举里还有其他选项比如IgnoreOrientation、Unchanged等日常开发用得较多的还是默认方式。3.2 路径中不能有中文——一个顽固的坑这是OpenCvSharp新手最常见的坑之一图片路径带中文ImRead直接返回空Mat。// 这段代码可能会返回空的Mat Mat img Cv2.ImRead(D:\图片\测试.jpg); // 判断是否为空 if (img.Empty()) { Console.WriteLine(图片读取失败); }原因在于OpenCvSharp内部调用的原生OpenCV库而OpenCV的imread函数不支持非ASCII路径。这在英文系统上没问题但中文Windows上路径经常带中文很容易中招。解决方案有好几种最推荐的是先把图片转成字节数组再用Cv2.ImDecode解码// 用FileStream读图片为字节数组再用ImDecode解码 byte[] bytes File.ReadAllBytes(D:\图片\测试.jpg); Mat img Cv2.ImDecode(bytes, ImreadModes.Color);这个方法完美绕开了路径编码问题因为File.ReadAllBytes是.NET原生的对Unicode路径有很好的支持。注意如果路径里包含中文而且必须用ImRead这种方式可以通过Windows的短路径名称先拿到不含中文的临时路径然后复制图片过去再读取。但这个方法实在麻烦我后来统一换成了ImDecode方案一劳永逸。3.3 判断图片是否读取成功无论用哪种方式读取拿到Mat之后第一件事就是检查它是不是空的if (img null || img.Empty()) { Console.WriteLine(图片读取失败请检查路径和文件是否正确); return; } Console.WriteLine($图片尺寸{img.Width} x {img.Height}); Console.WriteLine($通道数{img.Channels()});为什么要及时检查空Mat如果继续对一个空的Mat做处理程序会抛出异常或者给出一个荒谬的结果到时候排查起来更麻烦。所以养成读取后立即判断的习惯能省下不少调试时间。3.4 Mat对象是图像的根本理解了Mat就理解了OpenCvSharp百分之六十的核心。Mat对象由两部分组成头部矩阵头和数据区。头部记录了图像的大小、数据类型、通道数等信息数据区是真正存像素值的连续内存区域。在使用Mat时另外一个需要特别说明的点是Mat对象有引用计数的特性。多个变量可以指向同一块数据修改其中一个会影响其他所有引用者。如果需要一份独立的拷贝一定要用Clone()方法Mat a new Mat(test.jpg, ImreadModes.Color); Mat b a; // b和a共享同一份图像数据 Mat c a.Clone(); // c是a的完全独立拷贝4. 灰度化原理、实现与常见误区灰度化是图像处理里基础得不能再基础的操作但大多数教程只给一行代码不讲背后的原理导致很多人遇到问题不知从何入手。4.1 灰度图像的本质灰度图像说人话就是每个像素只有一个亮度值范围从0黑到255白一共256个级别。相比彩色图像的三个通道灰度图只有一个通道数据处理量直接降到三分之一。做灰度化处理不只是为了美观更是为了简化计算。很多识别算法需要先转灰度图再做二值化、边缘检测等操作因为灰度图消除了颜色信息干扰突出的是亮度特征和纹理信息。4.2 彩色转灰度的三种主流算法彩色图像每个像素是三个值B、G、R转灰度就是要把这三个值按某种规则计算成一个0到255的亮度值。常用的算法有这么几种第一种平均值法。把B、G、R三个值加起来除以三做法最粗暴。但问题在于人眼对三种颜色的敏感度不一样直接平均出来的灰度图看起来会不太自然。第二种加权平均法。这是OpenCV默认采用的方式公式是Gray 0.114 * B 0.587 * G 0.299 * R系数不是随便拍的而是根据人眼对不同颜色光线的感知灵敏度制定的绿色在自然光下最亮、人眼最敏感蓝色最不敏感。第三种取最大值法。直接取三个通道中最大的值作为灰度值图像整体会偏亮一般用得少。OpencvSharp里没有直接暴露这种加权计算过程而是提供了封装好的CvtColor接口相当于帮你把数学公式藏起来了。4.3 CvtColor灰度化完整示例using OpenCvSharp; // 读取彩色图片 using Mat colorImg Cv2.ImRead(D:\images\sample.jpg, ImreadModes.Color); if (colorImg.Empty()) { Console.WriteLine(图片读取失败); return; } // 转为灰度图 Mat grayImg new Mat(); Cv2.CvtColor(colorImg, grayImg, ColorConversionCodes.BGR2GRAY); Console.WriteLine($彩色图通道数{colorImg.Channels()}); // 输出3 Console.WriteLine($灰度图通道数{grayImg.Channels()}); // 输出14.4 灰度化后的数据变化灰度化不只是通道数变了内存占用也直接下降。举个例子一张1920x1080的彩色BGR图每个像素有3个字节内存占用约为192010803 6220800字节约5.93MB。而灰度图只有1个字节每像素内存约为1.98MB数据量缩水三分之二。这对大批量处理图片的性能影响非常明显。我做过一个实验对100张1200万像素的照片做简单的轮廓提取直接处理彩色图比先转灰度图再处理慢了接近3倍。所以如果后续处理不依赖颜色信息灰度化是性能优化的第一步。4.5 灰度化的一个重要误区通道顺序很多从其他图像库转过来的朋友会在通道顺序上踩坑。OpenCV系默认的彩色图通道顺序是BGR不是RGB。如果按RGB的习惯去拆通道拆出来会发现B和R是反的。// 如果想单独查看某个通道 Mat[] channels new Mat[colorImg.Channels()]; Cv2.Split(colorImg, out channels); // channels[0] 是蓝色通道 // channels[1] 是绿色通道 // channels[2] 是红色通道5. 图片保存ImWrite的选项参数与注意事项处理完图片怎么把结果保存下来也是一门学问。保存这一步看似简单其实也暗藏好几个容易忽略的点。5.1 ImWrite基础用法最基础的保存代码非常简洁// 保存灰度图 Cv2.ImWrite(D:\output\gray.png, grayImg);第一个参数是保存路径第二个参数是Mat对象。保存格式默认由文件扩展名决定存成.jpg就是JPEG格式存成.png就是PNG格式不需要额外指定编码器。5.2 中文路径问题同样存在和ImRead一样ImWrite对中文路径也不友好。解决办法完全一样先把图片编码成字节数组再用File.WriteAllBytes写文件// 先生成PNG格式的字节数据 byte[] data Cv2.ImEncode(.png, grayImg); // 再写入目标路径支持中文路径 File.WriteAllBytes(D:\输出\灰度图.png, data);注意Cv2.ImEncode第一个参数要带点号写成.png而不是png这是容易出错的地方。5.3 JPEG压缩质量的微妙处理如果你保存的是JPEG格式可以通过ImWriteParams控制压缩质量var paramsJPEG new ImageEncodingParam(ImwriteFlags.JpegQuality, 95); Cv2.ImWrite(D:\output\compressed.jpg, colorImg, paramsJPEG);为什么要特别关注JPEG质量因为在图像处理流程中如果多次保存JPEG每次保存都会有损压缩画质会肉眼可见地下降。如果是中间过程需要暂存图片建议用PNG这种无损格式只有最终输出需要小体积文件时才用JPEG并且质量参数尽量设置在90以上。JPEG质量参数范围是0到100数值越大质量越高文件也越大。95是一个比较靠谱的平衡点视觉上几乎无损体积也不会太夸张。如果保存PNG格式可以设置压缩级别// PNG压缩级别0-9默认6 var paramsPNG new ImageEncodingParam(ImwriteFlags.PngCompression, 6); Cv2.ImWrite(D:\output\result.png, grayImg, paramsPNG);PNG的压缩级别影响的是压缩耗时和文件体积不影响画质。实测下来级别太高压缩速度会变慢但文件体积减小有限日常用默认的6就行。5.4 保存前再次确认目标目录存在这是一个很常见的报错来源目标目录不存在ImWrite直接失败或者抛异常。稳妥的做法是string outputPath D:\output\result.png; string directory Path.GetDirectoryName(outputPath); if (!Directory.Exists(directory)) { Directory.CreateDirectory(directory); } Cv2.ImWrite(outputPath, grayImg);6. 完整示例从打开到灰度化再到保存把上面的知识点串起来写一个完整的示例程序。这段代码我在实际项目中直接用过路径中文、PNG质量参数、内存释放都处理了。using OpenCvSharp; class Program { static void Main(string[] args) { string inputPath D:\素材\风景照.jpg; string outputPath D:\处理结果\灰度图.png; try { // 1. 读取图片使用ImDecode解决中文路径问题 byte[] bytes File.ReadAllBytes(inputPath); using Mat colorImg Cv2.ImDecode(bytes, ImreadModes.Color); // 2. 检查图片是否有效 if (colorImg.Empty()) { Console.WriteLine(图片读取失败); return; } Console.WriteLine($原图尺寸{colorImg.Width} x {colorImg.Height}); Console.WriteLine($原图通道数{colorImg.Channels()}); // 3. 灰度化 Mat grayImg new Mat(); Cv2.CvtColor(colorImg, grayImg, ColorConversionCodes.BGR2GRAY); Console.WriteLine($灰度图通道数{grayImg.Channels()}); // 4. 保存结果 string dir Path.GetDirectoryName(outputPath); if (!Directory.Exists(dir)) { Directory.CreateDirectory(dir); } var pngParams new ImageEncodingParam(ImwriteFlags.PngCompression, 6); Cv2.ImWrite(outputPath, grayImg, pngParams); Console.WriteLine($灰度图已保存{outputPath}); } catch (Exception ex) { Console.WriteLine($发生异常{ex.Message}); } } }运行这段程序控制台会输出类似下面的信息原图尺寸1920 x 1080 原图通道数3 灰度图通道数1 灰度图已保存D:\处理结果\灰度图.png6.1 关于using和Dispose的使用时机在示例代码里我用了using关键字来管理Mat的生命周期using Mat colorImg Cv2.ImDecode(bytes, ImreadModes.Color);这种写法会自动在变量超出作用域时释放Mat持有的非托管内存。为什么要强调这个OpenCvSharp的Mat对象所占的非托管内存不会被.NET的垃圾回收机制自动管理如果没有及时Dispose在批量处理图片时内存占用会不断攀升最终可能导致OutOfMemoryException。灰度图的grayImg变量也应该在不再使用后调用Dispose()或者也用using语法包裹起来。不过要注意如果后面还需要用就不能提前释放。6.2 使用场景扩展批量处理文件夹内所有图片学会了单张处理批量处理就顺理成章了。我这里贴一段批量处理目录下所有jpg文件的代码在实际工程项目中会经常用到string inputDir D:\素材; string outputDir D:\处理结果; Directory.CreateDirectory(outputDir); foreach (string file in Directory.GetFiles(inputDir, *.jpg)) { string fileName Path.GetFileNameWithoutExtension(file); string outputFile Path.Combine(outputDir, fileName _gray.png); byte[] bytes File.ReadAllBytes(file); using Mat img Cv2.ImDecode(bytes, ImreadModes.Color); if (img.Empty()) continue; using Mat gray new Mat(); Cv2.CvtColor(img, gray, ColorConversionCodes.BGR2GRAY); byte[] outBytes Cv2.ImEncode(.png, gray); File.WriteAllBytes(outputFile, outBytes); Console.WriteLine($已处理{fileName}); }这段代码的核心点在于输入输出都用文件流方式绕开中文路径问题大量图片处理时每个Mat都用using管理保证内存能及时释放中间过程保存PNG格式避免多次保存JPEG导致画质下降。7. 高频问题排查运行报DLL异常及其他典型问题新手最容易在环境配置阶段被各种运行时报错劝退。这里把最高频的几种情况集中整理一下遇到问题直接对照排查。7.1 System.DllNotFoundException: Dll无法找到这是最典型的报错没有之一。报错信息类似Unhandled exception. System.DllNotFoundException: Unable to load DLL OpenCvSharpExtern: 找不到指定的模块。出现这个报错九成是缺少了OpenCvSharp4.runtime.win这个NuGet包或者该包没有正确复制到输出目录。检查方案打开项目文件.csproj确认有如下依赖PackageReference IncludeOpenCvSharp4 Version4.7.0.20230115 / PackageReference IncludeOpenCvSharp4.runtime.win Version4.7.0.20230115 /再检查输出目录里的run文件夹是否存在里面应该有x64和x86两个子目录。如果都没有重新安装NuGet包试试。7.2 图片读取后是Null或者Empty这个问题的根源上面分析过大概率是路径中文导致的。用ImDecode方法替代ImRead可以解决。另外还要检查图片文件本身是否损坏用系统自带图片查看器打开确认一下。一个小技巧是读取后立即打印尺寸信息Console.WriteLine($Width: {img.Width}, Height: {img.Height});如果输出是0 x 0说明图片根本没读进来。7.3 保存的图片全是黑的灰度化后保存出来的图片全黑大概率是Mat的数据有问题。常见原因有两个一是图像本来就是全黑的检查一下源图像二是在某个环节把数据传错了。可以用Cv2.ImShow窗口方式预览一下处理结果Cv2.ImShow(预览, grayImg); Cv2.WaitKey(0);这样能在界面上直接看到灰度图的实际效果便于判断是处理逻辑的问题还是保存环节的问题。7.4 平台目标导致的不兼容异常如果在发布或运行的时候报出类似BadImageFormatException的异常多半是平台目标设置不对。参考2.2节把平台目标统一改成x64即可。7.5 Release和Debug模式下行为不一致有些朋友碰到过这样的怪事Debug模式下运行正常切到Release就开始崩。这大概率也和运行库加载路径有关建议检查两个模式下的输出目录看看run文件夹是否都被正确复制。另外建议发布程序时把RuntimeIdentifier显式指定为win-x64例如在csproj中添加RuntimeIdentifierwin-x64/RuntimeIdentifier这样发布的版本更干净不会带上不必要的运行时标识。8. 一份值得收藏的踩坑清单把这一路走来的经验浓缩一下给各位划重点。路径问题用ImDecode/ImEncode只要涉及中文路径直接不用ImRead/ImWrite改用File.ReadAllBytes ImDecode的读取方案和ImEncode File.WriteAllBytes的保存方案。这是一劳永逸的解法。尽早验证图片有效性图片读完立刻检查Empty属性别等处理完才发现是空图白白浪费计算资源。记住BGR不是RGBOpenCV系的通道顺序是BGR这在多平台联调时特别容易出问题。灰度化后的通道数是1后续很多算法对输入图片有通道数要求操作之前先确认一下当前Mat的通道数是否符合要求。及时释放Mat处理大批量图片时每个Mat都要用using包裹或者手动Dispose否则内存会爆炸。中间过程忍住别存JPEG每存一次JPEG就有一次画质损失多次迭代处理时务必使用PNG格式。最终保存JPEG时质量设到90以上如果不是特别在意体积建议用95视觉上基本无损。安装NuGet包时别漏了runtime包OpenCvSharp4和OpenCvSharp4.runtime.win是两个不同的包只装前面那个运行时会报DLL缺失。工具是用来解决问题的不是用来折腾的。OpenCvSharp这个生态相对成熟坑虽然不少但绝大多数都有成熟的解法记录下来对后面做项目很有帮助。接下来我打算写第二篇笔记主题是图像的基本操作——裁剪、缩放、旋转和像素访问这些是更复杂的图像处理算法的基础。如果这篇笔记对你有用或者你觉得哪里有遗漏欢迎在评论区交流。下一篇见。
返回列表