简介:一套基于WPF与OpenCvSharp,使用C#开发的仿VisionMaster通用视觉框架源码,面向视觉软件工程师和工业自动化项目人员,用于快速搭建拖拽式流程化检测界面。整套源码开箱即用,支持插件式扩展,可按需追加定位、检测、深度学习等算法模块;框架分为界面层、视图模型层、数据模型层与服务层,将显示、数据和功能解耦,便于学习MVVM架构和视觉工具链组织。资源共2000个文件,约335.39MB,以C#源代码、JSON配置、XAML界面文件为主,并含DLL运行库、PDB调试符号及说明文档等,可直接编译阅读。已有119人学习下载,适合希望掌握流程图式视觉开发、从事机器视觉项目或研究WPF的开发者参考。借助这套源码,可理解工具节点、流程连线与参数配置的实现逻辑,并二次开发投入实际项目。
1. 一眼看懂:这是给 C# 视觉工程师的节点式视觉框架底座
用过 VisionMaster 这类拖拉拽视觉软件的人,基本都会有同一个念头:交互是真方便,但算法和授权都是黑匣子。这个标题想做的事,就是用 WPF、OpenCvSharp 和 C# 从零搭一套同思路的通用视觉流程框架——左边是节点库,中间是画布,右边是参数面板,把图像源、预处理、定位、测量这些算法封装成节点,拖进去、连上线、配好参数,保存成流程文件,换产品时直接换流程。它不是做个 Demo 交差,而是给产线视觉项目一个能复用的底座。适合手上有 C# 基础、被产线验收逼着反复改需求的视觉工程师。下面把这套框架的架构、拖拽实现、算法接入和踩坑一次讲透。
2. 先把骨架立住:节点抽象、数据管道与流程调度
2.1 选型依据:WPF 和 OpenCvSharp 组合的边界在哪里
这个框架的关键不是某段炫技代码,而是「节点 + 连线 + 上下文」这套数据模型能不能支撑起一整条视觉流程。选 WPF 是因为节点式画布本质上是「图形对象 + 数据绑定 + 交互事件」的组合:ItemsControl 负责渲染节点集合,Canvas 负责定位,Binding 驱动属性刷新,比 WinForms 手工控制坐标和刷新省掉大量重复代码。OpenCvSharp 则直接封装了 OpenCV 的 C++ 接口,Mat 在 C# 里是强类型对象,图像读写、滤波、匹配这些高频操作都有现成方法,不用自己 P/Invoke。
选型边界同样要讲清楚:WPF 只跑 Windows,但产线工控机几乎都是 Windows 系统,这个限制在视觉项目里基本不构成问题。OpenCvSharp 也不是所有 OpenCV 函数都封装了,遇到冷门接口还是得自己写 P/Invoke。这套框架定位在「流程编排 + 常见 2D 算法库」,3D 点云、深度学习推理这类特殊算法不在初始范围内,但可以通过自定义节点扩进去,后面第 6 章会讲。
| 对比项 | WinForms 方案 | WPF 方案 |
|---|---|---|
| 界面刷新 | 手工 Invalidate,节点一多容易闪 | 数据绑定 + 增量渲染,画布流畅 |
| 节点定位 | 靠 Control.Location | Canvas.Left/Top 绑定,支持动画 |
| 连线绘制 | GDI+ 手工管理 | Path/Geometry 直接做贝塞尔曲线 |
| 数据上下文 | 需要自己做数据到控件的映射 | DataContext 自动传递 |
2.2 节点基类与端口契约:所有算法模块的公共接口
先定义节点的数据模型。一个视觉节点必须有名字、类型、输入端口、输出端口和参数表。端口是连线的锚点,类型决定了能否连接——你不能把「数值」输出接到「图像」输入上,这个校验在节点库层面就要做掉。参数用字典而不是一堆强类型属性,是为了和反射参数面板兼容,第 4 章会用到。
public enum PinDirection { Input, Output } public enum PinType { Image, Number, Region, Any } public class Pin { public string Name { get; set; } public PinDirection Direction { get; set; } public PinType Type { get; set; } public VisionNode Owner { get; set; } } public abstract class VisionNode { protected VisionNode(string nodeType) { NodeType = nodeType; Name = nodeType; } public string Name { get; set; } public string NodeType { get; protected set; } // 一个节点可以多个输出,比如定位节点同时输出坐标和 ROI 图像 public ObservableCollection<Pin> InputPins { get; } = new(); public ObservableCollection<Pin> OutputPins { get; } = new(); // 通用参数表,值类型建议限定为 string/double/int/bool/enum public Dictionary<string, object> Params { get; } = new(); public abstract Task<bool> Execute(NodeContext context); }端口用集合而不是单个对象,是因为视觉流程里「一进多出」非常常见。例如模板匹配节点输入一张图,输出一个坐标点和一个相似度分数,对应两个输出端口。继承抽象类而不是实现接口,是因为基类可以承载端口初始化、日志写入这些公共逻辑,子类只需要关心 Execute 里到底做什么算法。
public class NodeContext { // 图像缓存,Key 约定为 "节点名.端口名" public Dictionary<string, Mat> ImageCache { get; } = new(); // 数值/区域/坐标等结果 public Dictionary<string, object> Results { get; } = new(); public ConcurrentQueue<string> Logs { get; } = new(); public void Log(string message) { Logs.Enqueue($"[{DateTime.Now:HH:mm:ss.fff}] {message}"); } } public class NodeLink { public string SourceNode { get; set; } public string SourcePin { get; set; } public string TargetNode { get; set; } public string TargetPin { get; set; } }这里有个约定要尽早定下来:节点之间不直接访问对方的实例,所有数据都通过 NodeContext 传递。图像统一放进 ImageCache,坐标和分数放进 Results。好处是流程执行可以任意排序、可以并行,节点之间没有强引用,保存流程时只需要序列化节点名和参数,不会拖进一堆运行时对象。
2.3 流程调度器:拓扑排序与循环依赖检测
画布上拖出来的是一堆节点和连线,但真正执行时必须有顺序:图像源先跑,然后是预处理,最后才是匹配和测量。顺序由连线的方向决定,这叫有向无环图。调度器的工作就是把这个图排出一个线性执行序列,同时拦截用户手滑连出来的环。
public class FlowRunner { private readonly NodeContext _context = new(); public async Task<bool> RunAsync(List<VisionNode> nodes, List<NodeLink> links) { var order = TopoSort(nodes, links); if (order == null) { _context.Log("流程存在循环依赖,请检查连线"); return false; } foreach (var node in order) { _context.Log($"开始执行 {node.Name}"); var ok = await node.Execute(_context); if (!ok) { _context.Log($"节点 {node.Name} 执行失败"); return false; } } return true; } private List<VisionNode> TopoSort(List<VisionNode> nodes, List<NodeLink> links) { var indegree = nodes.ToDictionary(n => n.Name, _ => 0); var adj = nodes.ToDictionary(n => n.Name, _ => new List<string>()); foreach (var link in links) { if (!adj.ContainsKey(link.SourceNode)) continue; adj[link.SourceNode].Add(link.TargetNode); indegree[link.TargetNode]++; } var queue = new Queue<VisionNode>(nodes.Where(n => indegree[n.Name] == 0)); var result = new List<VisionNode>(); while (queue.Count > 0) { var node = queue.Dequeue(); result.Add(node); foreach (var next in adj[node.Name]) { if (--indegree[next] == 0) queue.Enqueue(nodes.First(n => n.Name == next)); } } return result.Count == nodes.Count ? result : null; } }这是经典的 Kahn 拓扑排序。为什么用节点名字做 Key:因为流程保存成 JSON 时,连线两端存的就是节点名和端口名,加载后再映射回节点实例。TopoSort 返回 null 表示图里有环,执行前直接拒绝,避免程序卡死。等待节点执行用 await 而不是同步调用,是为将来图像采集这类 IO 节点预留异步能力,线程池不阻塞。
3. 画布拖拽与连线:WPF 交互层的核心实现
3.1 用 ItemsControl 铺节点,用 Canvas 定位
画布的第一版实现我建议别去继承什么自定义 Panel,直接用 ItemsControl 配合 Canvas。ItemsControl 负责把节点集合渲染出来,Canvas 负责给每个节点一个绝对坐标。关键是 Canvas.Left 和 Canvas.Top 要绑到 ViewModel 的 X/Y 属性上,这样拖动节点时只需要改 VM 的值,界面自动跟着走。
<ItemsControl ItemsSource="{Binding NodeVMs}"> <ItemsControl.ItemsPanel> <ItemsPanelTemplate> <Canvas x:Name="MainCanvas" Background="Transparent" AllowDrop="True" /> </ItemsPanelTemplate> </ItemsControl.ItemsPanel> <ItemsControl.ItemContainerStyle> <Style TargetType="ContentPresenter"> <Setter Property="Canvas.Left" Value="{Binding X}" /> <Setter Property="Canvas.Top" Value="{Binding Y}" /> </Style> </ItemsControl.ItemContainerStyle> <ItemsControl.ItemTemplate> <DataTemplate> <local:NodeControl /> </DataTemplate> </ItemsControl.ItemTemplate> </ItemsControl>节点 ViewModel 必须实现 INotifyPropertyChanged,否则拖动时 X/Y 改了,Canvas.Left 不会刷新。这个坑我没少踩,一开始直接把 X 定义成普通属性,界面纹丝不动,后来统一给 VM 基类加上通知逻辑,才彻底解决。
public class NodeViewModel : INotifyPropertyChanged { private double _x; private double _y; public double X { get => _x; set { _x = value; PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(X))); } } public double Y { get => _y; set { _y = value; PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Y))); } } public string Header { get; set; } public VisionNode Model { get; set; } public event PropertyChangedEventHandler PropertyChanged; }节点控件本身就是一个 Border 包着标题栏和端口列表,标题栏显示节点类型名,左右两侧画出输入和输出端口。每个端口是 Rectangle 或者 Ellipse,Tag 里存端口对应的 PinViewModel,这样拖拽连线时能从 Tag 直接拿到端口数据。
3.2 鼠标拖动节点:从 PreviewMouseDown 到坐标更新
拖动的实现用 Preview 事件而不是普通 Mouse 事件,是因为节点控件内部有标题、边框、端口等多个子元素,普通事件会被子元素吃掉。Preview 事件在路由到达子元素之前触发,能保证从节点的任何一个位置按下鼠标,都能开始拖动。
private NodeViewModel _vm; private Point _startPoint; private void Node_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) { if (sender is FrameworkElement fe && fe.DataContext is NodeViewModel vm) { _vm = vm; _startPoint = e.GetPosition(MainCanvas); e.Handled = true; // 阻止子元素接管 } } private void Node_PreviewMouseMove(object sender, MouseEventArgs e) { if (_vm == null || e.LeftButton != MouseButtonState.Pressed) return; var pos = e.GetPosition(MainCanvas); _vm.X += pos.X - _startPoint.X; _vm.Y += pos.Y - _startPoint.Y; _startPoint = pos; e.Handled = true; } private void Node_PreviewMouseLeftButtonUp(object sender, MouseButtonEventArgs e) { _vm = null; }拖动用坐标累加而不是绝对赋值,这是有意为之:如果画布里选中了多个节点要整体拖动,累加方式只需把每个选中节点的 X/Y 都加上偏移量,相对位置不会乱。e.Handled 要记得设,否则节点内部如果有编辑框,拖拽时文本光标会跟着乱跑。
3.3 端口到端口的连线:贝塞尔临时线与命中测试
连线的交互是三段式:在输出端口按下鼠标,画一条临时曲线跟随鼠标移动,在输入端口松开时完成连接。临时曲线用贝塞尔,两个控制点取起点和终点的水平中点,拖出来的线比直线柔和,也更接近 VisionMaster 这类软件的观感。
private PinViewModel _startPin; private Path _tempPath; private void OutputPin_MouseLeftButtonDown(object sender, MouseButtonEventArgs e) { if (sender is FrameworkElement fe && fe.DataContext is PinViewModel pin) { _startPin = pin; _tempPath = new Path { Stroke = Brushes.DodgerBlue, StrokeThickness = 2, IsHitTestVisible = false // 临时线不参与命中,防止挡住端口 }; MainCanvas.Children.Add(_tempPath); e.Handled = true; } } private void MainCanvas_MouseMove(object sender, MouseEventArgs e) { if (_startPin == null) return; var start = _startPin.CenterPoint; var end = e.GetPosition(MainCanvas); var geometry = new PathGeometry(); var figure = new PathFigure { StartPoint = start, IsFilled = false }; var c1 = new Point((start.X + end.X) / 2, start.Y); var c2 = new Point((start.X + end.X) / 2, end.Y); figure.Segments.Add(new BezierSegment(c1, c2, end, true)); geometry.Figures.Add(figure); _tempPath.Data = geometry; } private void MainCanvas_MouseLeftButtonUp(object sender, MouseButtonEventArgs e) { var target = HitTestPin(e.GetPosition(MainCanvas)); if (target != null && CanLink(_startPin, target)) { var link = new NodeLink { SourceNode = _startPin.Owner.Name, SourcePin = _startPin.Name, TargetNode = target.Owner.Name, TargetPin = target.Name }; ((CanvasViewModel)DataContext).AddLink(link); } MainCanvas.Children.Remove(_tempPath); _startPin = null; }HitTestPin 我一般用最近距离判断:遍历画布上所有可见端口,取鼠标位置与端口中心的距离,小于 12 像素就认为命中了。比 VisualTreeHelper.HitTest 更直观,也好控制「吸附」的手感。CanLink 里做三层校验:两个端口方向一进一出、类型一致、不能连到同一个节点自身,避免出现自环。
4. 视觉算法节点怎么落地:以图像源、匹配与参数面板为例
4.1 图像源节点:读图与 Mat 生命周期
图像源是每个流程的起点。生产环境里可能是工业相机 SDK 取流,开发调试阶段用本地图片文件就够。这个节点只做一件事:把一张图读进 NodeContext.ImageCache,Key 用「节点名.Out」,后面的节点统一用这个约定取图。
public class ImageSourceNode : VisionNode { public ImageSourceNode() : base("ImageSource") { OutputPins.Add(new Pin { Name = "Out", Direction = PinDirection.Output, Type = PinType.Image }); } public string FilePath { get; set; } public override Task<bool> Execute(NodeContext context) { if (!File.Exists(FilePath)) { context.Log($"文件不存在: {FilePath}"); return Task.FromResult(false); } using var mat = Cv2.ImRead(FilePath, ImreadModes.Grayscale); if (mat.Empty()) { context.Log($"读取失败: {FilePath}"); return Task.FromResult(false); } // 必须 Clone,直接存 mat 会在 using 结束后拿到空数据 context.ImageCache[$"{Name}.Out"] = mat.Clone(); context.Log($"图像尺寸: {mat.Width}x{mat.Height}"); return Task.FromResult(true); } }这里有两个细节。第一,ImreadModes.Grayscale 读成单通道,既省内存又满足大多数定位算法的输入要求;需要彩色图做颜色识别的节点,再单独加一个 ColorImageSource 节点。第二,mat.Clone() 不能省,因为 using var mat 离开方法时底层缓冲被释放,如果缓存直接存 mat,下一个节点取到的是已释放的空对象。这个坑在第 5 章还会专门讲。
4.2 模板匹配节点:从 MatchTemplate 到结果输出
模板匹配是视觉项目里最常用的定位手段,用来当算法节点的样板最合适。这个节点有两个输入输出:输入一张搜索图,输出匹配点的坐标和得分。参数是模板路径和阈值。执行时读模板、跑 MatchTemplate、取最大值坐标,把结果写进 Results。
public class TemplateMatchNode : VisionNode { public TemplateMatchNode() : base("TemplateMatch") { InputPins.Add(new Pin { Name = "In", Direction = PinDirection.Input, Type = PinType.Image }); OutputPins.Add(new Pin { Name = "Point", Direction = PinDirection.Output, Type = PinType.Number }); OutputPins.Add(new Pin { Name = "Score", Direction = PinDirection.Output, Type = PinType.Number }); } public string TemplatePath { get; set; } public double Threshold { get; set; } = 0.8; public override Task<bool> Execute(NodeContext context) { var key = $"{GetInputSourceName("In")}.Out"; if (!context.ImageCache.TryGetValue(key, out var src)) { context.Log($"找不到输入图像: {key}"); return Task.FromResult(false); } using var templ = Cv2.ImRead(TemplatePath, ImreadModes.Grayscale); using var result = new Mat(); Cv2.MatchTemplate(src, templ, result, TemplateMatchModes.CCoeffNormed); Cv2.MinMaxLoc(result, out _, out var maxVal, out _, out var maxLoc); context.Results[$"{Name}.Score"] = maxVal; context.Results[$"{Name}.Point"] = maxLoc; context.Log($"{Name} 得分 {maxVal:F3} 坐标 ({maxLoc.X},{maxLoc.Y})"); return Task.FromResult(true); } }| 参数 | 取值范围 | 说明 |
|---|---|---|
| Threshold | 0 ~ 1 | 相似度阈值,CCoeffNormed 下一般 0.7 以上可用 |
| TemplateMatchModes | 6 种 | 常用 CCoeffNormed,对光照变化相对鲁棒 |
| maxLoc | OpenCvSharp Point | Score 对应最高相似度的坐标 |
为什么要单独输出 Score:实际产线里匹配到位置不等于合格,得分低于阈值的要判 NG。阈值判断放在节点里会让节点职责变重,我习惯让这个节点只报数据和得分,后面挂一个判断节点做流程分支,框架的复用性更高。Templ 的 Mat 和 result 的 Mat 都用 using 包住,这两个临时对象用完即释放,内存表现会好看很多。
4.3 通用参数面板:反射遍历属性自动生成编辑行
如果每个算法节点都手工写一个参数面板,这套框架就失去了「拖拉拽通用」的意义。做法是:在节点被选中时,反射遍历它的公共可写属性,自动生成标签和输入控件。double、int、bool、string、enum 这几种类型覆盖了视觉节点 90% 的参数。
public class PropertyItem { public string Label { get; set; } public object Value { get; set; } public PropertyInfo Property { get; set; } public object Source { get; set; } } public class PropertyPanelViewModel { public ObservableCollection<PropertyItem> Items { get; } = new(); public void Load(VisionNode node) { Items.Clear(); foreach (var prop in node.GetType().GetProperties()) { if (!prop.CanWrite) continue; var type = prop.PropertyType; if (type == typeof(string) || type == typeof(double) || type == typeof(int) || type == typeof(bool) || type.IsEnum) { Items.Add(new PropertyItem { Label = prop.Name, Value = prop.GetValue(node), Property = prop, Source = node }); } } } }界面侧用一个 ItemsControl 绑定 Items,每个属性的编辑控件根据类型选模板:bool 用 CheckBox,enum 用 ComboBox,数值和字符串用 TextBox。TextBox 在 LostFocus 时调用 PropertyInfo.SetValue 把新值写回节点实例,同时同步到节点的 Params 字典。这样一来,新增一个算法节点只需要写算法逻辑和参数属性,参数面板自动出现,不需要再碰界面代码。
5. 避坑记录:WPF + OpenCvSharp 框架落地的 5 个高频问题
这些坑不是从文档里看来的,是实际做产线视觉项目时一轮轮踩出来的。每一条按「现象、原因、解决」写,遇到直接对号入座。
5.1 现象:算法一执行界面就假死
点击运行按钮后窗口标题栏出现「未响应」,鼠标转圈,图像越大卡得越久。原因很直接:MatchTemplate、FindContours 这些 CPU 密集操作直接跑在 UI 线程上,消息循环被占住,界面自然一动不动。解决方法是把算法执行丢进 Task.Run,只把日志和结果通过 Dispatcher 推回界面。运行按钮在任务执行期间禁用,并给一个忙碌光标,这是最简单也最有效的方案。
private async void RunButton_Click(object sender, RoutedEventArgs e) { RunButton.IsEnabled = false; Mouse.OverrideCursor = Cursors.Wait; try { var ok = await Task.Run(() => _flowRunner.RunAsync(Nodes, Links)); // 结果回 UI 线程后刷新日志和图像 RefreshLogs(); } finally { RunButton.IsEnabled = true; Mouse.OverrideCursor = null; } }这里注意,RunAsync 本身就是耗时操作,Task.Run 包住的是整个流程而不只是某个节点。日志输出用 ConcurrentQueue 缓冲,流程跑完一次性刷新,不要在算法循环里频繁调用 Dispatcher,否则 UI 线程反而会被日志刷爆。
5.2 现象:连跑几千张图内存涨到几个 G
连续跑图测试时,任务管理器里内存从 200MB 一路涨到 2GB 以上,而且等多久都不降。原因是 OpenCvSharp 的 Mat 引用的是非托管内存,GC 只追踪托管壳对象,壳没被回收之前底层缓冲不会释放,而 Cv2.ImRead、Cv2.MatchTemplate 的结果对象用完即丢,最容易堆积。解决方法是给临时 Mat 全部套 using,流程执行完显式清理 ImageCache。
// 每个流程跑完后调用,放在 finally 里 private void ClearImageCache(NodeContext context) { foreach (var kv in context.ImageCache) { kv.Value?.Dispose(); } context.ImageCache.Clear(); }即便用了 using,缓存里的图像也要在每轮跑完主动 Dispose。另外可以打开 OpenCvSharp 自带的强制 GC 开关,它会让 GC 更频繁地检查 Mat 壳对象,但别依赖它,主动释放才是正路。
5.3 现象:连线明明画出来了却怎么都点不中
视觉线宽只有 2 像素,Path 的命中测试按描边几何计算,鼠标稍微偏一点就点不到。这就是 WPF 里的「线宽玄学」,越细的线越难选中。解决方法是给连线 Path 做两层:下层是一条 StrokeThickness=10 的透明路径,专门负责命中测试;上层才是显示用的实线。删除连线时,在可视化树里找到对应 Path,Tag 里存链接模型,直接从这个模型移除。
<Path Stroke="Transparent" StrokeThickness="10" Tag="{Binding LinkModel}" /> <Path Stroke="#666666" StrokeThickness="2" />如果嫌两层 Path 麻烦,也可以用 IsHitTestVisible 配合一个粗的透明 Stroke,鼠标事件命中粗 Stroke 后,从 DataContext 里取链接模型。原理一样,关键是别让用户对着 2 像素的细线精准操作。
5.4 现象:节点属性改了,界面参数纹丝不动
在参数面板里把阈值从 0.8 改成 0.5,点运行时结果没变化,调试器里看节点实例属性其实已经改了。这个坑常见于两处:一是参数面板绑定的 PropertyItem.Value 没有在写入后触发通知;二是节点执行时读的是 Params 字典里的旧值,而面板写的是对象的 CLR 属性。解决方法是定一个约定:节点执行只读自己的实例属性,面板写入用 PropertyInfo.SetValue 同时回写字典,界面模板全部用 UpdateSourceTrigger=PropertyChanged 或 LostFocus 后主动刷新。所有属性的读写统一收口在 PropertyItem 里,不要在界面层散着写逻辑。
5.5 现象:流程一执行就不响应,疑似死循环
流程保存后再加载,点运行程序直接卡死,CPU 飙到 100%。原因是保存的流程连出了环,比如 A 的输出连到 B、B 的输出又连回 A,加载 JSON 时没做拓扑校验就直接执行,调度器在循环里出不来。解决方法是执行前先跑一遍 TopoSort,返回 null 就弹提示拒绝执行;加载流程文件时也做同样校验,同时把「链接指向不存在的节点」这类脏数据一起挡在外面。
var order = TopoSort(nodes, links); if (order == null) { MessageBox.Show("流程存在循环依赖,请检查连线。", "无法运行", MessageBoxButton.OK, MessageBoxImage.Warning); return; }持久化格式里连线两端存的是节点名字符串,加载后第一步就要验证字符串能否映射到现有节点,找不到就跳过这条连线并记录日志。这种防御式校验在框架层面多写几行,能省掉现场运维大量排查时间。
6. 收尾不是总结:几个值得做的进阶与验证手法
6.1 用标准图集验证框架可靠性
框架能跑通只是第一步,能不能稳定交付是另一回事。我习惯准备一组覆盖不同光照、不同位置的样本图,比如 20 张标准件图,把每个节点的耗时和结果记录成结构化日志,连着跑 100 次取均值和 P95。节点耗时的打点可以放在调度器里,不污染节点代码。
var sw = Stopwatch.StartNew(); var ok = await node.Execute(_context); sw.Stop(); _nodeStats[node.Name] = sw.ElapsedMilliseconds;这样能快速发现哪个节点是性能瓶颈。曾经有个预处理节点跑一张图要 800ms,打点之后发现是高斯滤波核设大了,换成 3x3 直接降到 80ms。没有打点,这种问题只能靠猜。
6.2 把节点做成插件 DLL,实现「换算法不换框架」
节点库如果只能靠改主程序增删,框架很快就僵了。更好的做法是约定一个 Plugins 目录,程序启动时用 Assembly.LoadFrom 扫描目录里的 DLL,找出所有继承 VisionNode 的类型,自动注册到节点库。新算法编译成独立 DLL 丢进目录,重启程序就出现在节点列表里。流程文件里保存节点类型名,加载时从注册表反查类型并创建实例。
有次在现场,产线临时换产品,操作员自己拖了三个节点、改了阈值和模板图就把流程跑通了,我站在旁边看,那一下觉得之前踩的坑都值了。后来我养成了一个习惯:每次想往框架里加节点,先问一句它能不能被别人复用,不能就不放进去。这个原则让框架半年没加过冗余节点。希望帮到你。
本文还有配套的精品资源,点击获取