ARTICLE DETAIL

资讯详情

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

WinForms+Halcon仿VisionPro拖拽式视觉检测工具流设计

WinForms+Halcon仿VisionPro拖拽式视觉检测工具流设计 简介基于Halcon与C# Winform构建的通用图像处理工具工程面向机器视觉开发者和C#/.NET工程师解决图像处理流程快速搭建与模块复用问题。工具仿照VisionPro的拖拽式交互通过插件机制动态加载功能模块支持工具间参数传递及JOB流程运行走向配置降低了开发门槛。压缩包共715个文件大小约21.94MB以cs源码、resx界面资源、png图标、dll运行库及exe可执行为主另含23个csproj工程文件和sln解决方案可直接打开编译学习。已有237人学习。压缩包内包含VisionEdit-master主工程完整呈现插件封装、数据流衔接、图像编辑与处理流程设计思路适合需要快速上手Halcon二次开发、理解模块化视觉框架的开发者参考。1. 用 WinForms Halcon 复刻 VisionPro 的拖拽式工具流为什么值得自己写视觉检测项目做久了你会发现现场工程师脑子里那张“检测流程图”就是一张有向图读图、滤波、定位、测量、判定每个模块有输入输出按顺序跑。VisionPro 把这套交互做得最彻底工具拖到画布一拉连接线数据就传过去了改流程不用改代码。但 VisionPro 的授权成本和算法封闭性让不少集成商开始动自己写的念头WinForms 做壳Halcon 出算子仿 VisionPro 的拖拉形式做一套通用图像处理工具。这个标题指向的正是这样一个方案。Halcon 负责算力WinForms 负责界面插件机制让工具能动态装卸Job 负责流程运行走向。它解决的是“换个检测物料就要重新改软件”的痛点适合手里已有 Halcon 授权、又需要频繁调整工艺流程的团队。听着是件挺大的工程但拆开看就是三件事工具怎么封装、连线怎么建模、插件怎么加载。下面分开说透。2. 整体骨架把 Halcon 算子包装成工具节点再让工具之间“接线”传值2.1 五层架构接口层、运行时层、界面层与 Halcon 适配层拆分的原因只写一个图像处理小工具比如“打开图片、做一次滤波、保存结果”直接在窗体按钮里调 HOperatorSet 就行。但标题里出现“通用”两个字含义就变了工具种类不确定流程组合不确定工具之间传什么值也不确定。把算法调用直接写死在窗体代码里流程一变就要重新编译发布现场根本等不起。所以我做这类项目时第一步不是写界面而是先把架构切层。界面层只负责 WinForms 展示运行时层管工具实例和数据流插件协议层定义契约Halcon 适配层处理 HObject 生命周期插件加载层负责从 DLL 挖掘工具。层次分明后才能回答那个关键问题新加一个算法工具需要动老代码吗答案是如果走插件路径一行都不动。这里要特别给 Halcon 适配层一个位置因为 Halcon 的对象模型和 WinForms 的托管环境是两套世界观。HImage、HRegion 这些 HObject 子类背后是原生句柄需要显式 Dispose而 WinForms 里的控件靠垃圾回收。如果你把 HImage 直接塞进 Dictionary 里不管Job 跑一晚上能积攒几千个未释放句柄最后 Halcon 卡死在某个莫名其妙的算子调用上。这个坑做 Halcon 集成的老手基本都踩过。所以我建议的表如下层职责典型组件界面层工具箱、画布、属性网格、状态栏MainForm、ToolBoxControl、FlowCanvas运行时层工具实例生命周期、数据流、Job 执行顺序JobExecutor、ToolContext插件协议层工具接口、特性元数据IImageTool、ToolPluginAttributeHalcon 适配层HObject 包装、类型克隆、统一释放HalconObjectRegistry、HImageDisplay插件加载层扫描 DLL、反射创建工具定义PluginCatalog、ToolDefinition2.2 标准数据桶用 ToolData 统一承载 HImage、HRegion、HTuple工具与工具之间要传值先得有装值的“桶”。VisionPro 的做法是强类型端口每个工具声明自己的输入输出类型我们在自研方案里常用做法是一个宽松的 ToolData 类底层用 Dictionary对外提供类型安全的读写方法。为什么不用强类型端口因为 Halcon 算子的输入输出类型本来就杂HImage、HRegion、HXLDCont、HTuple 混着传强类型端口会让插件接口变得很重。// ToolData.cs —— 工具间值传递的统一数据桶 public sealed class ToolData { private readonly Dictionarystring, object _data new Dictionarystring, object(); public void SetT(string key, T value) { if (value is IHalconObject halconObj) { // Halcon 对象存副本Clone 只增加引用计数不复制像素数据 _data[key] halconObj.Clone(); } else { _data[key] value; } } public T GetT(string key) { if (_data.TryGetValue(key, out var val)) return (T)val; return default; } }这段代码里的 Set 方法有个反直觉的设计无论传进来的是 HImage 还是 HRegion都先 Clone 再存。Clone 对 Halcon 对象来说是引用计数加一不是深拷贝像素所以性能开销可以忽略。为什么要这么做因为 Halcon 工具经常会在内部把输入对象 Dispose 掉典型场景就是“读取图像”工具跑完顺手把图像释放了下游“Blob 分析”再去取就得到空句柄。Clone 一份放在数据桶里上游销毁影响不到下游。Get 方法用泛型做类型转换要求调用方明确知道数据类型。连接线在界面上已经做了端口类型匹配所以代码里强转不会经常失败。如果发现取出来是 null 或类型不匹配多半是工具连线时没接对端口此时应该把工具名和数据 key 一起写进诊断日志而不是默默吞掉异常。2.3 工具执行的最小实现Execute() 输入输出契约与一个真实工具示例有了数据桶工具节点就可以定义得非常“窄”。VisionPro 的工具本质是个黑匣子界面调参数Execute 吃输入出输出内部算法怎么实现没人管。自研时我也是这个思路接口只约两件事能执行能给出编辑控件。// IImageTool.cs —— 工具插件必须实现的最小契约 public interface IImageTool { string Name { get; } string Category { get; } bool IsReady { get; } void Execute(ToolContext context); Control CreateEditControl(ToolContext context); } // ToolContext.cs —— 一次 Job 运行期的共享上下文 public class ToolContext { public ToolData Inputs { get; } new ToolData(); public ToolData Outputs { get; } new ToolData(); public Dictionarystring, object GlobalStates { get; } new Dictionarystring, object(); public Exception LastError { get; set; } }Execute 方法不返回结果一切结果写进 context.Outputs。这样 JobExecutor 才能统一处理流程而不需要关心每个工具特有的返回类型。context.GlobalStates 用来放跨工具的全局状态比如相机标定矩阵标定工具算完后放进 GlobalStates后面坐标变换工具直接取不需要专门拖一条连接线。下面是一个最简单的“读取图像”工具它演示了工具的最小实现长什么样public class ReadImageTool : IImageTool { private string _imagePath ; public string Name 读取图像; public string Category 输入; public bool IsReady File.Exists(_imagePath); public Control CreateEditControl(ToolContext context) { // 返回一个用于设置图片路径的 WinForms 控件 TextBox tb new TextBox { Width 200 }; tb.Text _imagePath; tb.TextChanged (s, e) _imagePath tb.Text; return tb; } public void Execute(ToolContext context) { HImage image new HImage(); image.ReadImage(_imagePath); context.Outputs.Set(Image, image); // 注意image 局部变量不在这里 Dispose所有权交给 Outputs } }这个例子看起来简单关键却在局部变量 image工具执行完不能把 image 释放掉因为 Outputs 里的 Clone 只是引用计数加一真正的持有者还是这个局部变量。正确做法是让工具内创建的 HImage 所有权移交给 Outputs或者显式把局部引用置空。这块逻辑不清楚Job 跑几次后就会出现句柄泄漏。经验做法是谁的输出谁负责但释放要延迟到 Job 结束时由 JobExecutor 统一遍历所有 Outputs 释放一遍这样避免“上游释放、下游还在用”的竞争。3. 仿 VisionPro 的拖拉与 JOB 运行画布、连接线和执行顺序3.1 工具箱到画布的 DragDrop 落地从 ToolDefinition 到 ToolNode工具箱里列的是工具模板画布上放的是工具实例。拖动时传递的数据不能用字符串类型名要传递一个 ToolDefinition 对象里面包含元数据和创建实例的方法。这样在 DragDrop 事件里拿到定义后直接实例化不用再走一遍类型反射。// 工具箱里的 ListBox 触发拖动 private void ToolBox_ItemDrag(object sender, ItemDragEventArgs e) { ToolDefinition def e.Item as ToolDefinition; if (def ! null) { _toolBox.DoDragDrop(def, DragDropEffects.Copy); } } // 画布接收拖放 private void Canvas_DragDrop(object sender, DragEventArgs e) { if (!e.Data.GetDataPresent(typeof(ToolDefinition))) return; ToolDefinition def e.Data.GetData(typeof(ToolDefinition)) as ToolDefinition; Point dropPos Canvas.PointToClient(new Point(e.X, e.Y)); IImageTool instance def.CreateInstance(); _canvasNodes.Add(new ToolNode(instance, dropPos)); Canvas.Invalidate(); // 触发重绘画出新节点和已有的连线 }这里用 DragDropEffects.Copy 而不是 Move意图是“从模板复制新实例”工具箱里的模板不能被拖走。ToolDefinition.CreateInstance 内部用 Activator.CreateInstance 反射创建工具实例这一步必须延迟到真正放下时执行不能在程序启动扫描插件时就 new 出来。很多 Halcon 工具在构造函数里会加载算子库、模板文件提前实例化会把启动时间拖得很长操作体验很差。画布建议用一个自定义的 UserControl 来做而不是 Panel 里堆控件。每个 ToolNode 在画布上只是记录坐标和工具实例界面绘制全部在 OnPaint 里手动完成。这样做的好处是拖动缩放、连接线命中检测、选中框这些交互都能接管不会被 WinForms 的布局引擎干扰。节点外观仿照 VisionPro 的风格外框方块、左侧输入端口、右侧输出端口、底部工具名称整体用灰白底色衬托 Halcon 图像结果界面观感会专业很多。3.2 连接线的绘制与命中检测什么时候允许“接线”连接线是仿 VisionPro 拖拉形式的核心。用户在输出端口按下鼠标拖到另一个工具的输入端口释放就建立了数据通路。这条数据通路在内部用一个 PortLink 对象表示public class PortLink { public ToolNode SourceNode { get; set; } public string SourceOutputKey { get; set; } public ToolNode TargetNode { get; set; } public string TargetInputKey { get; set; } }画线用 GDI 的贝塞尔曲线视觉上比直线柔和也更接近 VisionPro 的风格。绘制代码放在画布 OnPaint 的 PaintEventArgs.Graphics 里用 Pen 和 DrawBezier 即可。判断鼠标是否点在线上不要遍历所有连线做贝塞尔曲线解析式求解性能扛不住。我把每条线在绘制时缓存 20 个采样点命中检测时循环这些点计算每个点到鼠标的欧氏距离小于 8 像素就视为选中。这个方案在 100 个工具节点规模下实测很流畅300 个节点才开始有明显卡顿。连线合法性要在这时判断目标工具是否包含叫这个名称的输入 keyinput key 和 output key 的类型是否匹配如果类型不匹配弹一个轻量提示不建立连线。我见过有人把“图像”输出接到“测量结果”输入类型转换在 Execute 时才报错跑流程到一半才发现那才叫折腾。宁可拖线时不通过也不要等到 Job 运行到一半才翻车。3.3 JOB 运行走向拓扑排序、任务进度与断点执行流程Job 的执行走向由连线关系决定但现场工程师习惯“从上往下看流程”。实际上只要建成有向无环图执行顺序就等价于拓扑排序。我实现的 JobExecutor 会把 PortLink 整理成邻接表做一个最简单的 Kahn 算法拓扑排序。// JobExecutor.cs —— 拓扑排序核心片段 public ListToolNode TopologicalSort(ListToolNode nodes, ListPortLink links) { var inDegree nodes.ToDictionary(n n, n 0); var adj nodes.ToDictionary(n n, new ListToolNode()); foreach (var link in links) { adj[link.SourceNode].Add(link.TargetNode); inDegree[link.TargetNode]; } var queue new QueueToolNode(nodes.Where(n inDegree[n] 0)); var sorted new ListToolNode(); while (queue.Count 0) { var current queue.Dequeue(); sorted.Add(current); foreach (var next in adj[current]) { if (--inDegree[next] 0) { queue.Enqueue(next); } } } if (sorted.Count ! nodes.Count) throw new InvalidOperationException(工具之间存在循环依赖无法执行); return sorted; }这个实现有个前提工具间的依赖完全由连线决定。但现实中还有一种“隐性依赖”比如工具 A 和工具 C 没有连线但 A 把矩阵写进 GlobalStatesC 执行时需要读它。只按连线排序C 可能跑到 A 前面。我在 IImageTool 接口里加了 PrerequisiteToolIds 列表拓扑排序时把这些前置工具也当作 fake link 塞进邻接表从而保证 C 一定在 A 之后执行。Job 运行不能阻塞 UI。执行循环放到 Task.Run 里每个工具执行完成回调一次进度更新 WinForms 状态栏和进度条。这里有一个并发细节进度回调要回到 UI 线程执行如果直接用 BackgroundWorker 的 ReportProgress 就不用操心线程切换如果用 Task ConfigureAwait(false)回调里访问进度条时先检查 InvokeRequired别把界面线程拖进跨线程操作异常里。// 启动 Job使用 async/await 保持 UI 响应 private async void btnRunJob_Click(object sender, EventArgs e) { btnRunJob.Enabled false; statusStripProgress.Value 0; ToolContext context new ToolContext(); Stopwatch sw Stopwatch.StartNew(); await Task.Run(() _executor.ExecuteJob(_sortedTools, context)); sw.Stop(); btnRunJob.Enabled true; statusStripProgress.Value 100; toolStripStatusLabel.Text $JOB 执行完成耗时 {sw.ElapsedMilliseconds} ms; if (context.LastError ! null) { MessageBox.Show(context.LastError.Message, JOB 运行错误); } }await 之后的代码默认回到 WinForms 主同步上下文所以更新进度条和按钮状态不需要额外 Invoke。这是 async/await 在 WinForms 里最省心的用法。千万不要在 Task 内部直接改 UI 控件那是经典翻车行为。4. 插件动态加载让每个图像算法变成一个可控插件4.1 定义插件接口与元数据特性宿主与插件之间的“协议”标题里的“插件可动态加载调用”是整个方案最容易理解错的地方。插件不是一个单独的功能 DLL而是整套工具以独立程序集的形式存在插件目录里。宿主启动时扫描插件目录识别出所有工具类展示在工具箱里。要识别就需要一个约定宿主定义 IImageTool 接口插件实现它并打上一个 ToolPluginAttribute 特性。// ToolPluginAttribute.cs —— 工具元数据标注 [AttributeUsage(AttributeTargets.Class, AllowMultiple false)] public class ToolPluginAttribute : Attribute { public string Name { get; } public string Category { get; } public string Description { get; } public string IconKey { get; set; } public ToolPluginAttribute(string name, string category) { Name name; Category category; Description ; } }C# 的 Attribute 本身就是一种元数据协议。插件作者开发新算法工具时只需完成三件事引用宿主发布的接口程序集、实现 IImageTool、在工具类上贴特性。宿主完全不用重新编译。这比 VisionPro 的“工具组”模型更贴合 Halcon 的场景——Halcon 新算法层出不穷滤波、测量、骨架化、浓淡补正每个都能快速包装成一个插件工具。4.2 反射扫描与延迟实例化插件目录里发生了什么插件目录我做得很规矩宿主程序集旁边的 plugins 文件夹每个插件工程编译后把 DLL 复制进去。宿主在 Form_Load 时扫一遍目录把工具定义加载进 ListBox 或 ListView。// PluginCatalog.cs —— 扫描插件 DLL 并提取工具定义 public class PluginCatalog { public ListToolDefinition ScanPlugins(string pluginDir) { var definitions new ListToolDefinition(); foreach (string dllPath in Directory.GetFiles(pluginDir, *.dll)) { Assembly assembly; try { assembly Assembly.LoadFrom(dllPath); } catch { continue; // 非 .NET 程序集或依赖 Missing跳过不致命 } foreach (Type type in assembly.GetTypes()) { if (type.IsAbstract || !typeof(IImageTool).IsAssignableFrom(type)) continue; var attr type.GetCustomAttributeToolPluginAttribute(); if (attr null) continue; definitions.Add(new ToolDefinition(attr.Name, attr.Category, type) { Description attr.Description, IconKey attr.IconKey }); } } return definitions; } }ScanPlugins 里只保存 Type不创建实例这是刻意为之的延迟实例化。很多 Halcon 工具的构造过程会做算子初始化、模板加载代价很高而工具箱只是展示工具名称不真正用到工具实力没必要提前 new。等用户把工具拖到画布那一刻再通过 ToolDefinition.CreateInstance 反射实例化。这里要说两个经验参数。第一Assembly.LoadFrom 会锁住插件 DLL导致运行时无法替换文件。我的做法是工具发布到 plugins 目录后更新时需要退出程序再覆盖。第二如果插件依赖了宿主不存在的程序集Assembly.GetTypes 可能抛 ReflectionTypeLoadException。捕获这个异常后用 ex.Types 拿到能加载的类型而不是直接整个跳过否则一个插件的坏依赖会影响其他正常插件。4.3 插件沙箱与防翻车版本冲突、依赖缺失和异常隔离插件动态加载最容易翻车的是版本冲突。插件 A 引用了 host 目录里的 halcondotnet.dll 21.11 版插件 B 自己带了一份 22.05 版进程内两个同一个程序集的不同版本会直接导致加载失败。常见做法是统一 Halcon 运行时所有插件工程引用 halcondotnet.dll 时设置 Copy Localfalse运行目录由宿主集中提供。这要求插件在技术上从属于宿主的公共依赖库不能各带各的。另一个防翻车点是异常隔离。Halcon 算子在实际图像上经常抛出 OperatorException比如图像尺寸不对、Region 为空、区域越界。如果这些异常直接冲向 JobExecutor一个工具的失败会把整个流程打断而且很难定位到底哪个工具、哪个参数出了问题。我在 ToolContext 里加 LastError 属性JobExecutor 对每个工具的 Execute 做统一 try-catch异常时把工具名、输入 key 列表、异常堆栈写进运行日志并让画布上该工具节点变红。这样现场操作员不用打开 IDE 就能看出哪一步挂了。插件目录里的 DLL 如果还有非托管依赖比如 Halcon 的 halcon.dll 没有放在系统 PATH 里加载时会报 DllNotFoundException。我的解决办法是在程序启动时设置 Environment.SetEnvironmentVariable(PATH, halconBinDir ; originalPath)把 Halcon 的 bin 目录加进去。这是做 Halcon 集成时几乎必用的环境变量细节能省掉一大半 DllNotFoundException 排查时间。5. 避坑记录Halcon 许可证、显示控件与工具链调试中的 5 个典型事故5.1 HWindowControl 在 WinForms 里闪烁、卡死图像不刷新现象把 HWindowControl 放在画布右侧的显示区域拖动窗体或滚动时图像区域疯狂闪烁把窗体最小化再恢复图像变成黑块迟迟不刷新。原因Halcon 自带的 HWindowControl 是非托管控件它直接映射了一个窗口句柄WinForms 的重绘机制和它的原生绘制不兼容位置改变时背景来不及刷新。解决显示窗口固定放在一个独立 Panel 里不随画布滚动图像刷新强制走 HOperatorSet.DispObj 到固定窗口句柄。如果必须缩放显示图像优先用 Halcon 新版本的 HSmartWindowControl它的内部有缓冲刷新机制闪烁比老控件轻得多。还可以在 Resize 事件里重新 SetPart 一次保持图像比例。5.2 Halcon 算子直接在界面线程跑整个窗体假死现象Job 跑到某个耗时算子比如大尺寸图像的各向异性滤波或骨架化整个窗口拖动不了连取消按钮都点不动只能任务管理器结束进程。原因Execute 里调用了 Halcon 的同步算子而 Job 执行循环被直接放在 UI 线程上调用。解决JobExecutor 的 Run 方法放进 Task.Run 或 BackgroundWorker工具内部不应访问任何 UI 控件所有进度通过 context.ProgressCallback 回调由宿主负责线程调度。这里有个执行层面的原则图像处理线程和界面线程必须分离Halcon 的 HWindowControl 可以在工作线程里调 DispObj但访问控件属性时要用 Invoke 包一层。5.3 插件加载后报“无法加载 halcondotnet.dll”或坏持的 Halcon 许可证现象宿主启动正常拖入某个插件工具后直接抛 FileNotFoundException指向 halcondotnet.dll有的现场机器上则报出“License not found”一类的运行时错误所有 Halcon 算子调用全部失败。原因halcondotnet.dll 没有位于宿主输出目录或系统 PATH 中或者安装的 Halcon 运行时授权过期、授权指向的 MAC 地址不对。解决在启动程序里把 Halcon 安装目录的 bin\x64 追加到 Environment PATH确认环境变量 HALCONROOT 指向实际的 Halcon 安装目录。许可证方面项目开发期用本机授权部署到产线前要确认持有什么授权类型运行版省成本但功能受限测量和深度学习算子经常需要更高档的授权这是选型时就要确定的不要等到现场部署那天才发现算子跑不起来。5.4 工具之间传 HObject 引用下游拿到空句柄或数据被并发修改现象上游工具执行完下游工具读取 Image 是空对象或者多个 Job 并行跑时同一个 Region 变量被另一个 Job 的修改冲掉结果忽好忽坏。原因ToolData 里如果直接保存原始引用上游工具内部一旦执行 Dispose 就释放了下游要用的句柄多个 Job 共享同一个 ToolData 实例更会在并发时互相干扰。解决ToolData.Set 对 IHalconObject 强制 Clone增加引用计数每个 Job 独立创建 ToolContextJobExecutor 在 Job 结束时统一 Dispose 所有 Outputs。我自己坚持“一个 Job 一个 ToolContext”哪怕内存多占一点也不会出现数据竞争这种极难排查的 bug血泪经验。5.5 连线“看起来连上了”但 JOB 执行顺序错乱结果时好时坏现象画布上明明把 B 工具的输入连接到 A 工具的输出但运行 Job 时 B 经常先执行拿到的输入是上一次运行留下的旧值。原因拓扑排序只考虑了 PortLink而部分工具的依赖关系是通过 GlobalStates 隐式建立的拓扑排序看不见。解决把隐式依赖写成工具的一个接口属性 PrerequisiteToolIds在排序前把这些依赖也加入邻接表。另外画布要支持循环依赖检测当用户准备在已排序的工具之间建立反向连线时立刻拦截提示。这个校验写起来非常简单调两遍拓扑排序就能查出是否有环每次连线完成都重排一次目前没有发现漏网的情况。6. 把工具流做成能验收的东西断点调试、性能计时与回归测试6.1 工具执行计时与结果快照让“上一次跑得好好的”有后悔药Job 跑一次不能只给我一个“成功/失败”的状态。我习惯让 JobExecutor 给每个工具建一个 ToolSnapshot记录工具名称、耗时、输出关键数值、图像缩略图路径。图像缩略图用 HOperatorSet.WriteImage 输出 PNG关键数值从 ToolData 中挑几个重点字段存成 KV。public class ToolSnapshot { public string ToolName { get; set; } public double ElapsedMs { get; set; } public Dictionarystring, string KeyValues { get; set; } public string ThumbPath { get; set; } public string Error { get; set; } }这些快照在调试时是宝贝。产线半夜打电话说“测量值跳了”你用快照回放那个批次的运行数据直接看到是滤波模块耗时暴涨还是定位区域偏移比远程连现场猜快得多也算是给自己留一道后悔药。6.2 Job 断点与单步执行关键工具跑完就暂停工具链调试到后期断点比一句句注释代码有用得多。我在 JobExecutor 里用一个 ManualResetEventSlim 做暂停点给工具节点设置 IsBreakPoint执行到这个节点前等待信号界面上的“继续”按钮释放信号让 Job 接着跑。单步执行就是把下一个工具临时设为断点跑完自动停下。这个能力对现场调试价值很大让现场工程师看到哪一步开始结果不对。6.3 同一 JOB 的重复率回归验证怎么设计工具链发布前我会准备一组标准样本图跑同一份 Job 二十次统计每个测量工具输出数值的标准差。坐标误差超过 0.05 像素宽度值波动超过 0.02 毫米就去查对应工具的 Halcon 算子参数是不是把非确定性算法比如检测算子的模式匹配打开全搜索和确定性算法混用了。验证的时候把每个工具的 Snapshot.KeyValues 汇总成表格打印出最大值、最小值、平均差和 3 倍标准差这一页纸就是给老板和客户看的验收证据。我现在养成的习惯是每个工具默认开启诊断模式界面上一个按钮导出全部 Snapshot导出格式直接是 CSV字段一目了然。不管谁说“这次肯定没问题”我都先跑 20 遍回归再答复。图像处理这行翻车往往不是算子不会调而是流程状态乱掉没察觉。希望帮到你。本文还有配套的精品资源点击获取
返回列表