☰
VisionPro二次开发日志体系搭建:从NLog配置到现场排障实践
2026/9/30 4:38:17 网站建设 项目流程

上个月我们一条产线上的视觉检测程序又出幺蛾子了——过检率从99%掉到92%,操作员只说"跑着跑着就这样了",重启软件、重存图片都不管用。最后定位到原因,靠的不是盯着QuickBuild的调试窗口,而是翻了日志文件,发现相机Trigger超时的几个时间点正好和NG扎堆的时间段完全重合。那次之后我算是想明白一件事:VisionPro二次开发里,Log模块真不是写几个Console.WriteLine就算完事的。

这篇文章就围绕VisionPro二开中日志模块的完整落地过程来聊,覆盖技术选型、配置设计、代码实现、现场踩坑,以及从日志延伸出来的运行统计和远程排障思路。内容会带具体代码和配置,适合正在做VisionPro二次开发、或者准备给视觉项目补一套正规日志体系的读者,你直接照着改就能用。

1. 日志模块在VisionPro二开里的定位,比你想的更重要

1.1 没有日志的视觉项目,排查问题全靠猜

我见过不少用VisionPro做二次开发的团队,前期忙着调算法、跑精度,日志这块基本是能省则省。项目验收的时候,程序里只有几个MessageBox或者Debug.Print,运气好点的会在关键节点写个txt文件。

当设备交付到现场,画风就完全变了。视觉系统是7×24小时跑的,不是你调算法时候那台安静工控机。今天光源衰减导致对比度下降,明天外部信号干扰导致Trigger丢失,后天操作员误改了配方参数,这些统统不是你在办公室能预设到的场景。没有日志,现场工程师能做的只有三件事:重启软件、换产品重测、把图片拷回来问你"到底怎么回事"。

1.2 二开日志和QuickBuild自带调试窗口的边界

很多人问:VisionPro不是自带运行时调试窗口吗?QuickBuild里能看当前帧、能看检测结果、能看ToolBlock的输出,为什么还要自己做日志?

道理很简单:QuickBuild的调试信息是"活在内存里的"。它不会自动持久化,软件一关全没了;它也没有时间轴概念,你无法回答"昨天下午三点这台设备在干什么";它更不能在出现异常时主动记录上下文。工业现场要的不是"当前状态",而是"历史轨迹"。一份能追溯的日志,就是设备的黑匣子。

我的做法是把日志模块当成视觉程序的基础设施来设计,和图像采集、结果输出、IO通信平级对待。这样做的直接收益是:任何一次上门售后,我可以用远程桌面先翻日志,把问题定位到具体时间点和具体模块,再决定是调参数还是改代码。省下的差旅费都能换好几台工控机。

1.3 一套合格日志要覆盖的完整范围

结合这些年做视觉项目的经验,VisionPro二开的日志至少要覆盖四个层面:

  • 系统层:程序启动关闭、配置加载、硬件初始化和释放、异常捕捉
  • 算法层:每次取像、每个ToolBlock的运行、关键算子的输入输出、检测结果和耗时
  • 通信层:和PLC的握手信号、结果下发、TCP/IP通讯报文、设备IO状态
  • 业务层:产品换型、配方切换、操作员动作、统计报表相关数据

这四个层面不是并列关系,而是交叉叠加的关系。比如一个 NG 样本,系统层要记录"哪台设备哪个相机拍的",算法层要记录"哪个ToolBlock哪个算子判的NG,核心特征值多少",通信层要记录"NG结果是否成功下发到PLC",业务层要记录"当时运行的是哪个产品配方"。只有合并起来看,才是一条能回答"为什么NG"的完整证据链。

2. 日志组件的选型和配置设计,我踩过的坑不希望你重踩

2.1 为什么选了NLog而不是log4net

VisionPro二次开发基本都基于.NET Framework或.NET 6/8,可选日志框架很多。常用的就是log4net、NLog、Serilog三家。

