首页 / 资讯中心 / 文章详情

OpenCvSharp4.1例程实战:Mat、Canny与视频链路调参排错

OpenCvSharp4.1例程实战:Mat、Canny与视频链路调参排错 ★ FEATURED ARTICLE
简介面向 C# 开发者的 OpenCvSharp4.1 官方例程合集适合已具备一定 .NET 基础、希望对照官方示例学习 OpenCV 4.1 图像处理与计算机视觉 API 的读者。包内共 176 个文件以 60 个 cs 源码和少量 vb 示例为主体辅以 jpg、png、bmp 等示例图像、mp4 演示视频、项目配置、解决方案及 prototxt 等模型描述文件压缩包约 37.25MB目录按示例与工程文件组织便于检索和直接运行调试。已有 13192 人学习下载说明其在 C# 视觉入门与示例查阅场景中具有一定参考价值。读者可从中获得图像读写、二值化处理等基础案例的完整工程结构对照源码理解 OpenCvSharp 的调用方式、参数设置与常见排错思路也可将其作为课程实验、项目原型或自有应用的起步模板。1. OpenCvSharp4.1 例程包到底装了什么先跑通再拆原理拿到 OpenCvSharp4.1 官方例程的第一晚我建议先别动代码原样跑一遍比读十页文档管用。它是 OpenCV 4.1 的 C# 封装配套示例集合覆盖 Mat 读写与像素访问、色彩空间转换、滤波与边缘检测、轮廓分析、摄像头取流和视频写出再往外一圈是 WinForms/WPF 的显示封装。适合两类人想把手上 Python 视觉脚本迁到 C# 桌面端的开发者以及需要在 .NET 项目里嵌图像处理、又不想自己写 P/Invoke 的工程师。下面按「跑通—拆解—调参—排错」的顺序把拆包时的判断和参数选择写清楚能照着复现。2. 环境搭起来四个包的职责边界与第一个出图的 Mat 程序例程包本身不带运行环境能不能跑起来几乎全看 NuGet 引用对不对。这一章先把包结构讲清楚再建一个最小工程验证链路通不通最后讲 Mat 的内存模型——这块不清楚后面所有例程都会以内存暴涨的形式还回来。2.1 四个包的职责边界与常见引错OpenCvSharp 的包拆得很细职责基本是这样的包名作用缺了它会怎样OpenCvSharp4托管层 APIMat、Cv2、VideoCapture 等编译期就报找不到类型OpenCvSharp4.runtime.win原生 OpenCvSharpExtern.dll 与 ffmpeg 视频解码库编译能过运行时 DllNotFoundExceptionOpenCvSharp4.ExtensionsMat 与 System.Drawing.Bitmap 互转WinForms 显示这一环断掉OpenCvSharp4.WpfExtensionsMat 与 WPF BitmapSource 互转WPF 下拿不到 WriteableBitmap关键点在于版本对齐托管包和 runtime 包必须是同一个 4.1 分支版本。C# 侧调用的导出函数签名跟着托管包走原生 OpenCvSharpExtern.dll 的导出表跟着 runtime 包走两者错位最典型的症状是EntryPointNotFoundException而且报错信息指向一个看起来毫不相干的函数名第一次遇上会以为是包坏了。另一个硬约束是位数。原生 dll 分 x86 和 x64进程位数必须一致。C# 工程默认是 AnyCPU在 64 位系统上跑成 64 位进程但一旦勾了「首选 32 位」进程变成 32 位加载 x64 的 dll 直接失败。我一般做法是显式把 PlatformTarget 钉死成 x64不给运行时留选择余地。2.2 从零建工程三条命令加一个 csproj用命令行建工程最快避免在 IDE 里点来点去点到某个默认选项上。# 建一个控制台工程名字随便起 dotnet new console -n MatDemo cd MatDemo # 托管包与原生运行库包版本都跟 4.1 分支对齐 dotnet add package OpenCvSharp4 -v 4.1.* dotnet add package OpenCvSharp4.runtime.win -v 4.1.*版本号这里用4.1.*浮动写法具体补丁号以包管理器里 4.1 分支实际的最新版为准不要跨大版本混装。工程文件里再补一行平台目标Project SdkMicrosoft.NET.Sdk PropertyGroup OutputTypeExe/OutputType TargetFrameworknetcoreapp3.1/TargetFramework PlatformTargetx64/PlatformTarget !-- 钉死位数避免 AnyCPU 在 32 位上翻车 -- AllowUnsafeBlockstrue/AllowUnsafeBlocks !-- 例程里有指针遍历像素的写法 -- /PropertyGroup /ProjectAllowUnsafeBlocks不是必须但例程里访问像素有几个地方用的是Mat.DataPointer加unsafe指针先开着能省一次重编译。目标框架方面4.1 这一代在 .NET Framework 4.6.1 以上和 .NET Core 3.x 上都能跑桌面端拿 WinForms/WPF 做壳的选前者也行逻辑基本不用改。2.3 第一个能出图的程序Mat 从读到显示不要一上来就跑最复杂的那个例程先用二十行代码确认读图、处理、显示、保存四条通路都通。using OpenCvSharp; // 读图flags 决定通道数Color 是 3 通道 BGR using var src Cv2.ImRead(sample.jpg, ImreadModes.Color); if (src.Empty()) { // 路径错、文件损坏、编码不受支持都会走到这里提前退出比后面崩在 CvtColor 里好排查 Console.WriteLine(读图失败检查路径或换个格式); return; } // 灰度与边缘中间结果都放在独立的 Mat 里方便单独保存查看 using var gray new Mat(); Cv2.CvtColor(src, gray, ColorConversionCodes.BGR2GRAY); using var edges new Mat(); Cv2.Canny(gray, edges, 50, 150); // 显示窗口名不要用中文部分平台的 highgui 对宽字符支持不一致 Cv2.ImShow(src, src); Cv2.ImShow(edges, edges); Cv2.WaitKey(0); // 0 表示无限等待按键阻塞在这一行直到窗口有输入 Cv2.DestroyAllWindows(); // 不调用的话后续循环里 ImShow 会不停新建窗口几个参数值得记一下ImreadModes.Color强制三通道即使原图是灰度或带 alpha 也会被转成 3 通道Canny的三个参数分别是输入、输出和两个阈值后文会专门讲怎么定这两个数WaitKey(0)返回按键码传 0 是无限等传 1 就是等 1 毫秒视频播放循环里必须传非零值这一点后面还会碰到。这段代码能跑通说明托管包、原生 dll、highgui 三条链路都在位。跑不通的话问题基本就落在第 5 章那几条上。2.4 Mat 的内存模型为什么到处都在 usingMat 对象本身在托管堆上像素数据在非托管内存里靠引用计数管理。GC 只管得到那个壳管不到像素缓冲区所以Dispose必须显式调用using写法是最省心的方式。更麻烦的是 Mat 支持视图从一个 Mat 上切一块 ROI新 Mat 和原 Mat 共享同一片像素内存。using var src Cv2.ImRead(sample.jpg, ImreadModes.Color); // 这个 Mat 只是视图不拷贝像素指向 src 内部的一块区域 using var roi new Mat(src, new Rect(100, 100, 200, 200)); // Clone 才是真正申请新内存、复制像素 using var copy roi.Clone(); roi.SetTo(Scalar.All(0)); // src 上的对应区域一起变黑 // copy 不受影响搞不清这一点常见后果是「我明明只改了缩略图原图也花了」。判断方法很简单构造函数带new Rect(...)或者new Range(...)的那种多半是视图带.Clone()、.CopyTo()的才是独立内存。写循环处理时视图的好处是省内存坏处是生命周期互相牵连——视图还没释放原 Mat 的引用计数就降不到零那片内存就一直挂着。至于例程目录怎么读我的习惯是按依赖关系从简到繁来而不是按字母序典型分组覆盖内容建议顺序基础图像ImRead/ImWrite、像素访问、Mat 运算第一图像处理色彩空间、阈值、滤波、形态学、边缘第二轮廓与几何FindContours、包围盒、多边形拟合第三视频与摄像头VideoCapture、VideoWriter、帧循环第四界面与显示WinForms/WPF 控件绑定、缩放与刷新第五检测类级联分类器、DNN 推理加载最后包内实际目录可能略有出入但依赖关系不会变。先把基础图像那组跑完能省掉后面大量「到底是 API 用错还是环境没通」的反复猜。3. 例程逐类拆解ImRead、CvtColor、Canny、FindContours 的参数怎么定这一章是例程包里最值钱的部分也是大多数人跑完就跳过、后面写业务代码时又回头翻的部分。图像处理的参数没有万能值但每一个都有可以量化的定法下面按调用链顺序讲。3.1 读写与色彩空间通道顺序和 HSV 的取值范围OpenCV 的默认通道顺序是 BGR不是 RGB。C# 里从 Bitmap 转过来、或者从摄像头抓帧色序都是 BGR直接送给 WPF 或 WinForms 显示会和原图对不上蓝色发红。转换方式在显示环节做不要在算法入口做。using var src Cv2.ImRead(sample.jpg, ImreadModes.Color); using var hsv new Mat(); Cv2.CvtColor(src, hsv, ColorConversionCodes.BGR2HSV); // HSV 里 H 在 OpenCV 中被压到 0~179不是 0~360 // 所以要取「红色」范围得用 0~10 和 170~179 两段而不是 0~30 和 330~360 using var mask1 new Mat(); using var mask2 new Mat(); Cv2.InRange(hsv, new Scalar(0, 80, 60), new Scalar(10, 255, 255), mask1); Cv2.InRange(hsv, new Scalar(170, 80, 60), new Scalar(179, 255, 255), mask2); using var redMask mask1 | mask2; // 两段合并三个参数的含义H 是色相OpenCV 为了塞进 8 位无符号数除以了 2所以上限是 179S 是饱和度0 到 255低于 80 的基本是灰白区域V 是明度低于 60 接近纯黑。这三个下限不是随手写的室内弱光场景 V 要往下调到 40 左右否则暗部目标全丢强光过曝场景 S 要往下调否则高光区域饱和度被压扁。ImreadModes那几个常用值也顺便记一下Color强制三通道Grayscale单通道Unchanged保留原始通道和位深——读 16 位 TIFF 或带 alpha 的 PNG 时只有Unchanged不会丢数据其余模式都会做隐式转换精度掉了自己都不知道。3.2 滤波与边缘两个阈值到底怎么定高斯模糊的核尺寸必须是奇数传偶数会直接抛异常。sigma 传 0 表示由核尺寸反推这是最常用的组合因为手动调 sigma 和核尺寸两个参数很容易互相打架。using var src Cv2.ImRead(sample.jpg, ImreadModes.Grayscale); // ksize 必须是奇数sigmaX 传 0 由 ksize 推导 using var blur new Mat(); Cv2.GaussianBlur(src, blur, new Size(5, 5), 0); // Canny 的双阈值低于低阈值直接判非边缘高于高阈值直接判边缘中间地带看连通性 using var edges new Mat(); Cv2.Canny(blur, edges, 50, 150);两个阈值的经典比例是 1:2 到 1:3问题在于绝对数值随场景变化极大。一个可量化的定法是用灰度均值做基准Cv2.MeanStdDev(blur, out Scalar mean, out Scalar stddev); // 以均值上下浮动 33% 作为双阈值再夹到合法区间 double lower Math.Max(0, (1.0 - 0.33) * mean.Val0); double upper Math.Min(255, (1.0 0.33) * mean.Val0); using var autoEdges new Mat(); Cv2.Canny(blur, autoEdges, lower, upper); Console.WriteLine($lower{lower:F1}, upper{upper:F1});MeanStdDev输出两个 Scalar这里只取第一个通道的Val0灰度图正好只有这一个通道有意义。0.33 这个系数来自经验偏低会把纹理噪声都拉成边缘偏高会丢掉弱对比轮廓。调试阶段建议把算出来的两个数打印出来下次遇到同类场景直接从这组数起步比盲调快得多。模糊核尺寸和 Canny 是联动的。核太大细边缘在模糊阶段就没了怎么调阈值都找不回来核太小噪声进到 Canny 里阈值再高也会有零星碎边。常见做法是先用 5×5 起步边缘太碎把核加到 7×7边缘太粗回到 3×3。3.3 形态学与轮廓hierarchy 和面积过滤形态学的两个基本操作是开运算和闭运算前者去小噪点后者补小孔洞结构元素尺寸决定能处理多大的缺陷。// 开运算先腐蚀后膨胀去掉细小噪点 using var kernel Cv2.GetStructuringElement(MorphShapes.Rect, new Size(3, 3)); using var opened new Mat(); Cv2.MorphologyEx(edges, opened, MorphTypes.Open, kernel); // 闭运算先膨胀后腐蚀填补轮廓内部的小断裂 using var closed new Mat(); Cv2.MorphologyEx(opened, closed, MorphTypes.Close, kernel);GetStructuringElement的形状参数里Rect适合矩形目标Ellipse适合圆形或自然目标Cross适合细线状结构。尺寸必须是奇数3×3 是最常用的起点处理的是像素级的毛刺要到 7×7 以上才能填补肉眼可见的缺口但同时会把相邻目标粘在一起这一步要拿实际图反复看。找轮廓的签名里有三个容易记错的点// 输入图建议传 clone虽然 OpenCV 3.2 之后 FindContours 不再修改输入但跨版本保险 using var input closed.Clone(); Cv2.FindContours( input, out Point[][] contours, out HierarchyIndex[] hierarchy, RetrievalModes.External, // 只要最外层轮廓 ContourApproximationModes.ApproxSimple); // 压缩共线点只保留拐点 foreach (var contour in contours) { double area Cv2.ContourArea(contour); if (area 200) continue; // 面积过滤去掉噪点轮廓 Rect box Cv2.BoundingRect(contour); double perimeter Cv2.ArcLength(contour, true); // 面积与周长平方的比值可以粗略判断形状紧致程度接近圆的目标这个值偏大 Console.WriteLine($area{area:F0} box{box} perimeter{perimeter:F0}); }RetrievalModes的选择直接影响结果External只返回最外层适合数目标个数Tree返回完整层级父子关系存在HierarchyIndex的Parent、FirstChild字段里适合做嵌套结构分析比如找某个外框里面的小孔。ApproxSimple会把一条直线上的中间点全部压缩掉点数能降一个数量级做面积和周长计算时精度损失可以忽略但如果你要拿点集做模板匹配就得换成ApproxNone。面积阈值 200 这个数不是固定的它和图像分辨率强相关。判断方法先把所有轮廓面积打印出来排序噪点通常集中在个位数到几十真实目标会有一两个明显的数量级跳跃阈值就取在跳跃中间。4. 摄像头与视频链路VideoCapture 打开失败、帧循环与 WPF 渲染图像处理是静态的视频链路是动态的问题类型完全不同。静态图调不通最多是结果不对视频链路调不通就是黑屏、卡死、内存一路涨。这一章把取流、帧循环、界面刷新、写出四段拆开。4.1 VideoCapture 打开失败的排查顺序// 第二个参数指定后端Windows 上 DSHOW 兼容性最好MSMF 打开更快但设备兼容性略差 using var cap new VideoCapture(0, VideoCaptureAPIs.DSHOW); if (!cap.IsOpened()) { Console.WriteLine(打开摄像头失败); return; } // 分辨率要在打开之后、读帧之前设置顺序反了很多设备会静默忽略 cap.Set(VideoCaptureProperties.FrameWidth, 1280); cap.Set(VideoCaptureProperties.FrameHeight, 720); cap.Set(VideoCaptureProperties.FPS, 30); // 设置不一定生效读回来确认实际值 Console.WriteLine($实际 {cap.FrameWidth}x{cap.FrameHeight} {cap.Fps});排查顺序我一般是这样先确认设备索引0 是默认摄像头多设备时逐个试 0 到 3再看后端DSHOW和MSMF换着试某些采集卡只认其中一个然后看是不是被别的程序占用摄像头是独占资源另一个进程开着预览就抢不到最后看分辨率设了一个设备不支持的尺寸有的驱动会直接失败而不是回退到最近的可用值。Set之后再Get一遍确认这一步能省掉大量「代码明明设了 1080p 为什么出来是 640」的困惑。4.2 帧循环的写法与退出条件using var frame new Mat(); while (true) { // Read 内部是 Grab Retrieve返回 false 表示流结束或取帧失败 if (!cap.Read(frame) || frame.Empty()) { Console.WriteLine(取帧失败可能是设备断开或文件读完); break; } // 处理放在这里注意不要在里面 new 大对象循环里每帧分配会触发频繁 GC Cv2.CvtColor(frame, frame, ColorConversionCodes.BGR2GRAY); Cv2.ImShow(live, frame); // 这里必须是 1 而不是 0传 0 会阻塞到按键视频就变成逐帧步进了 if (Cv2.WaitKey(1) 27) break; // 27 是 ESC }循环体里有三个细节Read复用了同一个frame对象不会每帧新建 Mat这是有意的避免每帧分配非托管内存WaitKey(1)除了控制播放节奏还负责处理 highgui 的窗口消息不调用的话窗口会假死退出条件除了 ESC还要考虑流自然结束所以Read的返回值必须判断不能只靠异常。文件播放时节奏控制另说。摄像头是实时的WaitKey(1)就够播本地视频想按原速回放得按 FPS 算延时WaitKey((int)(1000 / fps))直接传 1 会快放。4.3 在 WinForms 和 WPF 上刷新画面Cv2.ImShow只适合调试真做工具得嵌到界面里。WinForms 用 PictureBox转换靠 Extensions 包// WinForms每帧转成 Bitmap 塞给 PictureBox var bmp OpenCvSharp.Extensions.BitmapConverter.ToBitmap(frame); // 上一帧的 Image 必须手动释放否则 GDI 句柄会一路涨到系统上限 var old pictureBox1.Image; pictureBox1.Image bmp; old?.Dispose();WPF 走的是另一条路转成WriteableBitmap直接绑到 Image 控件// WPF需要 OpenCvSharp4.WpfExtensions var wb OpenCvSharp.WpfExtensions.WriteableBitmapConverter.ToWriteableBitmap(frame); imageControl.Source wb;WPF 这里有个跨线程的坑取流通常放在后台线程但给Image.Source赋值必须在 UI 线程。最省事的做法是后台线程只负责处理和转换转好的WriteableBitmap通过Dispatcher.BeginInvoke抛回 UI 线程赋值。BeginInvoke而不是Invoke是因为同步等待在帧率高的时候会把后台线程拖住反而掉帧。性能上还有一条BitmapConverter.ToBitmap每帧都会新建一个 Bitmap30fps 下每秒 30 次分配加释放GC 压力不小。分辨率上到 1080p 以后建议改成预分配一个固定尺寸的 Bitmap用LockBits直接写像素虽然代码多几行但帧率会稳很多。4.4 VideoWriter 的编码与尺寸约束// 四字符编码XVID 兼容性好MJPG 文件大但几乎所有播放器都认 using var writer new VideoWriter( out.avi, FourCC.XVID, 25, // 帧率要和写入节奏一致 new Size(1280, 720), // 必须与每帧尺寸完全一致 true); // 是否彩色 if (!writer.IsOpened()) { Console.WriteLine(VideoWriter 初始化失败多半是编码器不可用); return; } // 写帧 writer.Write(frame); // 结束必须释放否则文件尾索引不完整播放器打不开 writer.Release();三个硬约束帧尺寸必须和构造时完全一致差一个像素都会静默丢帧最后得到一个 0 字节或者时长对不上的文件帧率参数只写进文件头不影响实际写入速度写快了就是快放FourCC.XVID在部分精简版系统上可能找不到对应编码器这时换FourCC.MJPG它是 Motion-JPEG几乎不会失败代价是文件体积大好几倍。5. 避坑与排查五个把人劝退的现场例程跑不通的原因高度集中下面五条按我遇到的频率排序每条都按现象、原因、解决三段写。5.1 原生库加载失败与位数不匹配现象编译通过一运行就抛DllNotFoundException: OpenCvSharpExtern或者BadImageFormatException。原因前者是 runtime 包没装或者原生 dll 没被复制到输出目录后者是位数不匹配32 位进程去加载 64 位 dll。还有一种隐蔽情况机器上装过别的 OpenCV 版本系统 PATH 里有同名 dll加载到了错误的那份。解决先看输出目录下有没有OpenCvSharpExtern.dll和opencv_videoio_ffmpeg*.dll没有就重新装 runtime 包并清理重建有的话用dumpbin /headers看一眼架构把 PlatformTarget 显式设成 x64不要依赖 AnyCPU 的默认行为排查 PATH 污染必要时在代码里显式指定原生库搜索目录。5.2 读视频返回空帧与中文路径现象VideoCapture的IsOpened()返回 true但Read一直返回 false或者ImRead对某些图片一直返回空 Mat换个路径就好了。原因视频打不开多半是缺 ffmpeg 解码库runtime 包里带的那份没被复制过去图片读不到基本是路径含非 ASCII 字符原生层的文件打开用的是窄字符接口编码一错就找不到文件。解决视频先确认 ffmpeg 的 dll 在输出目录图片路径要么改成纯英文要么用Cv2.ImDecode(File.ReadAllBytes(path), ImreadModes.Color)绕开原生文件接口——这个写法把读文件交给 .NET解码交给 OpenCV中文路径问题直接消失我在所有交付项目里都这么处理。5.3 Mat 未释放导致内存一路涨现象跑几分钟内存从几百兆涨到几个 G任务管理器里非托管内存持续增长GC 也压不下去。原因Mat 的像素数据在非托管内存里GC 只回收托管壳不回收像素缓冲区。循环里new Mat()而不Dispose或者用 ROI 视图把原 Mat 的引用计数钉住都会造成泄漏。另一种常见情况是异常路径跳过了Dispose。解决所有 Mat 一律用using包括临时中间结果循环里复用同一个 Mat 而不是每帧新建用Cv2.GetTickCount之类的手段做压力测试跑一千帧看内存是否稳定视图用完立刻释放需要保留就Clone出独立内存。5.4 WaitKey 不调用导致窗口假死现象ImShow的窗口白屏、拖动没反应、点关闭按钮也关不掉整个进程像卡住了。原因highgui 的窗口消息要靠WaitKey驱动不调用它窗口只是创建了但不进消息循环。这一条在新手代码里出现频率极高属于典型的「玄学」问题——代码看不出错就是不响应。解决任何ImShow之后必须跟一个WaitKey静态图用WaitKey(0)视频循环用WaitKey(1)程序退出前调DestroyAllWindows()如果是在后台线程里显示注意 highgui 的窗口操作要放在同一个线程里跨线程调用会出现更难查的问题。5.5 WPF 图像不更新与跨线程异常现象后台线程更新了 Mat界面上的图一动不动或者直接抛「调用线程无法访问此对象因为另一个线程拥有该对象」。原因WPF 的 UI 元素有线程亲和性后台线程不能直接赋值Image.Source另一种情况是每帧都赋了新的WriteableBitmap但 UI 线程被别的逻辑占满渲染队列积压看上去就是不刷新。解决转换在后台做赋值用Dispatcher.BeginInvoke抛回 UI 线程WriteableBitmap可以复用同一个实例改内容后调Lock/AddDirtyRect/Unlock比每帧新建省一大截如果帧率还是上不去检查是不是把耗时处理放在了 UI 线程。6. 把例程改成自己的工具一条可复用的处理管线例程的价值不在代码本身在它给出了每条调用链的标准写法。真正落地时把这些散落的调用收成一个管线类参数从配置文件读比每次复制粘贴强得多。我一般的组织方式是一个IImageStep接口加若干实现管线负责串联每步的开关和参数写在配置里public interface IImageStep { string Name { get; } Mat Process(Mat input); // 返回新 Mat调用方负责释放 } public sealed class Pipeline { private readonly ListIImageStep _steps new(); public void Add(IImageStep step) _steps.Add(step); public Mat Run(Mat src) { var current src.Clone(); // 入口先 Clone避免改到调用方的图 foreach (var step in _steps) { var next step.Process(current); current.Dispose(); // 上一步的中间结果立刻释放 current next; } return current; } }这套写法的好处是每步都能单独开关、单独计时。加一个Stopwatch包在Process外面跑一遍就知道瓶颈在哪一步多数时候是形态学或者大核模糊而不是看起来最复杂的轮廓分析。步骤关键参数调参依据灰度转换通道顺序 BGR2GRAY输入若是 RGB 要先转序高斯模糊ksize 奇数、sigma 为 0边缘碎就加大核Canny高低阈值 1:21:3用灰度均值 ±33% 起步形态学开核 3×3 到 7×7噪点尺寸决定核大小轮廓过滤面积阈值按面积分布的数量级跳跃取验证方法我固定用两步一是拿一张标准图跑全流程把每一步的中间结果ImWrite出来存成序列肉眼过一遍找出哪一步开始不对二是跑一千帧压力测试看内存曲线是否平稳、单帧耗时是否在预算内。这两步走完参数才敢往业务里带。血泪经验是别在管线里做隐式转换。初期为了省事我在某一步里直接把 BGR 当 RGB 处理单测看不出来上线后所有颜色判定的阈值全偏排查了大半天。从那以后我每接一个 C# 视觉需求都会先把中间结果落盘、把每步耗时打出来再谈调参。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站