简介:面向工业视觉与上位机开发者的仿VisionMaster通用视觉框架源码包,基于WPF、OpenCvSharp与C#实现流程图式拖拉拽流程编排,开箱即用且便于二次开发。压缩包共2000个文件,约335.39MB,以C#源码(.cs)为核心,配以JSON配置、XAML界面、XML工程配置、DLL依赖库及csproj项目文件,涵盖界面、逻辑与工程配置,可直接编译运行。工程按Views、ViewModel、Models、Service四层组织,清晰体现MVVM模式与界面、数据、存储、服务分离的设计思路;内部已集成深度学习、线程控制、延时跳转、AI YOLOv等视觉工具插件,可理解插件式功能扩展机制,也便于按需替换或新增算法模块。适合需要快速搭建视觉检测原型或研究视觉框架架构的开发者,无论用于课程设计、毕业课题还是实际产线视觉定位,都有较强参考价值;既可用作学习参考,也能基于源码替换与新增工具模块,投入实际项目。目前已有119人学习下载。
1. 用 WPF + OpenCvSharp 拆一个仿 VisionMaster 的拖拉拽视觉框架:最值得先读的是交互内核
做视觉项目的人,大多在流程 IDE 上耗过时间:读图、找特征、算结果,每换一个项目就要重排一遍逻辑,代码写到一半又发现调参界面和算法逻辑耦合得太死。这个资源想解决的就是这件事——一套用 WPF + C# + OpenCvSharp 写的仿 VisionMaster 的拖拉拽通用视觉框架软件,不是 Demo,是整套源码,开箱即用。你会在里面看到真正可落地的流程编辑交互:左侧工具箱把算子拖到画布,连线决定数据流向,右侧属性面板直接改参数,整套流程能存成文件,再打开接着改。适合两类人:一类是要快速搭自家视觉软件原型的工程师,一类是刚入视觉岗位、想弄明白这类 IDE 内部机制的新人。我拆完的结论是:界面反而不是难点,把节点的数据流和序列化想清楚,整个项目就立住了一半。
2. 源码结构与第一次跑通:先别急着看算法,把工程的四条边界理清
这一章不做代码逐行讲解,先带你把这套源码当成一个解决方案来读。否则你打开 .sln 面对几百个文件,很容易迷失在 XAML 和算子的细节里。
2.1 解决方案里的工程边界:谁负责界面,谁负责调度,谁负责干活
项目给我的第一印象是工程边界分得清楚。一个完整的流程 IDE,如果所有代码堆在一个 WPF 工程里,后面加算子就是一场灾难。这套源码把功能拆成了四个可独立编译的工程,大致划分如下:
VisionFramework.sln ├─ App.UI // WPF 主程序,MainWindow、画布交互、工具箱、属性面板 ├─ Flow.Core // 流程内核,节点抽象、端口、调度器、序列化 ├─ Algorithm.Library // 算子实现,读图、灰度、二值化、找圆、模板匹配 └─ Controls.Common // 通用控件,节点样式、连线段、端口拖拽辅助类- App.UI 引用 Flow.Core 和 Controls.Common,负责把用户操作翻译成流程模型的变化。
- Flow.Core 不依赖 OpenCvSharp,它只管节点的执行和数据的搬运。
- Algorithm.Library 引用 Flow.Core 和 OpenCvSharp,具体算法在这里实现。
这种分层的好处是:你要加一个新算法,只需要在 Algorithm.Library 里加一个类,App.UI 几乎不用改。如果以后想把调度内核换成别的界面框架,Flow.Core 也可以独立复用。我接触过的很多项目源码,问题恰恰出在 UI 和业务逻辑揉在一起,这版能分开,说明设计者的思路是经过实际项目打磨过的。
2.2 启动链路:从 App.xaml.cs 到流程画布的第一帧
看启动顺序,能最快摸清一个 WPF 项目的骨架。项目采用的是常见的依赖注入方式做初始化,入口在 App.xaml.cs 的 OnStartup 里:
protected override void OnStartup(StartupEventArgs e) { base.OnStartup(e); // 1. 构建服务容器,注册流程内核、算子注册表、主窗口 ViewModel _bootstrapper = new Bootstrapper(); _container = _bootstrapper.BuildContainer(); // 2. 读取上次退出时保存的流程文件(如果存在) var recentFile = LoadRecentFlowFile(); var flowEngine = _container.Resolve<FlowEngine>(); if (!string.IsNullOrEmpty(recentFile)) { flowEngine.Load(recentFile); } // 3. 主窗口交给容器管理,ViewModel 负责把流程模型映射到画布 var mainWindow = new MainWindow { DataContext = _container.Resolve<MainWindowViewModel>() }; mainWindow.Show(); }这里有两个关键选择值得注意。第一,主窗口的 ViewModel 是从容器里解析出来的,而不是在 XAML 里 new 出来,这样 FlowEngine 实例会被共享注入,画布上的所有节点和核心引擎拿到的是同一个实例。第二,流程文件的加载发生在窗口显示之前,实现的是“打开软件就恢复到上次编辑现场”的效果,这个细节对工业软件来说很实用。
如果你之前写 WPF 是直接在 MainWindow 的构造函数里 new 一堆对象,这个启动结构就是很好的升级方向。整套注册逻辑都集中在 Bootstrapper 里,找依赖关系一目了然。
2.3 跑通第一个流程:拖两个节点,连起来,按下运行
源码里通常自带一个示例流程,但新手还是应该亲手拖一遍流程。操作路径是这样:
- 用 Visual Studio 打开解决方案,先还原 NuGet 包。OpenCvSharp 的包会比较大,首次编译耐心等。
- 把 App.UI 设为启动项目,直接 F5 运行。主界面出现后,左侧是工具箱,中间是画布,右侧是属性面板。
- 从工具箱拖一个“图像读取”节点到画布,再拖一个“二值化”节点。把前者的输出端口拖动到后者的输入端口,松手后出现一条连线。
- 在属性面板里设置图像路径和阈值参数。
- 点运行按钮,观察节点状态。节点变成绿色表示执行成功,红色表示抛异常。
第一次跑通的重点不是算法效果,而是确认事件链路是完整的:拖拽能创建节点,连线能建立数据通道,运行能触发执行。如果到这一步都顺畅,说明这套 IDE 的骨架是健康的。后续你要做二次开发,基本就是在这个链路上加新的节点类型。
3. 拖拉拽交互的关键实现:工具箱、画布放置、连线判定与流程存档
很多视觉工程师拿到这类源码,第一反应是找算法在哪,但实际最花时间的是交互层。VisionMaster 这类产品用起来顺手,核心就在于“拖、连、调”三个动作的响应足够自然。这一章拆解这套源码里对应的实现方式。
3.1 工具箱的数据模型:为什么用 Factory 而不是 Type
工具箱不是把字符串显示在 ListBox 里那么简单,关键是要让画布在 Drop 时能拿到“该创建哪种节点”的信息。源码里的 ToolboxItem 设计了这样一组属性:
public class ToolboxItem { // 左侧列表中显示的名称 public string DisplayName { get; set; } // 缩略描述,鼠标悬停时提示用途 public string Description { get; set; } // 关键点:这里保存的不是 Type,而是一个工厂委托 public Func<FlowNode> Factory { get; set; } }这里用 Factory 而不是 Type,是一个值得借鉴的设计。如果直接存 Type,画布 Drop 时就要用反射 Activator.CreateInstance 创建实例,效率尚可,但类型如果被裁剪或程序集改变,反射就会失败。而把创建逻辑收敛到注册阶段,工具箱绑定数据源的时候,每个节点就自带创建能力。
工具箱的 ViewModel 通常长这样:
public class ToolboxViewModel { public ObservableCollection<ToolboxItem> Items { get; } = new(); public void RegisterNode<T>(string displayName, string description) where T : FlowNode, new() { Items.Add(new ToolboxItem { DisplayName = displayName, Description = description, Factory = () => new T() }); } }拖拽开始则是在 ListBox 的 MouseLeftButtonDown 事件里调用 DoDragDrop:
private void ToolboxItem_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) { var item = (sender as FrameworkElement)?.DataContext as ToolboxItem; if (item == null) return; var dataObject = new DataObject(typeof(ToolboxItem), item); DragDrop.DoDragDrop(toolboxListBox, dataObject, DragDropEffects.Copy); }注意 DataObject 里装的是 ToolboxItem 对象本身,而不是字符串。这是很多翻车案例的起点——如果传的是 DisplayName,画布 Drop 时还得再做一次名称到类型的映射,多一层不必要的维护。直接传对象,配合 DragDropEffects.Copy,语义上表示“我是创建一个新副本,而不是移动原节点”。
3.2 画布的 Drop 逻辑:坐标基准和鼠标偏移是第一个坑
画布用的是标准 Canvas。节点落位的关键在 Drop 事件里的坐标转换。源码里的处理逻辑大致如下:
private void FlowCanvas_Drop(object sender, DragEventArgs e) { if (!e.Data.GetDataPresent(typeof(ToolboxItem))) return; var item = e.Data.GetData(typeof(ToolboxItem)) as ToolboxItem; var pos = e.GetPosition(flowCanvas); // 用工厂创建节点实例,并指定初始位置 var node = item.Factory(); node.NodeId = Guid.NewGuid().ToString(); node.X = pos.X - 60; // 减去节点宽度的一半 node.Y = pos.Y - 20; // 减去节点标题栏高度的一半 FlowNodes.Add(node); }这里的 60 和 20 不是随便写的:节点模板宽度约 120,高度约 40,做一次粗略偏移后,节点中心能基本落在鼠标释放的位置。如果你自己改造节点样式导致尺寸变化,这两项必须同步调整,否则视觉上会有明显的鼠标和节点错位感。
还有一点,e.GetPosition 的参数是 flowCanvas 而非主窗口。让新手容易踩坑的就是这里:如果对主窗口取坐标,画布左上角不在 (0,0) 时,所有节点都会偏离一段距离。只要画布外层还有边框、工具栏、滚动条,这个偏移就一定会出现。所以拿到坐标时务必传入放置目标容器。
3.3 节点移动与三连击式的事件序列
节点在画布上拖动的实现没有直接上 Thumb 控件,而是手动处理鼠标事件三连击:MouseLeftButtonDown、MouseMove、MouseLeftButtonUp。为什么这么选?因为 Thumb 控件在 Canvas 里虽然拖动逻辑简单,但样式定制起来比较受限制,而且后续要支持缩放平移时,事件处理会更麻烦。手动实现的核心逻辑如下:
private void NodeControl_MouseLeftButtonDown(object sender, MouseButtonEventArgs e) { var node = (sender as FrameworkElement).DataContext as FlowNode; _draggingNode = node; _dragOffset = e.GetPosition(flowCanvas) - new Point(node.X, node.Y); node.IsSelected = true; (sender as UIElement).CaptureMouse(); e.Handled = true; } private void NodeControl_MouseMove(object sender, MouseEventArgs e) { if (_draggingNode == null) return; var pos = e.GetPosition(flowCanvas); _draggingNode.X = pos.X - _dragOffset.X; _draggingNode.Y = pos.Y - _dragOffset.Y; _draggingNode.RefreshConnections(); // 更新连线端点位置 } private void NodeControl_MouseLeftButtonUp(object sender, MouseButtonEventArgs e) { _draggingNode = null; (sender as UIElement).ReleaseMouseCapture(); }_dragOffset 的保存是保证节点移动时不会被“瞬移”到鼠标正下方。如果没有这个偏移量,按下鼠标那一刻节点就会跳到鼠标位置,体验很差。还有一点要注意,MouseDown 里要调用 CaptureMouse,否则鼠标移出节点控件范围后,Move 和 Up 事件就会断掉。
拖动结束后必须调用 RefreshConnections,把与之相连的连接线端点同步过去。这一点很容易遗漏,如果你发现节点拖动后连线不跟着走,基本是这个方法没有被调用。
3.4 连线的判定:HitTest 与端口类型校验
连线是这类 IDE 里最微妙的部分。用户从一个输出端口拖出一根临时线,松手时系统需要判断是否落在合法的输入端口上。源码里的实现思路是:拖动端口时,把端口对象放进 DataObject,画布 Drop 时做 VisualTreeHelper 命中测试,再向上查找最近的输入端口。
private void FlowCanvas_Drop(object sender, DragEventArgs e) { if (e.Data.GetData(typeof(OutputPort)) is OutputPort fromPort) { var hitElement = VisualTreeHelper.HitTest(flowCanvas, e.GetPosition(flowCanvas)) as DependencyObject; var toPort = FindParent<InputPort>(hitElement); if (toPort != null && CanConnect(fromPort, toPort)) { Connections.Add(new ConnectionViewModel(fromPort, toPort)); } } }CanConnect 这个方法承担了类型校验。它能避免用户把一个图像输出端口接在数值输入端口上,也避免了一个输出重复连接到同一个输入。三个校验逻辑一般如下:
- 输入端口未被占用,一个输入端口只能连一条线。
- 端口数据类型兼容,图像流只能接图像流,数值流只能接数值流。
- 不是连接到自身节点。
这套校验放在 Drop 时做,比放在执行时做要友好得多。错误暴露得越早,用户花费的试错成本越低。
3.5 流程序列化:把流程图存成 JSON 结构
流程保存功能是这类软件区别于普通调参工具的重要分水岭。源码里采用 JSON 保存,结构清楚,便于手工修改和调试:
{ "version": 1, "nodes": [ { "id": "node-001", "type": "ImageReadNode", "x": 120, "y": 80, "params": { "ImagePath": "C:/demo.bmp", "IsGray": false } }, { "id": "node-002", "type": "BinaryThresholdNode", "x": 320, "y": 80, "params": { "Threshold": 128, "MaxValue": 255 } } ], "connections": [ { "fromNodeId": "node-001", "fromPortIndex": 0, "toNodeId": "node-002", "toPortIndex": 0 } ] }保存时要把节点内部的 params 转换为普通字典,避免直接序列化控件的依赖属性。加载时按 type 字段创建节点实例,再给节点属性赋值。这里远比看起来复杂,因为反序列化会遇到类型映射丢失的问题,具体排错方案放在第 5 章展开。
4. 流程调度内核:数据如何从读图节点一路流到结果输出
交互层是皮,调度内核才是这套框架的里子。这一章聚焦一个核心问题:当用户按下运行,系统如何确保每个节点按连线方向被正确执行。
4.1 抽象节点:带端口、带坐标、带执行的统一基类
在 Flow.Core 里,节点并不直接等同于某个算法,而是一个带执行能力的模型。核心接口的常见形态如下:
public abstract class FlowNode { public string NodeId { get; set; } public double X { get; set; } public double Y { get; set; } // 输入端口和输出端口 public List<InputPort> Inputs { get; } = new(); public List<OutputPort> Outputs { get; } = new(); // 执行逻辑,由具体算法节点实现 public abstract object Run(FlowContext context); }InputPort 和 OutputPort 本质上是带数据缓存的数据槽:
public class InputPort { public string Name { get; set; } public object Value { get; set; } // 记录这个输入值来自哪个节点,便于界面显示调试信息 public string SourceNodeId { get; set; } } public class OutputPort { public string Name { get; set; } // 下游订阅者,用于通知式调度 public List<PortSubscription> Subscribers { get; } = new(); }把端口从节点中独立出来,是为了让连线和数据流动不依赖具体的节点类型。任何节点都具有一组输入和一组输出,调度器只和端口打交道,不需要知道内部是阈值分割还是模板匹配。
节点执行时,典型模式是先从输入端取值,运算后再写到输出端的 Value。这样数据流方向永远是从 Output 到 Input,和连线的视觉方向保持一致。
4.2 调度器:不做全局拓扑排序,而是让数据沿着连线流起来
有一种典型的错误设计是:每次运行前对整个流程图做一次完整的拓扑排序,然后在循环里按序执行所有节点。这听起来很严谨,但实际并不匹配交互式流程编辑器的需求。用户可能只想运行从某个节点开始的后半段,也可能流程中存在一个分支结构。更贴近 VisionMaster 的调度方式是“数据流驱动”:节点的执行由连线上游的输出触发。
源码里对应的执行器逻辑模糊掉细节后是这样的:
public void RunFrom(FlowNode startNode, FlowContext context) { var queue = new Queue<FlowNode>(); queue.Enqueue(startNode); while (queue.Count > 0) { var current = queue.Dequeue(); // 1. 执行当前节点 var outputs = current.Run(context); // 2. 把每个输出值派发给订阅了该端口的下游 for (int i = 0; i < current.Outputs.Count; i++) { var value = outputs[i]; foreach (var subscription in current.Outputs[i].Subscribers) { var inputPort = subscription.InputPort; inputPort.Value = value; inputPort.SourceNodeId = current.NodeId; // 3. 将下游节点加入队列,实现层递推进 queue.Enqueue(subscription.OwnerNode); } } } }这个设计有一个重要特性:节点只会在“上游已经执行完毕”的情况下被触发,因为下游入队的动作发生在输出值写入之后,这天然保证了执行的依赖顺序。流程图只要不存在环形连接,这个队列就一定能跑完。
如果用户在流程里人为定义了循环,调度器会进入死循环。所以工业级的实现通常还要加一个“最大执行步数”的保护,或者对节点执行次数做统计,超过阈值时弹出提示并终止。这个边界细节,二次开发的时候一定要保留。
4.3 OpenCvSharp 图像数据的流转:Mat 的引用计数与释放策略
在具体算子层,节点间传递最多的并不是普通数值,而是图像。用 OpenCvSharp 时,图像类型自然是 Mat。这里有一个必须理解的内存特征:Mat 在 OpenCvSharp 里是托管壳,它内部引用了一块原生内存。当 Mat 被复制到另一个变量时,原生内存的引用计数会增加,只有所有引用都被 Dispose,内存才会真正释放。
新手最常见的翻车方式是在每个算子节点里直接 new Mat,然后在节点结束时调用 Dispose:
public override object Run(FlowContext context) { var srcMat = context.GetImageInput<Mat>(0); // 危险写法:这里新建了一个 Mat,释放时机无法控制 var resultMat = new Mat(); Cv2.CvtColor(srcMat, resultMat, ColorConversionCodes.BGR2GRAY); return resultMat; }如果流程只有一个下游,这个写法问题不大。但流程存在分支时,同一个输出会被两个下游订阅。此时返回的 Mat 如果在上游节点执行结束时被释放,分支下游拿到的就是一个已释放的悬空对象。更可靠的做法是引入一个图像池或者引用管理集合:
public class MatPool { private readonly List<Mat> _allocated = new(); public Mat Rent() { var mat = new Mat(); lock (_allocated) { _allocated.Add(mat); } return mat; } public void DisposeAll() { lock (_allocated) { foreach (var mat in _allocated) { if (!mat.IsDisposed) mat.Dispose(); } _allocated.Clear(); } } }MatPool 一次性管理所有节点新创建的 Mat,在流程结束或者用户重新运行前统一释放。这样简化了每个节点的内存职责:算法节点只负责把结果交给 MatPool,不负责单独释放。代价是内存占用峰值会略高,但对桌面视觉软件来说,这个取舍通常是值得的。如果在实际项目里遇到长时间运行后内存持续增长,第一排查点就是哪些 Mat 没有被纳入池中管理。
4.4 属性面板:用反射驱动参数编辑,而不是手工写死控件
右侧属性面板在源码里不是为每个节点单独写一组 TextBox,而是基于节点暴露的公共属性,通过反射自动生成编辑行。这个机制极大降低了新增算子的工作量。核心思路如下:
public class PropertyPanelBuilder { public IEnumerable<PropertyRowViewModel> BuildFor(FlowNode node) { foreach (var prop in node.GetType().GetProperties()) { // 只收集标了编辑特性的属性 var display = prop.GetCustomAttribute<DisplayNameAttribute>(); var category = prop.GetCustomAttribute<CategoryAttribute>(); if (display == null) continue; yield return new PropertyRowViewModel { Category = category?.Category ?? "默认", PropertyName = prop.Name, DisplayName = display.DisplayName, PropertyType = prop.PropertyType, Value = prop.GetValue(node), SetValue = v => prop.SetValue(node, v) }; } } }设置值的 SetValue 闭包直接指向节点的原始属性,这样改完参数后不需要额外同步回节点对象。在这一层需要注意,如果属性类型是枚举,界面就要生成 ComboBox 而不是 TextBox;如果是布尔类型,则生成 CheckBox。通常的做法是根据 PropertyType 在 View 层选择不同的编辑控件模板。
5. 避坑与排查:五个高频问题,每一个我都踩过
这一章整理的五个问题,是我在阅读这类 WPF 流程框架源码后最想留下的笔记。前三个和交互相关,后两个与运行时相关。每条都按现象、原因、解决三个角度展开。
5.1 拖放后节点出现在画布外或者和鼠标错位一大截
现象:从工具箱拖出一个算子,松手后节点没有出现在鼠标附近,而是跑到画布左上角或直接被滚动条遮住。
原因:Drop 事件里取坐标时用了错误的参考元素。常见错误是把 e.GetPosition 的参数传给 MainWindow 或根 Grid,导致坐标没有减去画布的相对偏移。另外,节点模板尺寸和落位时的减半偏移量不一致,也会导致视觉上错位。
解决:一律使用真正承载节点的容器作为坐标基准:e.GetPosition(flowCanvas)。同时把节点中心落位的偏移量从固定数值改为动态获取,比如从节点模板的 ActualWidth 和 ActualHeight 计算,这样即使换了节点样式也不会失准。
5.2 OpenCvSharp 的 Mat 在多次运行流程后出现内存持续上涨
现象:同一张图反复运行流程,内存占用一次比一次高,最后系统卡顿,甚至出现 OpenCvSharpException 提示原生内存分配失败。
原因:每个算子节点执行时都新建了 Mat,又没有在流程结束时统一释放。GC 只能回收托管壳,原生内存只有引用计数归零才会释放,而节点对象一旦被界面引用,里面的 Mat 字段就可能一直存活。
解决:不要让每个节点自行管理 Mat 生命周期。改为由执行器统一维护一个 MatPool,流程运行结束或重新运行前调用 DisposeAll。如果某个中间 Mat 还需要保留用于显示,需要把它从池里摘出来单独管理,避免释放后图像控件显示异常。
5.3 连线删除后,下游节点还在重复执行上一次的旧数据
现象:把一条连线从画布上删除,但再次运行时下游节点仍然读取到上一次的输入值,结果看起来像是断线无效。
原因:InputPort.Value 字段在节点执行时被赋了新值,但在连线删除后,没有任何代码把输入值置空。调度器只通过 Subscribers 触发节点,而 InputPort 仍保留着历史缓存,节点执行时直接取出旧值。
解决:删除连线时,除了从 Connections 集合移除记录,还要同步执行 inputPort.Value = null、inputPort.SourceNodeId = null。更保险的做法是在每次流程开始前遍历所有节点的所有 InputPort,统一清空缓存,确保每次运行都是从干净状态开始。
5.4 反序列化流程文件时报类型错误,节点创建为空
现象:保存流程文件后重新打开程序,加载时报异常,提示找不到某个节点类型,或者节点创建出来是 null。
原因:反序列化时直接调用 Type.GetType 去解析 JSON 里的 type 字段。Type.GetType 默认只在当前程序集和 mscorlib 里查找,而具体的算法节点都在 Algorithm.Library 这个单独程序集里,程序集名称不匹配就会失败。
解决:维护一个类型注册表,在程序启动时用 GetType().Assembly 扫描所有 FlowNode 子类并登记到一个字典。反序列化时用这个字典做映射,而不是直接走 Type.GetType:
private static readonly Dictionary<string, Type> _nodeTypeMap = new(); public static void ScanAndRegister() { var assembly = typeof(FlowNode).Assembly; foreach (var type in assembly.GetTypes()) { if (!type.IsAbstract && typeof(FlowNode).IsAssignableFrom(type)) { _nodeTypeMap[type.Name] = type; } } } public static FlowNode Create(string typeName) { if (!_nodeTypeMap.TryGetValue(typeName, out var type)) throw new NotSupportedException($"未注册的节点类型: {typeName}"); return Activator.CreateInstance(type) as FlowNode; }这样即使节点类型挪了程序集,只要启动阶段扫描逻辑还在,加载流程文件就不会丢类型。
5.5 端口之间连不上线,拖出线条后松手没反应
现象:从输出端口拖到输入端口,松手后没有生成连线,也没有任何错误提示。
原因:最常见的是 HitTest 命中的元素是端口内部的某个子控件,比如 Border 或 TextBlock,而 FindParent 向上寻找 InputPort 时因为层级不对而失败。另外,如果端口外层被某个 Grid 设置了 IsHitTestVisible=false,整个端口都会失去命中响应。
解决:连线的目标查找不要依赖单一 HitTest,更稳妥的做法是遍历所有输入端口,计算鼠标位置和端口控件中心位置的距离,取最小距离且在阈值范围内的端口作为连接目标。这样比 VisualTree 的层级查找要稳定,也便于后期做连接器自动吸附。
6. 二次开发实战:自己写一个算子节点并接入面板,四步走
理解这套框架最快的方式,是亲手往里面加一个节点。下面以添加一个简单的“图像锐化”算子为例,演示完整接入过程。
第一步,在 Algorithm.Library 新建一个类,继承 FlowNode 并实现 Run:
[DisplayName("锐化滤镜")] public class SharpenFilterNode : FlowNode { [Category("参数"), Description("锐化强度")] public double Amount { get; set; } = 1.0; public override object Run(FlowContext context) { var inputMat = context.GetInput<Mat>(0); // 取第一路图像输入 var kernel = new Mat(3, 3, MatType.CV_32F); // 用固定核做一次自定义滤波,演示核心逻辑 Cv2.Filter2D(inputMat, outputMat, -1, kernel); return outputMat; } }第二步,在工具箱初始化时完成注册:
toolboxViewModel.RegisterNode<SharpenFilterNode>("锐化滤镜", "对灰度图像做锐化增强");第三步,在类型注册表中补充该类型。因为 ScanAndRegister 已自动扫描 FlowNode 子类,一般无需额外处理。
第四步,编译运行,新节点会自动出现在工具箱,属性面板也会通过反射识别 Amount 参数。
整个过程不需要碰主窗口的 XAML,也不需要改任何交互代码,核心优势就在这里。从那以后,我每接到一个新的视觉需求,都会先按这套框架的模板把算法跑通,再分支出参数和类型校验,这样既不会被 UI 拖累,也不会让算法代码和界面代码纠缠不清。希望这套源码和这篇拆解,能帮你省掉重建轮子的时间。
本文还有配套的精品资源,点击获取