
簡介面向機(jī)器視覺開發(fā)者的通用圖像處理工具源碼基于Halcon和C# Winform實(shí)現(xiàn)交互方式仿照VisionPro的拖拽設(shè)計(jì)用戶通過拖拽不同功能模塊即可構(gòu)建流程模塊間自動完成參數(shù)與結(jié)果傳遞使JOB運(yùn)行走向直觀可控。工具采用插件體系每個圖像處理功能獨(dú)立封裝支持動態(tài)加載易于擴(kuò)展與二次開發(fā)適用于檢測、測量、識別等視覺場景。資源包共715個文件約21.94MB主要包含C#源代碼、資源文件、類庫與界面圖標(biāo)其中cs文件398個、resx文件114個、dll文件34個、png圖片70個并附有可執(zhí)行程序與工程配置方便編譯與學(xué)習(xí)。目前已有237人學(xué)習(xí)下載。通過該源碼讀者可以掌握插件式圖像處理框架的設(shè)計(jì)方法了解Halcon與Winform的集成技巧、工具間數(shù)據(jù)流的銜接方式以及JOB流程管理思路也可基于現(xiàn)有模塊快速擴(kuò)展新功能縮短項(xiàng)目開發(fā)周期。1. 一套仿VisionPro的WinForm通用圖像工具把Halcon引擎裝進(jìn)可拖拉的流程里做機(jī)器視覺上位機(jī)的人大概率都摸過VisionPro它那種把圖像源、定位、測量、通信拖到流程框里、用連線把工具的輸出喂給下一個工具的設(shè)計(jì)確實(shí)比在代碼里一個個調(diào)Halcon算子要直觀得多。這套資源做的就是同一件事基于C# WinForm把Halcon的算子封裝成一個個獨(dú)立工具工具之間仿照VisionPro的ToolBlock做值傳遞整套流程按JOB方式組織運(yùn)行。說白了就是用Halcon代替VisionPro的底層算法把它的交互體驗(yàn)搬到自家上位機(jī)上。適合正在做視覺檢測項(xiàng)目、被Halcon腳本維護(hù)逼瘋、想在WinForm里搭一套可復(fù)用流程框架的C#工程師。底層引擎還是Halcon能干多少活全看你對算子的熟悉程度這一點(diǎn)先擺清楚。2. 總體架構(gòu)宿主、插件契約、值傳遞與JOB運(yùn)行器怎么拆拿到這套工程壓縮包第一步別急著編譯先把它的分層邏輯看明白。這類工具做得好不好不取決于單個算子用得多花哨而是看宿主程序能不能讓工具之間干凈地協(xié)作。下面把這套資源里最常見的一版架構(gòu)拆開講這也是我認(rèn)為它最值得參考的部分。2.1 先分三層宿主界面層、插件契約層、運(yùn)行管理層宿主界面層就是WinForm主窗體負(fù)責(zé)擺放工具列表、畫布區(qū)域、屬性面板和JOB運(yùn)行按鈕。它不直接引用任何具體工具的代碼只依賴一個定義好的接口。插件契約層是關(guān)鍵所有工具采集、預(yù)處理、找圓、模板匹配、測量、OCR之類都實(shí)現(xiàn)同一個接口這樣宿主程序才能在不修改主程序的前提下把新工具dll丟進(jìn)插件目錄就完成加載。運(yùn)行管理層是JOB運(yùn)行器它持有當(dāng)前流程的工具鏈和連線關(guān)系按順序把前一個工具的輸出值寫入后一個工具的輸入。這套資源里應(yīng)該能看到類似的目錄結(jié)構(gòu)主程序工程、工具接口工程、若干獨(dú)立工具工程。工具工程之間互相不認(rèn)識只認(rèn)得契約工程里定義的接口。這種拆法和VisionPro的ToolBlock里工具是插件的思路是一致的好處是以后加新算法不需要動主程序壞處是接口定義得夠不夠穩(wěn)直接決定了后期的開發(fā)體驗(yàn)。我在自己項(xiàng)目里一般把這個契約工程單獨(dú)編譯成一個dll版本號管得很嚴(yán)因?yàn)樗泄ぞ叨家蕾囁坏┙涌诤灻儎诱麄€插件目錄要重新編譯一遍。2.2 值傳遞的核心先定數(shù)據(jù)結(jié)構(gòu)再定接口VisionPro里每個工具都有輸入和輸出工具A的輸出連到工具B的輸入靠的是一個通用數(shù)據(jù)類型。Halcon世界里這個通用類型天然就是HTuple它既能裝整數(shù)、浮點(diǎn)、字符串也能裝圖像句柄之外的大多數(shù)標(biāo)量數(shù)據(jù)。工具接口里會定義輸入集合和輸出集合集合里每一項(xiàng)有名字、類型、當(dāng)前值運(yùn)行時按名字從上游工具的輸出集合里取值塞進(jìn)下游工具的輸入集合。這套資源里有一個細(xì)節(jié)值得關(guān)注就是工具間的值傳遞不是靠強(qiáng)類型對象而是用類似鍵值對的方式。我拆過不少類似工程這里用鍵值對是務(wù)實(shí)的選擇因?yàn)楣ぞ咛?、類型各異?qiáng)類型依賴會把插件體系搞死。HTuple本身是弱類型的傳圖像之外的參數(shù)特別方便但如果傳的是HObject圖像就得另開一條通道比如在值傳遞包里包一層對象引用。2.3 JOB運(yùn)行器的走向按連線順序驅(qū)動工具鏈JOB在這類框架里就是一條完整的檢測流程。運(yùn)行器拿到當(dāng)前JOB的工具鏈表和連線表從起始工具開始逐個執(zhí)行執(zhí)行完一個就查它的輸出連接到哪些工具把值推過去再執(zhí)行下游。這種執(zhí)行模型對應(yīng)VisionPro里的Job運(yùn)行邏輯也對應(yīng)Halcon里HDevelop腳本里順序調(diào)用算子的過程只不過把腳本文本變成了圖形化連線和參數(shù)面板。運(yùn)行時要做三件事解析連線表、組裝配料、按順序執(zhí)行。連線表在內(nèi)存里通常就是一個列表每個條目記錄上游工具ID、上游輸出名、下游工具ID、下游輸入名。這部分看清了后面寫JOB運(yùn)行器就不會跑偏。3. 核心實(shí)現(xiàn)從ITool接口到插件動態(tài)加載再到流程運(yùn)行的落地代碼3.1 先定ITool契約輸入、輸出、執(zhí)行三件事工具接口是整套體系的基石。按VisionPro的思路一個工具必須能回答三個問題需要什么輸入、產(chǎn)出什么輸出、執(zhí)行時干些什么。工程里對應(yīng)的接口大概長這樣public interface ITool { string Name { get; set; } string ToolType { get; } ListParamItem Inputs { get; } ListParamItem Outputs { get; } bool Execute(JobContext context, out string errorMsg); object GetConfig(); void LoadConfig(string json); ToolStatus Status { get; set; } } public class ParamItem { public string Name { get; set; } public Type DataType { get; set; } public object Value { get; set; } }Execute里的JobContext是運(yùn)行時上下文它至少應(yīng)該攜帶當(dāng)前幀的HObject圖像、當(dāng)前工具鏈的執(zhí)行日志、以及一個全局對象池用來臨時存放圖像句柄這些不適合塞進(jìn)HTuple的東西。我在自己的工程里還會往JobContext里塞一個CancellationToken配合Halcon算子的SetCheck機(jī)制流程冗長時能及時中斷執(zhí)行。參數(shù)說明Inputs和Outputs是運(yùn)行時值傳遞的懸掛點(diǎn)界面上的拖拉連線本質(zhì)上就是在編輯這兩個列表中的條目引用。GetConfig和LoadConfig負(fù)責(zé)把工具的參數(shù)序列化成JSON這是后面做JOB保存和回放的基礎(chǔ)建議你在自己的接口設(shè)計(jì)里也預(yù)留這兩個方法否則后面做項(xiàng)目保存功能時屁股要擦很久。3.2 插件掃描與動態(tài)加載Assembly.LoadFrom的正確姿勢工具都實(shí)現(xiàn)ITool宿主程序怎么發(fā)現(xiàn)它們常見做法是啟動時掃描指定插件目錄用Assembly.LoadFrom加載所有dll再通過反射找出ITool的實(shí)現(xiàn)類并實(shí)例化。下面是這套資源里插件掃描的典型實(shí)現(xiàn)public ListITool LoadToolsFromDirectory(string pluginDir) { var tools new ListITool(); var files Directory.GetFiles(pluginDir, *.dll); foreach (var file in files) { var assembly Assembly.LoadFrom(file); var types assembly.GetTypes() .Where(t typeof(ITool).IsAssignableFrom(t) !t.IsAbstract); foreach (var type in types) { var instance Activator.CreateInstance(type) as ITool; if (instance ! null) { tools.Add(instance); // 工具自帶描述特性用來在UI列表里顯示友好名稱 var attr type.GetCustomAttributeToolDescriptionAttribute(); instance.ToolType attr?.DisplayName ?? type.Name; } } } return tools; }拿到實(shí)例之后界面上的工具箱就把它們列出來用戶拖一個到畫布就復(fù)制出一個工具實(shí)例加入當(dāng)前JOB。這里有兩個參數(shù)值得注意插件目錄我建議放成獨(dú)立文件夾不要和exe挨在一起因?yàn)檫\(yùn)行時更新dll時文件占用會帶來麻煩LoadFrom之后的dll會被鎖定這個問題在避坑清單里會展開講。邏輯說明GetTypes()會觸發(fā)該程序集所有類型的加載如果某個工具dll里混進(jìn)了依賴Halcon運(yùn)行時但其實(shí)沒被正確引用的類型這里會拋ReflectionTypeLoadException。項(xiàng)目初期最容易翻車的點(diǎn)在這常見做法是捕獲異常后把未加載的類型列表打印出來而不是放掉整個掃描過程。3.3 JOB運(yùn)行器按連線順序驅(qū)動工具鏈JOB運(yùn)行器是整個框架的發(fā)動機(jī)。VisionPro里按連線走向執(zhí)行工具這里用一張鄰接表跑BFS式順序執(zhí)行但視覺工具的JOB通常不是并發(fā)而是嚴(yán)格的先后依賴所以我一般實(shí)現(xiàn)成帶循環(huán)檢測的拓?fù)渑判驁?zhí)行。核心代碼框架public void RunJob(Job job, HObject frame, JobContext ctx) { // 先做拓?fù)渑判虼_認(rèn)沒有環(huán)形連線 var order TopoSort(job.Tools, job.Connections); ctx.Image frame; ctx.Log.Clear(); foreach (var toolId in order) { var tool job.ToolMap[toolId]; // 把上游傳下來的值灌進(jìn)工具輸入 foreach (var input in tool.Inputs) { if (ctx.RemoteValues.ContainsKey(input.Name)) { input.Value ctx.RemoteValues[input.Name]; } } // 執(zhí)行工具 string err; tool.Execute(ctx, out err); ctx.Log.Add(${tool.Name}: {err}); // 把輸出寫入上下文供下游工具拾取 foreach (var output in tool.Outputs) { if (ctx.RemoteValues.ContainsKey(output.Name)) ctx.RemoteValues[output.Name] output.Value; } } }參數(shù)說明ctx.RemoteValues是全局值表工具A輸出了一個叫CenterX的HTuple寫入這張表工具B的輸入里也聲明了CenterX運(yùn)行器就用這個名字去匹配。這里要特別注意命名沖突兩個工具輸出同名值時后者會靜默覆蓋前者踩過坑的都知道這問題在聯(lián)調(diào)時有多難查。邏輯說明TopoSort是這套代碼里最值得優(yōu)先讀的方法工具數(shù)量超過10個之后連線會復(fù)雜到肉眼看不出來此時排序結(jié)果里若出現(xiàn)環(huán)運(yùn)行器應(yīng)當(dāng)直接報錯而不是死循環(huán)。我習(xí)慣在排序失敗時把環(huán)的路徑拼成字符串打到日志里這個細(xì)節(jié)比工具本身更救命。4. 值傳遞與拖拉交互把ToolBlock的連線體驗(yàn)搬進(jìn)WinForm4.1 輸入輸出怎么映射HTuple與C#類型互轉(zhuǎn)VisionPro里連線傳輸?shù)氖欠盒蛿?shù)據(jù)Halcon這邊的HTuple天生就是干這個的。但團(tuán)隊(duì)里新來的同事經(jīng)常直接把HTuple到處傳導(dǎo)致工具內(nèi)部全是if (hTuple.Type HTupleType.DOUBLE)這種分支工具多了以后代碼臭得沒法聞。這套資源里做了專門的值轉(zhuǎn)換層把HTuple轉(zhuǎn)成C#強(qiáng)類型再交給算子使用這一步我認(rèn)為是整個工程最清醒的設(shè)計(jì)之一。public static double GetDouble(HTuple h, double defaultValue 0.0) { if (h null || h.Length 0) return defaultValue; try { return h.D; } catch { return defaultValue; } } public static Listdouble GetDoubleArray(HTuple h) { var list new Listdouble(); if (h null) return list; for (int i 0; i h.Length; i) list.Add(h.DArr[i]); return list; }邏輯說明Halcon的算子返回值經(jīng)常是數(shù)組形式比如FindCircle返回的一組圓心的行列坐標(biāo)必須用h.DArr取完整數(shù)組不能只拿第一個。我在看這套資源的源碼時特別注意了它對空HTuple的處理默認(rèn)值兜底寫得很全這直接提升了工具在缺參場景下的健壯度。4.2 拖拉連線的數(shù)據(jù)流UI層如何記錄連接關(guān)系WinForm原生控件里沒有現(xiàn)成的連線畫布所以工程里大概率用自定義控件或第三方圖形控件來實(shí)現(xiàn)拖拉交互。核心不在于畫線而在于每次拖拽完成時要記錄一條連接記錄存到Connection對象里public class ToolConnection { public string OutputToolId { get; set; } public string OutputParamName { get; set; } public string InputToolId { get; set; } public string InputParamName { get; set; } }這套資源的UI交互方式我的理解是工具箱在左側(cè)中間畫布擺工具圖標(biāo)右側(cè)屬性面板顯示當(dāng)前選中工具的輸入輸出參數(shù)網(wǎng)格。用戶從A工具的輸出行拖動到B工具的輸入行界面畫一條線同時生成上面的ToolConnection記錄。落庫后運(yùn)行器就用它做值路由。這也是建議新手先讀的部分因?yàn)樗裊I事件和數(shù)據(jù)流解耦了。如果你正在自己寫類似的WinForm視覺框架這個對象模型可以直接抄比在控件Paint事件里畫線然后順手改數(shù)據(jù)要干凈得多。4.3 值傳遞的運(yùn)行時機(jī)制解析連線、填充輸入、收集輸出有了ToolConnection列表運(yùn)行時值傳遞的實(shí)現(xiàn)就水到渠成。執(zhí)行到工具A之前先掃描所有連線找出OutputToolId等于當(dāng)前工具ID的記錄從工具的輸出集合里取出對應(yīng)參數(shù)值寫入全局值表。執(zhí)行完工具A后再掃描連線把輸出值推送到目標(biāo)工具的輸入集合。private void ResolveConnections(ToolConnection conn, ITool source, ITool target) { var srcOut source.Outputs.FirstOrDefault(o o.Name conn.OutputParamName); if (srcOut null) { LogError($輸出參數(shù)不存在: {conn.OutputParamName}); return; } var tgtIn target.Inputs.FirstOrDefault(i i.Name conn.InputParamName); if (tgtIn null) { LogError($輸入?yún)?shù)不存在: {conn.InputParamName}); return; } tgtIn.Value srcOut.Value; }參數(shù)說明這里有個坑源工具的輸出類型和下游工具的輸入類型要保持兼容。比如上游輸出的是HTuple整數(shù)下游聲明的是double賦值時HTuple的隱式轉(zhuǎn)換沒問題但如果下游聲明的是HObject圖像賦值就會直接拋異常。所以工具接口里ParamItem.DataType字段在UI上要做類型顏色區(qū)分我一般給圖像類參數(shù)標(biāo)藍(lán)色、數(shù)值類標(biāo)綠色操作者連錯線時能一眼看出來。邏輯說明ResolveConnections的調(diào)用時機(jī)是執(zhí)行每個工具前且只解析直接相連的上游連線不跨層傳遞。VisionPro里ToolBlock也是這個邏輯工具B的輸入不會自動拿到工具A的輸入值除非工具A把它作為輸出暴露出來。這一點(diǎn)跟新同事講流程走向時經(jīng)常被誤解建議你在自己的工程里注釋里寫清楚。5. 避坑清單Halcon引擎、插件加載與WinForm交互的六條血淚經(jīng)驗(yàn)5.1 現(xiàn)象插件掃描時拋ReflectionTypeLoadException所有工具都加載不出來原因插件dll依賴了Halcon的halcondotnet.dll但該dll沒有復(fù)制到插件目錄Assembly.LoadFrom只會把待加載程序集所在目錄作為探測路徑依賴解析失敗。解決把Halcon運(yùn)行相關(guān)的dll統(tǒng)一放到插件根目錄或者宿主程序啟動時提前用Assembly.LoadFrom預(yù)加載halcondotnet.dll。我做這類框架時會在插件目錄下建一個runtime子目錄把Halcon runtime、第三方依賴全放進(jìn)去再用AppDomain.CurrentDomain.AssemblyResolve事件兜底手動按名解析這樣插件更新時不會把主程序拖下水。5.2 現(xiàn)象程序運(yùn)行中替換插件dll提示文件被占用原因Assembly.LoadFrom加載的程序集默認(rèn)不卸載dll文件被進(jìn)程鎖定。在開發(fā)環(huán)境里每次編譯插件前都要先關(guān)主程序聯(lián)調(diào)效率極低。解決把LoadFrom改成Assembly.Load(File.ReadAllBytes(file))可以繞開文件鎖定但代價是程序集的依賴上下文脫離后續(xù)更新不愛生效。更實(shí)用的方案是把插件加載放進(jìn)獨(dú)立AppDomain整個JOB的加載和卸載都在子域里做。如果項(xiàng)目剛起步建議先接受換插件必須重啟程序的約束優(yōu)先用文件復(fù)制方式隔離插件版本等到插件數(shù)量超過10個再折騰獨(dú)立AppDomain不遲。5.3 現(xiàn)象跑的挺好但時不時閃退看日志發(fā)現(xiàn)是跨線程更新界面原因Halcon算子通常放在后臺線程執(zhí)行執(zhí)行結(jié)束后要把圖像和結(jié)果顯示在WinForm控件上。但HWindowControl或其他自定義繪圖控件的句柄只能在UI線程操作跨線程直接調(diào)用會拋異?;螂S機(jī)崩潰。解決執(zhí)行完成后用BeginInvoke把結(jié)果顯示邏輯切回UI線程。注意Halcon的HWindowControl在后臺線程里可以調(diào)用算子和HImage.SetWindowAttr但凡是綁定到控件的操作必須在UI線程把控件句柄當(dāng)作普通資源處理就會翻車。我在工具執(zhí)行耗時統(tǒng)計(jì)里專門加了一項(xiàng)UI刷新耗時避免把界面卡頓誤判成算法慢。5.4 現(xiàn)象工具輸出的HTuple到下游變成空值定位半天找不到原因原因工具輸出的HTuple是引用類型某些算子失敗時會把輸出HTuple清空但工具本身沒有返回錯誤狀態(tài)運(yùn)行器照樣把空值寫進(jìn)了連線。下游工具拿到空值后算子直接異常。解決在Execute里強(qiáng)制約定算子失敗時必須返回false并攜帶錯誤信息運(yùn)行器檢測到任一工具執(zhí)行失敗就終止整條JOB而不是繼續(xù)往下跑。這樣做的代價是調(diào)試時需要手動單步跳過失敗工具但這個決策能避免一大半跑著跑著結(jié)果不對的玄學(xué)問題。5.5 現(xiàn)象JOB里放了圖像采集工具但運(yùn)行時內(nèi)存持續(xù)上漲最后程序被殺掉原因Halcon的HObject是句柄式資源垃圾回收器不會立即釋放底層內(nèi)存。反復(fù)采集圖像不釋放舊圖像句柄內(nèi)存呈階梯式上漲這就是經(jīng)典的內(nèi)存泄漏。解決工具執(zhí)行完凡是輸出圖像到連線的上游工具要把圖像句柄登記到JobContext的ObjectPool里JOB結(jié)束后統(tǒng)一dispose。HObject實(shí)現(xiàn)了IDisposable但為了在插件場景下不跨dll卡Release我最終改成在JobContext里集中管理所有圖像生命周期比在每個工具里寫using穩(wěn)妥得多。5.6 現(xiàn)象換了一臺機(jī)器運(yùn)行報許可證錯誤Halcon窗口彈出來又消失原因Halcon的運(yùn)行時許可證是按機(jī)器綁定的。開發(fā)機(jī)上能用不代表部署機(jī)能用大坑之一就是HALCONROOT和HALCONIMAGES環(huán)境變量沒配齊或者裝了64位程序但誤用32位license文件。解決部署時先手動運(yùn)行一下Halcon自帶的檢查工具h(yuǎn)develop或license相關(guān)腳本確認(rèn)當(dāng)前機(jī)器能正常打開HDevelop再跑主程序。然后把HALCONROOT、HALCONARCH寫進(jìn)程序啟動配置里而不是依賴系統(tǒng)環(huán)境變量。我在這上面吃過虧后來在自己的框架里加了一個啟動自檢界面專門列出Halcon版本、license路徑、dll加載是否成功聯(lián)調(diào)時非常頂用。6. 把JOB保存成快照序列化工具鏈配置到JSON的一個落地習(xí)慣工具鏈的價值不止在于跑通一次更在于能把一條調(diào)試好的流程存檔下次換工件、換相機(jī)時只需改參數(shù)不重搭流程。這套資源里如果看了ITool里的GetConfig和LoadConfig你會發(fā)現(xiàn)幾乎所有工具參數(shù)都是可序列化的我在此基礎(chǔ)上補(bǔ)了一個JobSerializer用來把整套JOB配置落地成JSON。public class JobSnapshot { public string JobName { get; set; } public ListToolNode Tools { get; set; } public ListToolConnection Connections { get; set; } public DateTime SavedTime { get; set; } } public class ToolNode { public string Id { get; set; } public string ToolType { get; set; } public string ConfigJson { get; set; } }保存時遍歷JOB里的工具逐個調(diào)GetConfig()拿參數(shù)JSON連同連線表一起塞進(jìn)JobSnapshot。加載時先按ToolType去插件池找類型創(chuàng)建實(shí)例再調(diào)LoadConfig()把參數(shù)灌回去最后重建連線關(guān)系。這樣整條流程從能跑變成可復(fù)現(xiàn)換線換產(chǎn)品時不用重新調(diào)一遍所有閾值這是做視覺框架后期幸福感提升最明顯的功能。斷點(diǎn)調(diào)試也是這套快照機(jī)制的直接受益者。我給運(yùn)行器加了一個調(diào)試模式每執(zhí)行完一個工具自動把當(dāng)前JobContext的全局值表和圖像句柄導(dǎo)出到調(diào)試目錄這樣產(chǎn)品出問題時能在事后復(fù)現(xiàn)當(dāng)時數(shù)據(jù)長什么樣不用守在產(chǎn)線前干瞪眼。調(diào)試文件按時間戳命名配合JOB快照能定位到是哪一版參數(shù)跑出了異常結(jié)果。另外提醒一個實(shí)戰(zhàn)習(xí)慣我每次改完一套視覺流程都會強(qiáng)制走一遍保存快照 → 清空J(rèn)OB → 從快照恢復(fù) → 跑一遍的閉環(huán)確認(rèn)序列化沒有丟參數(shù)才敢把工程交給下一個人改。這個習(xí)慣幫我擋掉過無數(shù)次你昨天調(diào)的挺好的怎么今天就不行了的尷尬局面。希望這套資源的拆解筆記能幫你在WinForm里搭出一套稱手的Halcon工具鏈拿來即用也好改著用也好先把拖拉連線跑通后面的擴(kuò)展路就順了。本文還有配套的精品資源點(diǎn)擊獲取