
做上位机的朋友应该都有过这种经历客户拿着一张零件照片过来说“这些圆点、这些斑点帮我统计一下数量标出位置”。早期我接到这类需求第一反应是人工数但几百上千个斑点真要数起来眼睛花了都数不准。后来我换成C# OpencvSharp做斑点检测又把检测逻辑封装成自定义控件一套方案做完不仅顺利交付后面换项目还能整包复用。这篇文章就把我的整体设计思路、核心参数调节、踩过的坑完整梳理一遍给同样做C#上位机、视觉检测的朋友一个能直接抄作业的参考。1. 项目背景与整体设计思路1.1 这类需求到底在解决什么问题斑点检测听起来像个算法名词但落到实际项目里本质就是一件事从一张图像里把独立的小块目标找出来并且告诉使用者这些目标在哪里、数量是多少、有多大。工业场景里最常见的例子包括药片缺角检测、螺丝/垫片计数、LED灯珠定位、PCB板上的焊点检查甚至纸张表面的污点识别。它们的共同特点是目标与背景存在灰度或颜色差异形状接近圆形或椭圆形数量可能从几十到几千不等。这种活如果人工来做重复性极高眼睛疲劳之后准确性断崖式下降。如果是批量产线人工根本扛不住节拍。即便不是产线场景只是实验室环境里的样本统计人工数数也是纯浪费人力。所以把它交给代码去跑是必然选择。我选择OpencvSharp而不是直接用OpenCV的C版本很大一部分原因是团队技术栈是C#/.NET整套上位机软件都是WinForms写的中间硬插C模块维护成本立刻拉高。OpencvSharp作为OpenCV的C#封装接口风格和OpenCV几乎一一对应网上资料也能互相参考上手快、集成成本低。实际用下来它在斑点检测、模板匹配、轮廓查找这些经典2D视觉任务上表现稳定完全能满足这类需求。1.2 为什么非要搞一个自定义控件有些人可能会问不就一个检测功能吗直接在窗体里拖个PictureBox再在按钮点击事件里写检测逻辑不就行了确实行但只适合自己临时跑个demo。真实项目里同样的检测界面可能要在多个窗体复用参数需要暴露给操作人员调整检测结果要实时显示并交互。如果逻辑散落在各个窗体的Click事件里改一处要动多个地方维护起来相当痛苦。我做成自定义控件之后把图像显示、检测逻辑、参数配置、结果绘制全部收拢在一个UserControl内部对外只暴露几个属性和事件。业务窗体只需要把图片丢进来然后监听检测完成事件就行。参数调整通过控件的属性面板就能做WinForms设计器直接支持。这样一套设计换项目时把控件编译成dll拖到新工程里就能接着用不用重新写一遍视觉逻辑。这个思路其实和写类是一样的逻辑——关注点分离。视觉检测是视觉检测业务界面是业务界面窗体只负责调起流程和展示结果。后面遇到客户要加功能比如把斑点坐标导出Excel改控件内部代码即可不会影响外部调用方。2. 自定义控件的核心设计与参数体系2.1 控件职责边界显示、检测、交互三件事动手写控件之前我先把控件的职责边界想清楚。它应该做三件事显示原始图像和检测结果、执行斑点检测算法、暴露参数与结果给外部使用。至于图像从哪来检测完之后数据存哪里那是外部调用者的事情控件不应该关心。对应到代码结构上控件由两部分组成界面层和算法层。界面层就是一个PictureBox用来显示图像再加一排参数控件方便调试时实时调整也可以不做直接暴露属性让外部传入。算法层是核心检测方法输入Bitmap输出斑点列表并负责把斑点画到图上。这里有个细节值得注意不要把算法逻辑直接写在控件的Paint事件里。检测是耗时操作画图是高频操作两者混在一起界面必卡。正确做法是检测在独立方法或后台线程中执行结果保存成字段Paint/PictureBox只负责把结果绘制出来。我在早期版本把检测逻辑塞进了Paint事件的回调里结果图像稍微大一点界面刷新就掉帧严重后来才拆干净。控件对外暴露什么我列了几个设计原则参数用公开属性暴露而不是写死内部常量。检测完成通过事件通知而不是让外部轮询状态。图像Bitmap的释放由外部调用者负责控件不擅自销毁传入的图像。2.2 参数属性暴露让控件从一次性变可复用SimpleBlobDetector本身有十几个参数如果全部暴露成属性用起来会很痛苦。我筛选了实际项目中最常需要调整的几项阈值步长、阈值上下限、面积范围、圆度下限、凸度下限、惯性比下限再加一个最小斑点间距用于防止粘连目标被合并。这些属性我全部写成自动属性默认值参考OpenCV官方示例给定调用者改了属性后重新执行检测即可生效public int ThresholdStep { get; set; } 10; public int MinThreshold { get; set; } 50; public int MaxThreshold { get; set; } 220; public int MinArea { get; set; } 20; public int MaxArea { get; set; } 10000; public float MinCircularity { get; set; } 0.8f; public float MinConvexity { get; set; } 0.5f; public float MinInertiaRatio { get; set; } 0.5f; public int MinDistBetweenBlobs { get; set; } 10;这里有一个经验参数默认值不要照搬OpenCV示例。OpenCV给的是通用场景的默认值真实图像上几乎不可能一上来就合适。我通常的做法是先用默认值跑一遍再看检测结果去粗调面积和圆度这两个参数对结果影响最明显。控件的交互层面我加了一个很实用的能力鼠标悬停在某个斑点上时状态栏显示该斑点的坐标和直径。实现方式也不复杂PictureBox的MouseMove事件里遍历斑点列表判断鼠标位置与斑点的距离是否小于斑点半径。这个功能在客户验收时特别加分因为他们能看到每个点对应的实际数据而不是一个光秃秃的圆圈。2.3 参数调整界面测试阶段效率翻倍的关键如果只是代码层面暴露了一堆属性实际调试时每次都要改代码重新编译那效率太低了。我还做了一排简单的TrackBar和NumericUpDown绑定到参数属性上拖动滑块就能实时看到检测结果变化。这个调试界面的意义超出我的预期。以前用纯代码调参改一个阈值编译一次跑一轮要两三分钟。有了实时调整面板半分钟就能试出合适的参数组合。尤其是在现场调试时操作人员自己就能拖滑块找到合适的灵敏度不用每次都喊开发人员改代码。参数面板我放在控件右侧Dock属性设为Right宽度200像素左右。这样控件本身可以独立使用——不需要面板时就留空需要调试时就加载参数面板。这个设计让我在后续多个项目中都受益因为每个项目对于测试阶段要不要暴露参数的需求不一样。3. 斑点检测核心原理与OpencvSharp实操要点3.1 SimpleBlobDetector的工作原理它到底怎么找斑点使用OpencvSharp的SimpleBlobDetector代码量其实很少核心步骤就三步建参数、创建检测器、执行检测。但如果不懂原理参数一调就蒙。我花点篇幅把这个算法的工作机制讲清楚理解了之后调参就有方向了。SimpleBlobDetector的工作流程是这样的它会在MinThreshold到MaxThreshold之间以ThresholdStep为步长对灰度图进行多次二值化。每一次二值化图像中灰度值落在当前阈值区间内的区域被标为前景然后算法提取前景的连通区域记录它们的轮廓信息。之所以要连续做多次二值化是为了应对边缘模糊或灰度不均匀的情况——同一个斑点在不同阈值下会有不同的连通区域大小算法通过同一个区域在多个阈值级别下都出现这个特征来确认它是一个真正的斑点。除了多阈值二值化它还有一系列过滤器用来剔除不满足条件的连通区域。面积过滤器控制斑点大小范围圆度过滤器剔除形状偏离圆形的区域凸度过滤器剔除有凹陷的形状惯性比过滤器控制斑点的扁长程度。这些过滤器可以独立开关组合使用才能适应不同场景。理解了这层逻辑你就明白为什么参数这么多却找不到想要的斑点往往是多个过滤器叠加导致的。比如目标本身是椭圆形的圆度值天然不高又把MinCircularity设成0.9那当然什么都检测不到。我调参时的顺序永远是先把所有过滤器关掉只看纯二值化分割效果确认分割没问题后再逐个打开过滤器收紧条件。这个过程后面我会单独讲。3.2 参数详解一张表讲清楚每个参数的含义和调节方向刚接触斑点检测时最头疼的就是那一堆FilterBy开头的选项。我整理了一张速查表每次调参都对着看推荐你也保存一份参数含义作用调大/调小的效果ThresholdStep二值化阈值步长决定多阈值扫描的密度调小更精细但计算量增大MinThreshold/MaxThreshold二值化阈值范围决定参与检测的灰度区间范围太窄会漏掉暗色或亮色斑点MinArea/MaxArea斑点面积范围过滤过大或过小的区域最直观有效的过滤器MinCircularity圆度下限过滤形状不圆的区域调大后只保留接近正圆的斑点MinConvexity凸度下限过滤有凹陷的形状调大后形状更接近凸多边形MinInertiaRatio惯性比下限过滤狭长的形状调大后只保留接近圆形的斑点MinDistBetweenBlobs斑点最小间距合并过近的斑点调大后相邻斑点会被合并以圆度为例它的计算方式是4π乘以面积除以周长的平方。完美圆形的圆度值是1正方形大约0.785一片叶子的形状可能在0.4到0.6之间。如果检测目标是药片MinCircularity设在0.8以上很合理但如果目标是碎玻璃颗粒形状不规则圆度设到0.8就会漏检一大片这个时候应该降到0.5甚至更低。惯性比则描述了区域的长短轴关系。它反映了目标形状是不是细长条。圆形或方形的惯性比接近1长条形可能只有0.2到0.4。检测圆形斑点时它很有用但检测螺丝、纽扣这类非圆形目标时要注意关闭或者放宽。3.3 Mat与Bitmap互转的各种坑C#环境下使用OpencvSharp最绕不开的就是Mat和Bitmap的转换。图片从PictureBox或者文件读进来是Bitmap传给OpencvSharp处理要求Mat处理完画结果又要回到Bitmap。我最早踩过一个大坑是通道顺序。OpencvSharp默认加载图像是BGR顺序而Bitmap是RGB顺序。直接用BitmapConverter.ToBitmap转换后画面会呈现诡异的红蓝颠倒。解决办法是转换前用Cv2.CvtColor将Mat转成RGB顺序或者显示时把Bitmap的像素格式调整正确。实际代码中我统一约定控件内部Mat一律使用BGR转换显示用Bitmap时再转RGB。另一个坑是Bitmap的释放。BitmapConverter.ToBitmap每次调用都会生成新的托管Bitmap对象如果不及时Dispose内存会肉眼可见地暴涨。尤其是循环处理大批量图像时内存直接飙到几百兆程序卡到怀疑人生。我的处理原则是局部变量用using包裹或者try-finally里显式Dispose。再有一个非常隐蔽的问题是Mat的多引用共享机制。Mat是引用计数对象两个Mat变量可能指向同一块内存调用Dispose时只要还有一个引用存在内存就不会释放。这本身是好事但如果你在使用完一个Mat后误调用了它的Dispose而另一个Mat还在用同一块内存后续操作就会报异常。所以在代码里我对Mat的释放非常克制只对明显临时创建的Mat调用Dispose传递用的Mat交给生命周期管理者统一处理。3.4 灰度化与降噪预处理这一步别偷懒SimpleBlobDetector处理的是单通道灰度图所以在检测前必须先把BGR图像转成灰度图。这个转换用Cv2.CvtColor一行搞定没什么好说的。真正需要留意的反而是是否要做降噪。工业现场拍摄的图像往往有噪点尤其是低照度环境下。噪点对二值化过程影响很大——一个孤立的亮噪声点可能在某个阈值下形成一个微小连通区域如果面积过滤器设置不当就会被当成斑点检测出来。我实测过一张带有轻微传感器噪声的图片在MinArea设成10的情况下多出三十几个假斑点。降噪我推荐用高斯模糊核大小设成3x3或5x5。核太大会把真正的微小斑点磨平太小了又没有效果。高斯模糊后检测结果干净很多假斑点数量大幅下降。如果是强噪声环境可以再加上中值模糊它对椒盐噪声效果更好。不过也要提醒一句预处理不是越重越好。有一次我做芯片引脚检测为了减少干扰加了5x5的高斯模糊结果较小的焊点直接被抹平了检测数量一下子掉了三分之一。后来把核改回3x3情况才恢复正常。预处理参数的设定一定要和目标的尺寸挂钩。4. 实操从零搭建斑点检测控件全流程4.1 第一步创建自定义控件工程并完成界面布局打开Visual Studio新建一个WinForms控件库项目或者直接在现有WinForms工程里添加用户控件都行。我推荐先建独立控件库项目因为这样编译出来是一个独立dll后续多项目复用方便。控件库项目里添加一个UserControl取名BlobDetectorControl。控件布局我做成这样左侧是一个PictureBoxDock设为FillSizeMode设为Zoom图像等比缩放显示右侧是一个参数面板Panel的Dock设为Right宽度220像素。参数面板上放八组控件每组一个Label加一个NumericUpDown或TrackBar。如果不想做界面可以跳过这一步直接把参数做成公共属性。PictureBox选Zoom模式有个好处图像无论多大都能完整显示不会裁剪。坏处是后面做坐标映射时要多算一步缩放比例这个我放在第5章节单独讲。对于斑点检测这种需要看到全貌的场景Zoom模式利大于弊。4.2 第二步封装检测接口核心代码封装检测接口是整个控件的核心。我的做法是公开一个Detect方法输入Bitmap内部自动完成灰度化、降噪、斑点检测、结果绘制最后刷新界面显示。代码如下public void Detect(Bitmap inputImage) { if (inputImage null) return; // Bitmap转Mat using (Mat srcMat BitmapConverter.ToMat(inputImage)) { // 转灰度图 using (Mat grayMat new Mat()) { Cv2.CvtColor(srcMat, grayMat, ColorConversionCodes.BGR2GRAY); // 高斯降噪核大小根据目标尺寸调整 using (Mat smoothMat new Mat()) { Cv2.GaussianBlur(grayMat, smoothMat, new Size(3, 3), 0); // 配置检测器参数 SimpleBlobDetector.Params parameters new SimpleBlobDetector.Params { ThresholdStep ThresholdStep, MinThreshold MinThreshold, MaxThreshold MaxThreshold, MinRepeatability 2, MinDistBetweenBlobs MinDistBetweenBlobs, FilterByArea true, MinArea MinArea, MaxArea MaxArea, FilterByCircularity true, MinCircularity MinCircularity, FilterByConvexity true, MinConvexity MinConvexity, FilterByInertia true, MinInertiaRatio MinInertiaRatio }; using (SimpleBlobDetector detector SimpleBlobDetector.Create(parameters)) { // 执行检测 KeyPoint[] keyPoints detector.Detect(smoothMat); // 绘制检测结果 using (Mat resultMat new Mat()) { Cv2.DrawKeypoints(smoothMat, keyPoints, resultMat, Scalar.Red, DrawMatchesFlags.DrawRichKeypoints); // 保存检测结果 _lastKeyPoints keyPoints; _lastResultImage BitmapConverter.ToBitmap(resultMat); // 触发外部通知 OnDetectCompleted?.Invoke(this, new BlobDetectEventArgs(keyPoints)); // 刷新显示 _picBox.Image?.Dispose(); _picBox.Image (Bitmap)_lastResultImage.Clone(); } } } } } }这段代码有几个地方值得说明。第一所有的Mat资源我都用using包裹确保及时释放。第二DrawKeypoints绘制的结果是带灰度背景的图这样斑点轮廓在视觉上一目了然。第三检测结果保存到类字段里后续鼠标交互直接用不用重复计算。关于SimpleBlobDetector的Create方式OpencvSharp版本不同会有细微差别。我使用的是OpenCvSharp4语法如上所示。如果你用的是旧版本比如OpenCvSharp3API基本一致差别主要在使用OpenCvSharp.Extensions命名空间的BitmapConverter位置。4.3 第三步结果绘制细节与显示刷新斑点的绘制方式对结果的可读性影响很大。我用的是DrawRichKeypoints模式它会在每个斑点周围画一个圆圈圆的大小对应斑点的实际直径同时在圆心位置画一个小十字标记。红色在大多数灰度图上都很显眼客户反馈看起来非常直观。Cv2.DrawKeypoints(smoothMat, keyPoints, resultMat, Scalar.Red, DrawMatchesFlags.DrawRichKeypoints);还有一点要注意的是PictureBox的Image替换。如果直接把新的Bitmap赋给PictureBox旧的Bitmap不释放反复检测几十次后内存就会出问题。我习惯在替换前先Dispose掉旧图再赋新值。但这里有个隐含风险如果外部代码持有旧Bitmap的引用这里Dispose会导致外部访问异常。所以我在设计约定里明确控件显示的图像外部只能查看不能持有引用。检测结果的坐标信息我保存在KeyPoint数组里。KeyPoint有Pt坐标float精度、Size直径、Response响应强度等字段。很多场景下客户只要坐标和直径这两个字段已经足够。如果你需要更精确的轮廓边界可以改用FindContours自己做轮廓分析但那是另一个话题了。4.4 第四步后台线程与UI刷新策略斑点检测在图像尺寸较大时非常耗时。一张1920x1080的图像ThresholdStep设置成5时检测耗时可能达到几百毫秒到一两秒。如果直接放在UI线程里执行界面会假死用户拖动窗口都反应不过来。所以必须把检测放到后台线程。我的做法很简单外部调用Detect方法时内部用Task.Run启动检测检测完成后通过Control.BeginInvoke回到UI线程刷新PictureBox。这里有一个关键点不要在后台线程访问PictureBox属性。WinForms的UI控件有线程亲和性必须在创建它的线程上操作。BeginInvoke就是干这个用的。public void DetectAsync(Bitmap inputImage) { _ Task.Run(() { Bitmap resultBitmap ExecuteDetect(inputImage); BeginInvoke(new Action(() { _picBox.Image?.Dispose(); _picBox.Image resultBitmap; })); }); }注意一个容易踩的坑输入图像inputImage在后台线程被使用而外部线程可能会同时修改或释放它。所以我在DetectAsync一开始就做一次浅拷贝确保后台线程操作的是独立副本。这个拷贝会增加一些内存开销但换来的是线程安全值得。另一个要处理的问题是重复检测。用户连续点击检测按钮时上个任务还没跑完下个任务又启动了界面上的结果顺序会错乱。我加了一个简单的锁机制检测中不允许再启动检测等当前任务完成才能执行下一个。这个用SemaphoreSlim就能实现代码量很小但体验提升巨大。5. 调参经验与问题排查实录5.1 常见场景参数模板直接照抄也能用调参是斑点检测最耗时的一步。我把几个典型场景的参数组合整理成了模板直接套用可以少走很多弯路场景ThresholdStepMin/MaxThresholdMinArea/MaxAreaMinCircularityMinConvexityMinInertiaRatio药片/胶囊检测1050/200100/50000.80.80.7LED灯珠定位5100/20020/5000.850.90.8碎颗粒统计100/2555/10000.50.50.3螺丝/垫片计数1050/220500/100000.60.70.5这些参数不是拍脑袋定的是我在对应场景下反复试出来的经验值。药片目标圆整、大小一致所以圆度要求高碎颗粒形状随机圆度放宽才能保证检出率LED灯珠在特定背光下灰度范围集中所以阈值范围窄一点没问题。套模板只是起点。我建议拿到新图后按这个顺序调参先把面积过滤器打开这是最可靠的约束再去调圆度或惯性比控制形状最后动阈值区间。如果检测结果偏多优先收紧面积下限或圆度下限如果偏少优先放宽面积下限和圆度。不要去动ThresholdStep它影响的是计算速度对结果数量影响不显著。5.2 经典翻车案例光照不均和粘连斑点怎么处理第一个典型问题是光照不均。图像中心亮、边缘暗或者有渐变阴影导致同一个斑点在不同位置的灰度差异很大。阈值范围设小了暗处的斑点漏检设大了暗处背景的噪点被当成斑点。我遇到这种情况简单调参已经解决不了必须上图像预处理。一个有效方案是背景减除先用大尺寸高斯模糊求出近似背景然后用原图减去背景图得到去除光照影响的差值图。这个方法对渐变光照效果非常好代码实现也不复杂using (Mat backGround new Mat()) { Cv2.GaussianBlur(grayMat, backGround, new Size(51, 51), 0); Cv2.Subtract(grayMat, backGround, normMat); Cv2.ConvertScaleAbs(normMat, normMat); }第二个经典问题是斑点粘连。几个斑点紧挨着二值化后连成一个连通域面积过滤器没法把它们分开。我最早的做法是把面积调小但效果很差——粘连区域大小和单个斑点差异不大无法通过面积区分。后来我用距离变换 局部极大值的方式做分离再配合分水岭算法分割粘连区域。但这套方案代码量明显增加对于大多数应用场景我推荐一个更简单的策略调整光照或拍摄角度尽量避免粘连产生。如果做不到再考虑分水岭分割。分水岭方案网上资料很多其中OpencvSharp的Cv2.Watershed可以处理但需要先确定前景和背景标记实现复杂度确实高一些。5.3 检测性能优化大图不卡顿的调整思路图像尺寸是性能的最大瓶颈。一张500万像素的图片ThresholdStep设为5时二值化要跑40多次每次都要做连通域分析计算量非常可观。我的优化思路按优先级排列先降分辨率再降阈值扫描次数最后考虑并行。降分辨率是性价比最高的方案。很多场景根本不需要原始分辨率斑点只要能被检测出来坐标再映射回原始尺寸即可。做法是把输入图像等比缩放到宽度1000像素左右检测完成后把KeyPoint坐标按比例放大回原图坐标。这个操作对检测精度影响微弱但速度提升非常明显。在我自己的项目里从1920x1080缩到960x540检测耗时从1秒多降到300毫秒左右。ThresholdStep影响的是二值化次数。默认10时阈值范围50到220之间要跑17次改成20的话次数减半。如果你发现斑点边缘清晰、灰度对比明显可以把ThresholdStep适当调大性能收益很直接。并行处理是终极手段。如果一次要处理几十张图可以用Parallel.For同时跑多张图的检测。OpencvSharp的SimpleBlobDetector不是线程安全的同一个检测器实例不能多线程共享但不同实例之间互不干扰。所以每张图创建一个独立的detector实例即可。5.4 内存泄漏排查为什么检测几次后内存暴涨OpencvSharp在C#环境里最恶心的问题就是内存释放。它的Mat、SimpleBlobDetector、KeyPoint底层都是非托管资源GC不帮忙管理。如果不及时释放跑几十张图之后内存就开始起飞。我自己排查内存泄漏时第一个排查点就是BitmapConverter.ToBitmap返回的Bitmap。这个转换会生成全新的托管Bitmap占用的是C#这边的内存Dispose与否和Mat没关系完全靠自觉。另一个排查点是Mat的引用计数问题。同一块内存被多个Mat变量引用时只有最后一个引用释放后内存才真正回收。这里分享两个实用的小工具。Visual Studio自带的诊断工具能看托管内存和非托管内存的增长曲线如果非托管内存持续上升问题大概率出在Mat没有释放。WinForms的Timer控件可以用来做长时间压力测试每500毫秒执行一次检测并记录内存跑5分钟就能看出泄漏趋势。用using包裹是最省心的方案我基本做到了所有临时Mat都能自动释放。实在没法用using的场景比如把Mat存成字段就手动调用Dispose并置空引用。内存问题解决后连续检测100张图内存保持稳定这个问题才算真正处理完。5.5 坐标系统映射Zoom模式下怎么精确对应到鼠标位置PictureBox用Zoom模式显示图像时图像和控件之间有一个缩放比例和偏移量。如果直接把鼠标坐标当成图像坐标去匹配斑点位置完全是错的。这个问题在我第一次加鼠标悬停功能时踩了个结实。Zoom模式的计算逻辑是如果图像比控件大按比例缩放显示如果图像比控件小图像居中显示四周留白。公式拆开来看就三行代码float scale Math.Min((float)_picBox.Width / imageWidth, (float)_picBox.Height / imageHeight); float offsetX (_picBox.Width - imageWidth * scale) / 2; float offsetY (_picBox.Height - imageHeight * scale) / 2; float imageX (mouseX - offsetX) / scale; float imageY (mouseY - offsetY) / scale;把鼠标坐标逆变换回图像坐标之后再遍历KeyPoint数组计算与各斑点的距离小于斑点半径就认为鼠标悬停在该斑点上。效果是鼠标移到斑点上高亮显示离开恢复。这个坐标映射逻辑我最初写在MouseMove事件里但每次移动鼠标都遍历几百个斑点做距离计算有点浪费资源。优化方案是只在鼠标坐标发生变化时才计算或者限制100ms内只计算一次。对于几百个斑点来说其实性能压力不大但做上位机养成的习惯是能省则省流畅度优先。写在最后斑点检测这套方案我从最早的人工数数到现在封装成控件拖到哪个项目都能用前后迭代了三个版本。最大的体会是视觉算法本身并不复杂但落地到一套能复用的代码里需要把资源管理、界面响应、参数调试这些算法之外的事情一并考虑清楚。如果你也是C#上位机方向我建议遇到视觉需求时优先考虑封装控件哪怕项目只有这一个界面要用这个投入也是值得的——因为你不知道下一个项目什么时候又会遇到一模一样的需求。最后再分享一个小技巧调试期把所有参数都做成实时可调的界面等现场验收时你会感谢这个决定。