log4net最老牌,文档多,但配置写起来繁琐,滚动日志的配置尤为啰嗦。Serilog功能强大,主要卖点是结构化日志,适合配合Kibana这类日志平台使用,但在工控机这种单机、离线、低资源的场景下有点重了。我最终选的是NLog,理由有三:

  • 配置极其简单,一个xml文件搞定所有规则,不用写一行代码就能调整日志级别和输出方式
  • 对.NET Framework的原生兼容性好,老项目不升级服框架也能直接用
  • 支持异步Target,对视觉检测这种对耗时敏感的场景非常关键

当然,log4net也不是不能用,关键是团队要认可并形成规范,别今天log4net明天NLog最后仙上一堆野路子写文件的代码。

2.2 日志级别设计:Debug不是让你随便用的

NLog自带六种级别,从低到高是Trace、Debug、Info、Warn、Error、Fatal。这个分级不是摆设,我见过不少项目所有地方都写logger.Info(),结果日志文件三天就爆满,真正要排查时全是无关紧要的信息。

我推荐按下面的约定分配:

  • Trace:图像像素级调试、算子所有输入输出参数快照,只在开发期打开
  • Debug:ToolBlock内部关键节点值,辅助算法调试
  • Info:正常业务流程,比如取像完成、检测完成、结果已发送
  • Warn:异常但不影响主流程,比如连续N帧触发超时、灰度值略低于阈值
  • Error:功能失效,比如相机掉线、ToolBlock运行报错、PLC通信中断
  • Fatal:程序无法继续运行,比如配置文件损坏、内存耗尽

实际项目中,现场默认开到Info级别,Debug和Trace留作远程排障时动态开启。这个"平时少记、用时能开"的设计原则,能让日志模块既不影响性能又能应对突发。

2.3 关键一步:为VisionPro定制日志字段

单纯记文本内容是不够的,为了让日志能被脚本化分析,我在NLog的布局里加入了一套固定字段:

<target name="file" xsi:type="File" fileName="${basedir}/logs/VisionLog_${shortdate}.log" layout="${longdate}|${level:uppercase=true}|${logger}|${message}${onexception:inner= EXCEPTION:${exception:format=tostring}}"/>

单行日志采用"时间|级别|日志来源|消息内容"的管道符格式。这种格式看起来很简单,但配合固定字段扩展后,每行都能承载更多信息。我在实际项目里会额外追加这几个字段:

<layout> ${longdate}|${level:uppercase=true}|${logger}|${event-properties:item=jobName}|${event-properties:item=toolBlockName}|${event-properties:item=elapsedMs}|${message} </layout>

然后在代码中用结构化日志方式写入:

logger.Info("DetectFinished|Result={0}|Score={1:0.000}", result.Pass ? "OK" : "NG", result.Score);

这样做的最大好处是:日志不再是给人读的散文,而是一行行带格式的数据。现场排查时可以用Excel直接筛选,"找某个ToolBlock在某个时间段所有NG记录",一筛就出来,效率比肉眼扫屏高了一个量级。

3. 代码落地:把VisionPro的核心对象整个纳入日志体系

3.1 初始化与全局异常捕获

日志组件要在程序入口第一时间初始化,这样后续所有代码都有日志可用。我在Program.cs的Main方法里做三件事:加载NLog配置、注册全局异常处理、记录启动消息。

