
1. 一张NG图背后的业务痛点为什么要做FTP自动上传先讲一个我实际遇到过的场景。某条3C产线上的视觉检测工位用的是VisionMaster跑外观缺陷检测不良品图像全部保存在工控机本地。一开始没什么问题但项目跑了一个月后产线负责人跑过来找我说MES要追溯数据品质部每天要抽查NG图片售后那边也得看历史图总不能天天派个人去工控机旁边拷图。而且工控机本地磁盘已经快满了每天几千张图手工清理根本来不及。这就是典型的“图像只存不上传”的坑。VisionMaster本身可以很方便地把检测结果图像保存到本地磁盘但保存不等于归档。要做到真正的追溯、分析、远程审查必须把图像从工控机自动搬到服务器或共享存储上。FTP是最通用、成本最低、几乎任何操作系统和服务器都支持的协议。现在的需求就变成了在VisionMaster的流程跑完以后把指定的图像文件自动通过FTP上传到远程文件服务器传完以后本地文件按策略清理全程不需要人工干预。这篇内容就是围绕这个需求来拆的。我会把两条实现路线、FTP服务端的搭建、VisionMaster全局脚本里的完整代码、上传过程中最容易踩的坑以及外部二次开发模式的补充方案都讲清楚。不管你是刚接触VisionMaster的新手还是已经用了一段时间想优化图像归档流程的工程师这篇都值得看完。先说结论VisionMaster实现FTP自动上传技术上并不难核心就是三件事——拿到图像文件的本地路径、用FTP协议传出去、处理好网络异常和文件生命周期。但恰恰是“处理异常”这四个字让很多人在上线后被各种奇怪的问题折腾到怀疑人生。后面我会逐一把这些坑指出来。2. 两条技术路线流程内脚本与外部调用怎么选2.1 路线AVisionMaster全局脚本里直接发FTPVisionMaster的流程编排中有一个“全局脚本”模块支持C#脚本。它的核心作用就是在流程的某个节点执行一段自定义代码既可以读流程变量也可以操作文件系统。把FTP上传逻辑写在这个模块里等于在每个产品的检测流程中图像保存完成后立即触发上传。流程上的顺序大致是这样相机采集图像跑定位、测量、缺陷检测等算法。使用“保存图像”模块把结果图保存到工控机本地指定目录。把本地图像路径传给后面的“全局脚本”模块。脚本模块连接FTP服务器执行上传。根据上传结果决定是否删除本地文件、是否记录日志。优点很突出实时性高图像一出来马上传逻辑闭环和检测流程绑定流程跑到哪里图就传到那里不需要额外的外部程序或服务运维简单。缺点也要说清楚FTP上传是网络操作耗时不可控。如果服务器不在同一局域网、网络抖动厉害一次上传可能耗时几秒甚至超时。在高速产线上这会直接把节拍拖垮。所以我的建议是流程内上传适合图像量不大、单张图片小于几MB、服务器和工控机在同一个局域网内的场景。如果单张图几十MB或者每秒处理很多个产品不建议把FTP直接挂进主流程。2.2 路线B外部程序轮询目录与VM流程解耦另一条路线是让VisionMaster只负责“保存图像到本地”上传工作交给一个独立的外部程序来做。具体思路是VisionMaster流程里保存图像模块把图像保存到约定目录。外部程序比如C#写的Windows服务、定时任务通过FileSystemWatcher或定时轮询监视这个目录。发现新文件后读取文件并执行FTP上传上传完成后删除或移动到“已上传”目录。这条路线的核心价值就是解耦。VisionMaster流程的节拍不受网络影响上传失败了也不影响检测。外部程序可以做更复杂的重试、队列、断点续传、并发控制。哪怕网络断了文件也会先堆在本地网络恢复后自动补传。缺点就是多了一个系统组件多一套部署和监控。如果现场没有专门的IT支持这个程序挂了可能没人知道。所以采用这条路线时必须给外部程序加一个明显的心跳告警或看门狗机制。2.3 选型对照与我的经验建议我在不同项目里两条路线都用过给你一个比较主观但很实际的选型判断维度路线A流程内脚本路线B外部程序轮询实时性高图存完立即上传取决于轮询间隔通常1~3秒内对检测节拍影响有网络慢会拖流程无完全独立实现复杂度低只改VM流程即可中要额外开发和部署程序重试/队列能力弱脚本模块内循环不合适强可做完整队列和重试故障排查依赖VM日志依赖外部程序日志和服务状态适用场景小图、低频、局域网稳定大图、高速、网络不稳定或需要断网补传我的建议是如果你只是给一两台工位机做图像归档而且服务器就在车间机房直接上路线A省事。如果项目有十几台设备、图像量大、网络环境一般或者老板明确要求“断网不能丢图”那就干脆做路线B用独立服务来扛。顺便说一句很多朋友问VisionMaster基础版能不能用脚本和FTP功能这里明确一下全局脚本模块在基础版里就有和深度学习版没有关系。基础版和深度学习版的差异主要在算法工具包上不影响你做图像保存和FTP上传。3. FTP服务端搭建与账户目录设计3.1 服务端选择FileZilla Server还是Windows IISFTP上传必须有一个服务端。最常见的两个选择是FileZilla Server和Windows自带的IIS FTP服务。我个人的偏好是FileZilla Server原因很简单配置直观虚拟目录和用户权限清晰支持UTF-8中文文件名被动模式端口设置方便。Windows IIS FTP也不是不能用但权限配置要跟NTFS权限联动早期版本的编码兼容性还坑过我好几次。FileZilla Server装好后主要做三件事添加用户设置密码。指定该用户可访问的目录给写入权限。设置被动模式端口范围并在防火墙里放行。我一般会为视觉系统单独建一个专用账号比如vm_upload权限只给指定目录的写入和列表不给删除、不给重命名。表面上看起来多此一举实际上是在防“手滑操作”——一旦哪天有人用同一个账号手动连上去误删文件后悔都来不及。3.2 目录规划按日期和产线分目录FTP目录设计直接影响后续追溯效率。如果所有图像都堆在同一个目录下一个月几万个文件想在服务器上查一张图会非常痛苦。我常用的结构是/UploadRoot/ /Line01/ /2025/ /06/ /15/ NG_20250615_143025_001.jpg /Line02/ /2025/ /06/ /15/ OK_20250615_102233_005.jpg按“产线/年份/月份/日期”分层文件命名里带上日期时间和流水号。这样无论从FTP客户端翻目录还是写脚本按日期检索都非常方便。在VisionMaster里控制这个目录结构其实就是拼字符串的事后面代码部分会给你完整写法。3.3 被动模式端口与防火墙放行这里必须重点说不然你上线时会非常痛苦。默认情况下FTP有两个通道21端口是控制连接数据传输走的是另一个端口。主动模式是服务端主动连客户端被动模式是客户端主动连服务端。工控机通常在防火墙后面所以绝大多数场景要用被动模式。FileZilla Server里可以设置被动端口范围比如10000到10100。设置之后服务器防火墙必须放行这个端口段同时放行21端口。我在现场就碰到过配置全部正确但客户端总是卡在“读取目录列表”状态的情况。最后查了半天就是Windows防火墙只放行了21忘了放行10000~10100端口段。数据传输连接被墙功能自然跑不通。提示如果你用的是虚拟机里的FTP服务端除了Windows防火墙还要检查虚拟化平台的网络策略确保UDP/TCP的被动端口段能被外部访问。4. VisionMaster全局脚本实现FTP自动上传4.1 在流程里接入全局脚本模块以VisionMaster流程编辑为例从工具箱里拖动“全局脚本”模块到流程末尾。这个模块支持在流程变量和脚本之间交换数据。你需要定义输入参数用来接收“本地图像路径”再定义一个输出参数用来返回上传结果。不同的VM版本脚本模块的界面和API名称会略有差异但总体逻辑是一样的。有些版本通过参数列表直接配置变量有些版本用脚本内的Parameters对象访问。我下面给的代码是基于.NET C#的通用写法只要脚本宿主是.NET环境核心的系统类和方法全都能用。在配置输入参数时指向流程变量即可。比如“保存图像”模块输出的图片路径变量通常是字符串类型。如果你的保存模块输出的是一个路径列表多张图的情况那就需要用数组方式传入或者把多图展开成多个字符串参数。我这里先按单张图像路径来写多图的处理在4.4节补充。4.2 可直接套用的C#上传代码下面这段代码是核心。它的功能包括检查本地文件是否存在、自动创建远程目录、以被动模式上传文件、判断状态码、返回执行结果。你可以直接粘到VM的全局脚本模块里根据实际情况修改服务器地址、账号、密码和远程目录。using System; using System.IO; using System.Net; public class UserScript { public bool RunScript() { // 从VM全局脚本参数中读取图像路径 string localImagePath Parameters.Strings[LocalImagePath]; if (string.IsNullOrEmpty(localImagePath)) { return false; } if (!File.Exists(localImagePath)) { Log(本地文件不存在: localImagePath); return false; } string ftpHost 192.168.1.100; string ftpUser vm_upload; string ftpPass your_password; string remoteDir /UploadRoot/Line01/ DateTime.Now.ToString(yyyy) / DateTime.Now.ToString(MM) / DateTime.Now.ToString(dd); string remoteFileName Path.GetFileName(localImagePath); string ftpFullUri ftp:// ftpHost remoteDir / remoteFileName; try { EnsureRemoteDir(ftp:// ftpHost remoteDir, ftpUser, ftpPass); FtpWebRequest request (FtpWebRequest)WebRequest.Create(ftpFullUri); request.Method WebRequestMethods.Ftp.UploadFile; request.Credentials new NetworkCredential(ftpUser, ftpPass); request.UseBinary true; request.UsePassive true; request.KeepAlive false; request.Timeout 30000; request.ReadWriteTimeout 30000; using (FileStream fs new FileStream(localImagePath, FileMode.Open, FileAccess.Read)) { using (Stream rs request.GetRequestStream()) { fs.CopyTo(rs); } } using (FtpWebResponse response (FtpWebResponse)request.GetResponse()) { int statusCode (int)response.StatusCode; if (statusCode 200 statusCode 400) { response.Close(); Log(上传成功: remoteFileName); return true; } else { response.Close(); Log(上传失败, 状态码: statusCode); return false; } } } catch (Exception ex) { Log(上传异常: ex.Message); return false; } } private void EnsureRemoteDir(string dirUri, string user, string pass) { Uri uri new Uri(dirUri); string[] segments uri.AbsolutePath.Split(/); string current ftp:// uri.Host; foreach (string seg in segments) { if (string.IsNullOrEmpty(seg)) { continue; } current / seg; FtpWebRequest mkdirRequest (FtpWebRequest)WebRequest.Create(current); mkdirRequest.Method WebRequestMethods.Ftp.MakeDirectory; mkdirRequest.Credentials new NetworkCredential(user, pass); mkdirRequest.UseBinary true; mkdirRequest.UsePassive true; mkdirRequest.KeepAlive false; try { using (FtpWebResponse response (FtpWebResponse)mkdirRequest.GetResponse()) { response.Close(); } } catch (WebException ex) { // 目录已存在时会返回550按正常情况处理 FtpWebResponse errorResponse ex.Response as FtpWebResponse; if (errorResponse ! null) { int code (int)errorResponse.StatusCode; errorResponse.Close(); if (code ! 550) { throw; } } else { throw; } } } } private void Log(string message) { string logDir D:/VMLog; if (!Directory.Exists(logDir)) { Directory.CreateDirectory(logDir); } string logFile Path.Combine(logDir, DateTime.Now.ToString(yyyyMMdd) .log); File.AppendAllText(logFile, DateTime.Now.ToString(yyyy-MM-dd HH:mm:ss) message Environment.NewLine); } }这段代码里最值得注意的就是EnsureRemoteDir这个方法。FTP协议本身没有“递归创建目录”的命令.NET的FtpWebRequest也没有提供MkdirAll之类的高层封装。所以你必须自己把远程路径按“/”拆开一级一级往上建。每一级目录如果已经存在服务器会返回550代码里要捕获这个异常并忽略否则只要目录已存在整个上传就会中断。4.3 图像路径从哪里来很多新手在配置脚本参数时会卡在“LocalImagePath这个变量到底指向哪里”。实际上在VisionMaster里图像路径一般来自两个地方。一是使用“保存图像”模块。这个模块可以在检测结果图上叠加文字、绘制框线后以指定命名规则保存到本地。它的输出参数里能看到保存路径。把这个输出直接传给脚本模块的输入参数就行。二是自己用“图像源”模块或“格式转换”模块里的保存功能。有些场景只需要保存原始图不需要绘制结果就可以在图像源后接一个“图像保存”模块路径处理逻辑同理。最笨也最可靠的排查方法是先在VM流程里单独跑一帧看一眼保存图像模块实际生成了什么路径确保脚本拿到的路径是真实存在的文件完整路径。我在现场见过太多因为路径少一个反斜杠、多一个空格导致File.Exists判断失败的情况。所以脚本里第一步就做文件存在性检查是非常有必要的。4.4 多张图像和多文件名怎么办如果一次检测会保存多张图比如OK图、NG图、字符识别结果图等通常有两个处理办法。第一个办法是在流程里放多个“保存图像”模块然后用多个参数把这些路径传进同一个脚本模块。脚本里写一个循环依次上传。这个办法适合图像数量固定的场景。第二个办法是把保存模块配置成输出一个路径列表然后把列表作为一个数组参数传给脚本。脚本里遍历这个数组逐个检查、逐个上传。这个办法更灵活但需要你对VM的变量类型比较熟悉而且要处理数组参数的访问写法。无论哪种办法核心都是同一个先拿到完整路径列表再循环调用上传逻辑。上传单张的逻辑封装成独立方法多图只是外面套一层循环。5. 上传可靠性超时、重试、0字节、中文文件名这些坑5.1 0字节文件与写入竞态问题我遇到过最诡异的情况是上传显示成功但服务器上的文件大小是0字节。排查到最后发现是外部程序在文件还没写完整的时候就开始上传了。虽然VM的“保存图像”模块执行完时文件句柄已经关闭但在某些场景下——比如通过脚本自己拼接文件时——文件写入可能和上传逻辑存在竞态。判断0字节文件最简单的办法是上传前检查FileInfo.Length如果文件大小为0跳过或报错。但更稳妥的做法是上传前先判断文件是否可以以独占方式打开。如果可以独占打开说明没有其他进程正在写入该文件如果打开失败说明文件可能还在被占用需要等一会儿再试。在VisionMaster流程内脚本模式下一般不存在这种情况因为“保存图像”模块是同步执行的。但在外部程序轮询模式里这几乎是必踩的坑。解决思路就是上面说的要么延迟几秒再上传要么尝试用FileStream加FileShare.None打开文件打不开就说明文件还没写完。5.2 超时与重试机制FTP上传过程中最怕两件事一是服务器暂时不可达二是网络传输卡死。FtpWebRequest的Timeout属性控制的是获取连接或发送请求的等待时间ReadWriteTimeout控制的是读取或写入数据流的等待时间。这两个值必须都设置否则默认值可能长达几分钟一个卡死的连接能让整个流程停在那里。我的经验值是局域网内部署Timeout设15秒ReadWriteTimeout设30秒跨网段或走4G网络Timeout设30秒ReadWriteTimeout设60秒。一旦上传方法抛出异常或返回false脚本模块可以直接返回false给VM流程。这里要特别说一个设计不要在VisionMaster的全局脚本模块里做大量重试。因为脚本模块是跑在检测流程里的重试N次就会阻塞流程N倍的时间。如果第一次上传失败我建议让流程继续走完把失败信息记录下来。真正的重试机制要么放在VM外部用程序控制要么在后续的定时脚本里检查本地未上传文件再做补传。5.3 中文文件名和编码问题工控机的Windows系统默认中文编码是GBK而大多数Linux系FTP服务器默认使用UTF-8。FileZilla Server新版会优先使用UTF-8处理文件名一般问题不大。但如果你的FTP服务器是Windows IIS的老版本文件名里的中文不经过编码转换上传后可能变成乱码导致服务端存储的文件名完全没法看。我个人的建议是在源头规避这个问题。保存图像时统一用英文、数字、下划线、连字符作为文件名时间戳用yyyyMMdd_HHmmss格式再加流水号。比如NG_20250615_143025_001.jpg这种命名方式在任何系统的FTP环境下都不会出乱码。别在文件名里带中文省掉后面所有编码烦恼。如果你确实需要在文件名里保留中文那么在拼FTP地址前对文件名做一次URI转义用Uri.EscapeDataString处理。但需要提醒一句FtpWebRequest对转义后的路径解析行为在不同.NET环境下有细微差异上线前务必实测。5.4 主动模式被动模式之争在3.3节我已经讲了被动模式端口放行的问题。这里再补充一个容易忽略的细节如果工控机到FTP服务器之间还有路由器做端口映射被动模式下数据连接的端口范围必须映射到公网或指定网段否则客户端会收到服务器返回的内网IP地址导致连接失败。FtpWebRequest里设置主动或被动很简单就是request.UsePassive true或false。但我建议统一用被动模式除非你有明确的网络架构要求。主动模式要求工控机开放高位端口给服务器反向连接在多数工厂网络环境下都是自找麻烦。5.5 本地保留策略与磁盘清理图像上传之后本地文件不能永远堆着。工控机硬盘通常只有几百GB一天几千张图几天就能塞满。所以必须有一个明确的清理策略。清理策略分两种一种是“传完即删”适合服务器稳定性极高的场景另一种是“本地保留N天再自动清理”适合需要本地备份缓冲的场景。在VisionMaster全局脚本里“传完即删”最简单上传方法返回true后直接File.Delete(localImagePath)。如果怕误删可以先把文件移动到本地一个“已上传”目录等几天后再由定时任务清理。这个策略更有弹性我通常推荐用这个。注意不管用哪种策略都要把删除操作放在上传成功确认之后。千万不要先删文件再上传一旦上传失败本地原图就永久丢失了。这是我早期踩过的坑现在想起来还是肉疼。6. 外部二次开发模式下的FTP上传补充方案6.1 独立服务如何监听输出目录如果走路线B外部程序就要承担起监听和上传的全部职责。技术选型上C#的FileSystemWatcher是一个非常常用的方案。下面给出核心思路监控VM保存图像的输出目录。当目录下出现新文件时触发Created事件。在事件处理里先判断文件是否写完用独占打开尝试或文件大小稳定判断。执行FTP上传成功后删除或移动本地文件。如果失败把文件路径写进重试队列等待下一次重试。FileSystemWatcher的Created事件在文件刚创建时就会触发此时文件可能还在写入中。所以你不能在事件里立刻上传必须做延迟或稳定判断。一个更省心的做法是完全不用事件直接用定时轮询每隔2~3秒扫描一次目录然后按文件名前缀或修改时间筛选新文件。轮询写起来更简单也更容易控制并发和重试。6.2 用临时文件名加RENAME保证完整传输这是外部程序里一个非常实用的技巧。当外部程序发现新文件时文件可能还没写完直接上传可能传到半个文件。我们可以换一个思路VM保存图像时先保存为带.tmp后缀的临时文件名保存完成后再重命名为正式文件名。外部程序只监听正式文件名一旦发现正式文件出现说明图像已经完整保存可以放心上传。FTP服务端也支持RENAME命令.NET里对应WebRequestMethods.Ftp.Rename。所以上传过程中也可以分两步先把本地文件以文件名“.part”上传到FTP服务器上传完成后RENAME成正式文件名。这样即使网络传输中途断了服务器上也只会有.part临时文件不会出现一个看似正常、实际内容残缺的“正式图片”。6.3 这个方案适合什么场景我目前维护的视觉项目中凡是单张图像超过10MB、节拍要求高、服务器在异地机房的我都转向了外部程序方案。VisionMaster只负责保存到本地共享目录外部服务负责上传、重试和告警。VM流程里不需要引入任何网络操作稳定性和速度都有了保障。不过外部程序方案也有代价你要额外写一个服务、额外做部署、额外做告警。很多时候老板需要的只是“图像能从工控机到服务器”不是一个完美的架构。所以我的原则是小项目用VM脚本大项目用独立服务看规模办事不为了炫技去增加维护成本。最后再说一个我在多个现场验证过的细节无论走哪条路线一定要在FTP上传逻辑里保留可追溯的日志。日志里不仅要有“上传成功/失败”还要有文件大小、上传耗时、服务器响应状态码。否则出了问题你连是网络问题、文件问题还是FTP账号配置问题都分不清。日志虽然丑但关键时刻能保命。