static void Main(string[] args) { // 读取NLog配置文件(exe同级目录下 NLog.config) var logger = NLog.LogManager.GetCurrentClassLogger(); AppDomain.CurrentDomain.UnhandledException += (s, e) => { logger.Fatal(e.ExceptionObject as Exception, "UnhandledException"); }; Application.ThreadException += (s, e) => { logger.Error(e.Exception, "ThreadException"); }; logger.Info("=== 程序启动 版本:{0} ===", Application.ProductVersion); // 后续启动流程 }

很多二开项目死在"日志模块本身静默失败"这个坑里。NLog配置文件路径写错、目录权限不足,程序照跑,但日志就是写不进文件。所以我习惯在初始化后立刻做一次自检:

logger.Info("LogSelfCheck"); if (!File.Exists(logPath)) { // 弹窗警告并记录到事件查看器 MessageBox.Show("日志文件创建失败,请检查日志目录权限"); }

这个自检动作建议保留,工控机上重装系统后最常出问题的就是目录权限,用管理员权限装的软件和服务权限不一致,最容易出现"日志静默消失"。

3.2 把CogToolBlock的每次运行都变成一条结构化日志

VisionPro二次开发中,核心算法载体就是CogToolBlock。无论是走JobManager调度还是单ToolBlock跑流程,都要在运行前后打点计时。

我的封装思路是给ToolBlock加一个扩展方法,或者做一个Runner类统一包装:

public class ToolBlockRunner { private readonly Logger _logger = LogManager.GetLogger("ToolBlock"); public CogToolBlockResultMessage Run(CogToolBlock toolBlock, string jobName) { var sw = Stopwatch.StartNew(); _logger.Info("ToolBlockStart|Job={0}|ToolBlock={1}", jobName, toolBlock.Name); try { toolBlock.Run(null); sw.Stop(); var result = new CogToolBlockResultMessage { JobName = jobName, ToolBlockName = toolBlock.Name, ElapsedMs = sw.ElapsedMilliseconds, Pass = (bool)toolBlock.Outputs["Passed"].Value, Score = GetOutputValue(toolBlock, "Score") }; _logger.Info("ToolBlockEnd|Job={0}|ToolBlock={1}|Elapsed={2}ms|Result={3}|Score={4}", jobName, toolBlock.Name, sw.ElapsedMilliseconds, result.Pass ? "OK" : "NG", result.Score); return result; } catch (Exception ex) { sw.Stop(); _logger.Error(ex, "ToolBlockException|Job={0}|ToolBlock={1}|Elapsed={2}ms", jobName, toolBlock.Name, sw.ElapsedMilliseconds); throw; } } }

这一段代码看起来简单,但里面有几个细节值得说明:

  • 记录ToolBlock开始时间不只是一个计时器,更关键的是能和你软件里其他模块日志做时间对齐。比如,PLC说"我Trigger发出去了",你的日志能证明"那一刻相机确实没取到图",责任边界立刻清楚了。
  • 结果值不要只记Pass/NG,要记录Score或者其他关键判定值,只有Pass/NG无法发现"虽然判OK但分数很接近阈值"这种边际情况。
  • 异常必须带上下文一起记,光记"ToolBlock异常"是没有意义的,要把哪个Job、哪个ToolBlock、跑了多久都记进去。

3.3 图像采集与保存:日志索引图片管理

视觉项目里"保存NG图片"是标配,但是图片文件多了之后很难管理。我的做法是把图片文件名做成日志的一部分,让日志成为图片的索引。

string imageName = $"{DateTime.Now:yyyyMMdd_HHmmss_fff}_{jobName}_{result.Pass ? "OK" : "NG"}.bmp"; cogImage.Save(imagePath, CogImageFileModeConstants.Write); _logger.Info("ImageSaved|File={0}|Size={1}KB", imageName, new FileInfo(imagePath).Length / 1024);

这样处理之后,你翻日志发现某条NG记录,可以立刻根据文件名找到对应的原始图像,用VisionPro或者第三方看图工具重新分析。很多疑难杂症就是这么定位的——算法看的是实时流,你事后能复现的只有静态图,日志里的文件名就是通往现场的钥匙。

3.4 业务状态迁移与人工操作日志

二开的程序通常不是一直在检测,它有运行、暂停、待机、调试等各种状态。每次状态切换都应该记录,而且要多记一条"是谁触发的"。我看很多人只在代码里埋日志,操作员或者维护人员在界面上干了什么都没记,出了问题互相扯皮。

界面上所有按钮点击、参数修改、配方切换,都集中打日志:

private void btnStart_Click(object sender, EventArgs e) { _logger.Info("UserAction|Operator={0}|Action=StartClicked", CurrentOperatorName); // 启动检测流程 } private void cmbRecipe_SelectedIndexChanged(object sender, EventArgs e) { _logger.Warn("UserAction|Operator={0}|Action=RecipeChanged|From={1}|To={2}", CurrentOperatorName, _oldRecipe, cmbRecipe.Text); }

配方切换这种直接影响检测结果的动作,级别建议提到Warn,因为它是质量追溯里最高频的嫌疑点。我记得有次客户投诉说某批产品漏检,最后查到是当班操作员误切换了配方,型号A的检测标准被用到了型号B上。日志里的配方切换记录成了划分责任的关键依据。

4. 现场运行中的典型日志故障:不只是"写不进去"

4.1 线程安全:UI线程、相机线程、通信线程都在写日志

VisionPro二开程序往往是多线程模型:UI线程响应操作,采集线程回调图像,通信线程和PLC交互,算法线程执行ToolBlock。这种情况下,如果日志组件不是线程安全的,轻则丢日志,重则直接抛异常。

NLog本身是线程安全的,多个线程同时写同一个Target不会崩。但你要小心自己封装Logger的方式。我见过有同事图省事写了个静态工具类,里面用StreamWriter直接写文件:

// 非常不推荐的写法 public static void Log(string msg) { File.AppendAllText(_path, msg + Environment.NewLine); }

这段代码在单线程下没毛病,多线程时File.AppendAllText内部虽然带了锁,但频繁开关文件流性能极差。在视觉检测这种并行场景,极可能导致UI卡顿或日志丢失。正确做法永远是让NLog这类框架统一管,你只管调用logger.Info()。

4.2 日志文件无限膨胀:滚动策略与归档

没有滚动策略的日志系统就是定时炸弹。默认配置下,NLog写一个月能生成几个GB文件,到时磁盘满了程序直接崩溃。

我用的滚动配置:

<target name="file" xsi:type="File" fileName="${basedir}/logs/vision_${shortdate}.log" archiveAboveSize="10485760" maxArchiveFiles="30" archiveNumbering="Sequence" concurrentWrites="true"/>

解释一下:单文件超过10MB就归档,最多保留30份。这样算下来一个日志文件最多10MB,保留30天左右,占磁盘300MB以内,对工控机完全无压力。

归档策略之外还要注意文件名的时间粒度。我的习惯是按天一个主文件,10MB滚动归档,但日志文件名带日期。不要用单一固定文件名,不然滚动机制一旦失效,整个项目只有孤零零的大文件,想按天分析很难。

4.3 最高频的坑:日志拖慢检测节拍

视觉项目对耗时极度敏感,节拍是硬约束。高频率的I/O操作会让检测周期变长。NLog的异步Target就是为这个场景设计的。

<target name="asyncFile" xsi:type="AsyncWrapper" queueLimit="5000" overflowAction="Discard"> <target name="file" xsi:type="File" .../> </target>

开启AsyncWrapper后,日志消息先入队列,后台线程负责写入文件,业务线程不会被磁盘I/O阻塞。需要说明的是queueLimit和overflowAction的设计:队列上限5000条,超出后丢弃新日志。这是故意的取舍——日志可以丢几条,检测节拍不能丢。开发期你可以把这个值调大或者关掉异步,现场必须开异步。

我还踩过一个小坑:在ToolBlock运行回调里直接调用logger.Debug()记录大量图像特征值。Debug级别虽然只消耗极少量时间,但每个特征值都要格式化字符串,GC压力上来了。后来我把高频率日志全部改成延迟格式化调用:

logger.Debug(() => $"FeatureX={value1:0.000}, FeatureY={value2:0.000}");

这样只有在Debug级别真正启用时才会执行字符串拼接,从根上避免格式化开销。

4.4 相机或SDK产生的底层异常别一股脑打进日志

VisionPro调用相机SDK时经常冒出一些"看起来吓人但无伤大雅"的异常,比如超时重试、短暂丢帧。如果不做过滤,Error日志会淹没真正需要人工介入的故障。

我设计了一个"异常分级过滤"逻辑:底层通信类异常先捕获,判断是否是重试型异常,是则记录为Warn,连续失败超过N次才转成Error。这样日志里的Error永远表示"需要有人来看",Warn表示"暂时还能跑但要留意"。这是日志体系是否能被现场信任的关键。如果Error满天飞,现场人员会习惯性忽略,真的来了致命错误也没人在意。

4.5 配置文件损坏时的兜底

还有一种极端情况,NLog.config本身被误改导致解析失败,程序启动后所有logger都静默失效。我在初始化时加上try-catch,捕获到配置文件异常后,退回写Windows事件日志:

try { LogManager.LoadConfiguration("NLog.config"); } catch (Exception ex) { // 写事件日志 EventLog.WriteEntry("VisionApp", "日志配置加载失败:" + ex.Message, EventLogEntryType.Warning); // 启用内置默认配置 var config = new NLog.Config.LoggingConfiguration(); var target = new NLog.Targets.FileTarget("file") { FileName = "${basedir}/logs/fallback.log" }; config.AddRule(NLog.LogLevel.Debug, NLog.LogLevel.Fatal, target); LogManager.Configuration = config; }

这套兜底方案不算复杂,但在现场救过我好几次。二开项目最容易出的问题就是配置文件在实施时被工程师顺手改了,导致所有日志一夜之间消失。有兜底至少能留个现场证据。

5. 把日志用起来:运行统计、远程排障和持续改进

5.1 基于日志的分钟级运行统计

日志积累下来不只是排障用的,它还是产线效率分析的原材料。我给NLog加了一个自定义Target,日志消息实时转发给内存中的统计模块,每60秒输出一次汇总记录。

public class MetricsTarget : TargetWithLayout { private readonly ConcurrentDictionary<string, int> _counter = new(); protected override void Write(LogEventInfo logEvent) { var key = logEvent.Message; _counter.AddOrUpdate(key, 1, (_, v) => v + 1); // 每60秒发布一次统计快照 } }

统计的关键指标包括:每小时检测总数、OK/NG数量、平均检测耗时、ToolBlock最大耗时、通信超时次数。这些数据多数客户都会要求写进日报表,与其额外做埋点,不如直接从日志里聚合。日志系统和统计系统共用一条数据链路,省心也不会出现两边数据对不上的问题。

5.2 远程排障:动态调整日志级别而不重启程序

现场问题最怕的是"问题不能复现",而更怕的是"复现的时候日志等级不够,信息没记下来"。为此我在程序里做了一个运行时日志级别控制器:通过一个配置文件或者命名管道接收指令,动态修改NLog的规则。

public void SetLogLevel(string loggerName, LogLevel level) { var rule = LogManager.Configuration.LoggingRules .FirstOrDefault(r => r.LoggerNamePattern == loggerName); if (rule != null) { rule.SetLogLevel(level); LogManager.ReconfigExistingLoggers(); } }

调试阶段把级别调到Trace跑上几分钟,拿到足够信息后立刻降回Info,全程不需要停设备。这招在产线调试窗口期极短的时候尤其管用。你没法跟设备要"停机两小时让我复现问题",但你可以等它自然报错,只要日志管够就行。

5.3 日志驱动的二开质量改进

日志模块跑顺之后,我习惯每隔一段时间翻一遍Error和Warn记录,做一次"日志审查"。重点是回答三个问题:哪些错误重复出现?哪些流程耗时异常?哪些模块依赖关系不合理?

比如连续看到"ToolBlockA耗时超过300ms"多次出现,就可能意味着这个算子的匹配区域设置过大,或者训练样本不好,跑一个瓶颈分析就出来了。通过日志审查定位到问题,比凭感觉优化算法靠谱得多。

5.4 最后补充几个我常年保留的习惯

  • 日志里的时间统一用本地时间,但涉及跨天班次切换的项目,建议同时记录UTC偏移量,避免换班后时间混淆。
  • 涉及图像相关的日志,路径中不能有中文和空格,否则部分第三方软件打不开。
  • 定期手动检查一次日志文件能否正常打开、内容是否完整,而不是等到出问题才翻。
  • 程序版本号一定要写进日志启动行,我见过多个版本程序在同一台设备上切换,没版本号根本分不清某条日志是哪个版本打的。

根据我的经验,日志模块做得好的项目,售后成本能降低一半以上。它不直接产生检测价值,但它能让每一次故障都变成可追溯、可复盘、可改进的资产。如果你准备给VisionPro二开项目补日志体系,按照文章里的分层字段、分级策略和踩坑应对来做,基本能跑得稳。核心就一句话:日志不是用来"看"的,是用来"查"的——让未来的你能在几分钟内回答"当时到底发生了什么"。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询