对于基于Halcon的一种Winform自定义控件的MVVM化改造的一种方式
1. 开始
前面写了一片视觉定位的设计博文,那是一个winform的自定义控件,配有UI面板和二次开发方法。原文。但是,实际使用中会存在一个问题,也是winform大多数自定义控件的问题。在使用时,需要将其实例化一个控件对象(拉一个控件到界面),当一个软件中要存在多个显示视觉位置且显示的是同一个相机定位内容时,要么把同一个控件带着一起跑,要么就要实例化多个控件对象,然后让他们之间做共享。这就带来很大的不方便。对此,主要修改就是通过MVVM思想改造,使得调用者只持有业务和数据,调用者只要给不同实例赋值同一个vm,那么他们之间就是共享的。
2.总体架构
在前一篇中,其实已经有介绍过架构,MVVM改造版本中虽大体相同,但细节上还有地方有所不同。这篇只做一些简单介绍和流程图展示:
2.1分层结构
2.2 组件职责
| 组件 | 核心职责 | 不应承担的职责 |
|---|---|---|
VisionMain |
绑定控件、显示图像、绘制和编辑 ROI、响应 ViewModel 事件 | 模板文件组织和测量业务编排 |
VisionViewModel |
保存当前状态、编排模板/卡尺流程、持久化配方、发出显示通知 | 持有窗口句柄或直接操作控件 |
MatchEngine |
创建、加载、保存和查找 HALCON ShapeModel | 用户输入、消息框、窗口刷新 |
Caliper 抽象类 |
统一卡尺参数、策略创建、ROI 保存加载和测量入口 | 决定业务页面如何展示结果 |
| 卡尺子类 | 把具体 ROI 转为 HALCON Metrology 对象并生成强语义结果对象 | 管理模板配方和 UI 生命周期 |
AffMath |
模板参考位姿到当前位姿的点或圆弧变换 | 选择匹配结果或判断测量是否合格 |
CaliperResult |
保存拟合数据、输出 HTuple、按需绘制结果 |
再次执行测量或访问配方文件 |
3. 定位
3.1 模板定位流程
这里要注意的是我们要造一个方法去构造halcon里面的Hregion,尽量不要从窗口控件获取,而是从参数模块获取。另外,创建模板后需要执行一次原地匹配并记录结果,这是预匹配功能的示教基础。ROI可以设置为三类,其中匹配要用到搜索框和模板框,卡尺测量要用到卡尺ROI。
| ROI | 数据类型 | 目的 | 生命周期 |
|---|---|---|---|
| 搜索 ROI | ROIMatchParams.Search* |
限定模板查找范围,降低误匹配和计算量 | 随模板配方保存 |
| 模板 ROI | ROIMatchParams.Template* |
指定创建形状模型的示教区域 | 随模板配方保存 |
| 卡尺 ROI | ROIMeasureParams 中的类型专用字段 |
定义局部边缘测量的初始几何位置 | 随卡尺配方保存,并由具体卡尺持有对应 ROI 对象 |
3.2 运行阶段
这里,预匹配的功能被设置为:运行时如果启用预匹配,模板结果先改变卡尺内部的几何位置;随后才把当前图像交给具体卡尺测量。若关闭预匹配,卡尺直接使用加载或绘制时保存的绝对坐标。
3.3模板的加载和匹配机制
创建模板 的当前处理顺序是:
ReduceDomain把输入图像限制到模板区域;EdgesSubPix(..., "canny", 10, 20, 40)提取亚像素 XLD;SelectShapeXld保留轮廓长度大于等于 150 的对象;- 若没有轮廓,以另一组 Canny 参数重试,并把最小轮廓长度放宽到 120;
UnionAdjacentContoursXld合并相邻轮廓;CreateAnisoShapeModelXld创建允许行、列方向独立缩放的形状模型;- 读取模板轮廓并平移到 ROI 中心,保存到变量供 View 显示;
- 设置 标志位或状态机。
加载模板:
加载模板用 加载方法 把 .shm 文件载入 模板变量。ViewModel 同时恢复:
.shm形状模型;.xml模板与搜索 ROI 参数;.txt匹配参数,并间接读取PreRecipe中的模板参考中心;.jpeg缩略图路径;- View 刷新和 ROI 重绘请求。
对于模板查找:
查找函数 先对搜索区域执行 ReduceDomain,再用 MeanImage 平滑,然后调用 FindAnisoShapeModel。HALCON 返回多个并行数组:行坐标、列坐标、角度、行缩放、列缩放和得分;代码把它们转换为 List<Result>。
整个模板加卡尺的所有参数都保存为一个"配方":
如果是要配备二次开发调用函数,建议做三个重载以适应不同需求。
| 方法 | 方法功能 | |
|---|---|---|
GetTemplateResult(out score, out row, out col, out angle) |
返回首个结果,并通过事件发布完整结果列表 | |
GetTemplateResult(templateName, out ...) |
自动加载模型、ROI、匹配参数和参考中心,再委托给无名称重载 | |
GetTemplateResult(bitmap, templateName, out ...) |
把 Bitmap 转为 HImage 写入 Image,再委托给模板名重载 |
至于模板参数(包括创建模板和寻找模板以及后面的卡尺参数就不展开了,halcon手册中带有详细信息)
4. 测量卡尺
卡尺中,测量的流程和ROI坐标仿射变换内容和上一篇文章相似,直接看上一篇就好了。
5.MVVM分层设计
这里就会进入到我们要怎么把一个winform下的UserControl通过MVVM思想分层,以使得自定义控件被多次实例化时,不需要去控制每个实例,只要控制好vm就可以。归结起来说就是让调用的人只控制业务和数据。不过值得一提的是,对于老旧的winform创建的自定义控件,如果不引进框架(比如ReactiveUI)大刀阔斧的改进,其实做的改动更应该被称为"混合 MVVM 形态的自定义控件改造"。因为你很难通过小改动改成纯MVVM架构。
5.1 目标和设计
我们首先要看哪些字段可以留在View,哪些需要放在ViewModel中。
原则就是一开始说的,让用自定义控件的人只持有业务和数据,所以直接按“状态是否属于业务”划分。
| 原字段或对象 | 当前归属 | 判断依据 |
|---|---|---|
isUsePreMatch |
ViewModel.IsUsePreMatch |
测量流程的业务开关 |
isfullRegion |
ViewModel.IsFullRegion |
模板查找策略 |
currentTemplateName |
ViewModel.CurrentTemplateName |
当前配方状态 |
currentCaliper |
ViewModel.CurrentCaliper |
当前卡尺配方状态 |
templateCenterRow/Col |
ViewModel.TemplateCenterRow/Col |
坐标变换参考点 |
_matchParam |
ViewModel.MatchParam |
匹配参数模型 |
_createMatchesParam |
ViewModel.CreateMatchesParam |
模板创建参数模型 |
MatchEngine |
ViewModel.MatchEngine |
模板业务服务 |
caliper |
ViewModel.Caliper |
当前卡尺策略和参数 |
Image |
ViewModel.Image |
当前业务输入图像 |
RoiParams |
ViewModel.RoiParams |
可序列化的模板/搜索 ROI 数据 |
hSmartWindow |
VisionMain |
具体显示控件和窗口句柄 |
roiCtrl |
VisionMain |
具体 View 的 ROI 管理器 |
searchROI/templateROI |
VisionMain |
鼠标可编辑的 UI 对象 |
_rectR1/_rectC1/_rectR2/_rectC2/_rectColor/_hasRect |
VisionMain |
纯显示缓存,不影响算法状态 |
对于这种老项目的修改,要注意一个问题,ROI参数通常会单独建立一个类,他们其实是纯数据,可以由 ViewModel 序列化和重建 HRegion。但是ROIController 和 ROI 对象是带窗口状态的交互对象,必须由 View 保管。
然后对于方法的迁移,所有的按钮下的事件方法内容都要迁移(与数据和业务实现相关的尽量全部迁移,但与ROI强相关的有些不容易迁移)。
| 方法类别 | 归属 | 迁移后的调用关系 |
|---|---|---|
| 模板创建/加载/删除 | ViewModel | View -> CreateTemplate/LoadTemplate/DeleteTemplate |
| 模板匹配 | ViewModel | View -> GetTemplateResult,结果通过事件回传 |
| 匹配参数保存/加载 | ViewModel | View 只负责绑定控件 |
| 卡尺创建/加载/测量 | ViewModel | View -> CreateMeasure/LoadMeasure/GetMeasureResult |
| 卡尺 ROI 绘制 | View | View.DrawMeasure -> Caliper.DrawROI(roiCtrl, ...) |
| 模板/搜索 ROI 初始绘制 | View | View.CreateRegion |
| 图像显示、清窗、重绘 | View | 响应 ViewRefreshRequested |
| 结果轮廓和十字绘制 | View 或结果对象的绘制适配 | 响应 MeasureCompleted 或 DrawCrossRequested |
| Bitmap/HObject/HImage 转换 | Core.ImageConverter |
View 或 ViewModel 调用纯工具 |
因此“按钮事件变薄”不是为了减少代码行数,而是为了让业务入口可以在没有窗口的情况下被定位、测试和复用。可能仍有少数按钮直接操作 UI,例如打开图像、清理 ROI 和显示模板轮廓,这是合理的 View 职责,如果是老旧项目又不愿意大改就不要改。
在初始化时,设计一个可以外部赋值的ViewModel字段,使得能在外部接收ViewModel。
public VisionViewModel ViewModel
{
get => _externalVm ?? _internalVm;
set
{
VisionViewModel old = ViewModel;
if (!ReferenceEquals(old, value))
{
UnbindViewModel(old); //解绑旧 ViewModel函数
_externalVm = value;
if (_externalVm != null)
BindViewModel(_externalVm); //绑定新 ViewModel函数
}
}
}
这样,外部只要控制vm就可以使得多个实例共享同一个vm,而不用去控制每个实例。
var sharedVm = new VisionViewModel();
formA.VisionMain1.ViewModel = sharedVm; // 窗口A
formB.VisionMain1.ViewModel = sharedVm; // 窗口B(共享同一VM)
5.2 ViewModel的状态设计
可以分三个partial文件。避免一个文件过于臃肿和混乱。构造函数初始化核心状态需要把最核心的类初始化:
public VisionViewModel()
{
MatchEngine = new MatchEngine();
MatchParam = new MatchParam();
CreateMatchesParam = new CreateMatchesParam();
RoiParams = new ROIMatchParams();
}
主要状态可以分为四组:
| 状态组 | 属性 | 不变量或使用约束 |
|---|---|---|
| 引擎和参数 | MatchEngine、MatchParam、CreateMatchesParam |
生命周期通常跟随 ViewModel;参数需与当前模板配方对应 |
| 当前输入 | Image、RoiParams |
Image 是当前测量帧;RoiParams 是模板/搜索 ROI 纯数据 |
| 当前配方 | CurrentTemplateName、CurrentCaliper |
名称和文件后缀需要统一,加载前应校验存在性 |
| 流程开关 | IsUsePreMatch、IsFullRegion、TemplateCenterRow/Col |
决定是否定位、搜索范围及变换基准 |
属性通过 ObservableObject.Set 只有在值变化时触发 PropertyChanged。Image 属性还额外触发 ImageChanged,使 View 能把业务图像同步到显示控件:
public HImage Image
{
get => _image;
set
{
if (!ReferenceEquals(_image, value))
{
_image = value;
OnPropertyChanged();
OnImageChanged();
}
}
}
5.3 数据双向流动
BindViewModel 在 View 构造后完成两类工作:订阅 ViewModel 事件,以及把控件属性绑定到 MatchParam。
当前绑定的控件包括(以匹配参数为例,坐边是控件,右边是参数类下的字段)(这里即使是普通的winform程序中也建议使用这种绑定,而不要去写一大堆同步代码):
numAngleStart.Value <-> MatchParam.AngleStart
numAngleExtent.Value <-> MatchParam.AngleExtent
numMinScore.Value <-> MatchParam.MinScore
numNumMatches.Value <-> MatchParam.NumMatches
numMaxOverlap.Value <-> MatchParam.MaxOverlap
numGreediness.Value <-> MatchParam.Greediness
numericUpDownScaleRmin <-> MatchParam.scaleRMin
numericUpDownScaleRmax <-> MatchParam.scaleRMax
numericUpDownScaleCmin <-> MatchParam.scaleCMin
numericUpDownScaleCmax <-> MatchParam.scaleCMax
cmbSubPixel.SelectedItem <-> MatchParam.SubPixel
用 DataSourceUpdateMode.OnPropertyChanged绑定变量,因此有两个方向:
用户修改 NumericUpDown
-> Binding 将 decimal 转为 double/int
-> MatchParam setter
-> ObservableObject.Set
-> 其他绑定控件收到 PropertyChanged
LoadMatchParamFromTxt 修改 MatchParam
-> Set 触发 PropertyChanged
-> Binding 将 double/int 转回控件的 decimal Value
绑定示例代码
vm.ImagePointClicked += OnImagePointClicked;
vm.MeasureCompleted += OnMeasureCompleted;
vm.DrawMeasureROIRequested += OnDrawMeasureROI;
vm.AffDrawTemplateROIRequested += OnAffDrawTemplateROI;
// DataBindings
numAngleStart.DataBindings.Add(new Binding("Value", vm.MatchParam, nameof(MatchParam.AngleStart), true, DataSourceUpdateMode.OnPropertyChanged));
numAngleExtent.DataBindings.Add(new Binding("Value", vm.MatchParam, nameof(MatchParam.AngleExtent), true, DataSourceUpdateMode.OnPropertyChanged));
numMinScore.DataBindings.Add(new Binding("Value", vm.MatchParam, nameof(MatchParam.MinScore), true, DataSourceUpdateMode.OnPropertyChanged));
numNumMatches.DataBindings.Add(new Binding("Value", vm.MatchParam, nameof(MatchParam.NumMatches), true, DataSourceUpdateMode.OnPropertyChanged));
numMaxOverlap.DataBindings.Add(new Binding("Value", vm.MatchParam, nameof(MatchParam.MaxOverlap), true, DataSourceUpdateMode.OnPropertyChanged));
5.4 事件和命令
要把命令式 UI 调用改成语义事件:
改造前 ViewModel 或窗体业务代码直接执行:
hSmartWindow.ClearWindow();
roiCtrl.reset();
DisplayCross(row, col, 100, "green");
pictureBoxTemplate.Image = image;
改造后,ViewModel 只表达业务状态或显示请求:
OnViewRefreshRequested(new ViewRefreshEventArgs
{
ClearWindow = true,
DisplayImage = true,
ClearRoi = true
});
OnDrawCrossRequested(new DrawCrossEventArgs
{
Row = row,
Column = col,
Size = 100,
Color = "green"
});
当前事件契约如下:
| 事件 | 参数 | View 的实际动作 |
|---|---|---|
ImageChanged |
无 | 把 ViewModel.Image 显示到 HALCON 窗口 |
ViewRefreshRequested |
ClearWindow/DisplayImage/ClearRoi/Repaint |
清窗、显示图像、清 ROI、重绘 |
DrawCrossRequested |
Row/Column/Size/Color |
在匹配位置画十字 |
ThumbnailRequested |
TemplateName/ImagePath |
加载模板缩略图 |
TemplateMatched |
Success/Results/FirstResult |
更新匹配状态和结果摘要 |
MeasureCompleted |
Success/ResultType/ResultData/CaliperResult |
清理显示并绘制测量结果 |
DrawMeasureROIRequested |
RoiParams/Caliper |
View 调用 LoadROIParams 恢复卡尺 ROI |
AffDrawTemplateROIRequested |
IsFullRegion/Row/Col/Angle |
View 重绘仿射后的模板 ROI |
ImagePointClicked |
(row, col) |
转发图像点击坐标 |
事件的价值在于事件名表达“发生了什么”或“需要刷新什么”,不是暴露控件方法名。View 可以把同一事件映射到不同 UI,也可以在测试中只订阅事件而不创建窗口。
5.5 模板业务迁移
模板业务迁移需要做到把“从 ROI 对象提取数据”和“根据数据创建模型”分开。老项目的 CreateTemplate 直接从 hSmartWindow 和两个 ROI 对象取数据;修改后 View 先把可编辑 ROI 转为 ROIMatchParams,再调用 ViewModel,这样ViewModel 可以在没有具体 ROIController 的情况下重建模板区域,也可以由别的 View 提供同样的 ROIMatchParams。
View.CreateRegion / OnROIEvent
-> 读取 searchROI/templateROI.getModelData()
-> ViewModel.UpdateRoiParams(ROIMatchParams)
ViewModel.CreateTemplate(name)
-> 校验 name、Image、RoiParams
-> GenRectangle2 重建模板 HRegion
-> MatchEngine.CreateTemplateModel
-> 保存配方文件组
-> 发布 ThumbnailRequested / ViewRefreshRequested
5.6 卡尺业务迁移
说实话,这一块我觉得我没有太做好。最大的问题在于卡尺的GetMResult方法中传入了一个window(鬼知道为什么以前要这样做),就是winform界面上的halcon界面,也就是说它getresult的时候会把结果画上去。这在不愿意大动干戈的情况下不是很好操作。
修改的时候采用三段式兼容设计:
public abstract HTuple GetMResult();
public virtual void Draw(ViewWindow.ViewWindow viewWindow)
{
}
public HTuple GetMResult(ViewWindow.ViewWindow viewWindow)
{
HTuple result = GetMResult();
Draw(viewWindow);
return result;
}
ViewModel 调用无参 GetMResult(),并通过 MeasureCompleted 把结果通知 View;View 再根据需要调用 Draw。这样,服务端式或单元测试式调用可以拿到数据而不必构造窗口,旧 View 代码仍可继续工作。
这样最大的问题是Model层其实一定程度上侵入了UI。希望以后有时间能改掉。
5.7小总结
简单来说,如果遇到一个老的非常"winform"的自定义控件,当使用它且一个项目不止一个地方要出现却又显示相同内容的时候,可以考虑使用MVVM的思想改造一下,使得只需要控制一个ViewModel就可以控制多个View。
- 盘点状态:把字段分成业务参数、当前输入、配方状态、窗口对象和显示缓存;
- 建立通知基类:实现
ObservableObject.Set和PropertyChanged,先让参数对象可观察; - 移动参数模型:把
MatchParam、CreateMatchesParam、CaliperParam的变化通知接入绑定; - 创建 ViewModel:集中持有引擎、参数、图像、当前模板、当前卡尺和流程开关;
- 定义事件契约:先为刷新、绘制、缩略图、匹配完成和测量完成定义事件参数;
- 迁移模板业务:把文件 I/O、模型创建、匹配和 ROI 纯数据序列化移入 ViewModel;
- 迁移卡尺业务:把创建、加载、预匹配和结果获取移入 ViewModel,把 ROI 绘制留在 View;
- 拆分结果副作用:用无参数据方法和显式绘制方法替代“Get 方法顺便画图”;
- 改造 View:按钮只取输入和调用 VM,事件处理器只转换为控件操作;
- 验证边界:搜索 ViewModel 是否引用窗口控件,验证配方往返、失败传播、线程和解绑;
- 清理重复实现:删除原文件中的业务副本,保留纯显示和交互方法;
- 补齐生产能力:统一错误契约、资源所有权、UI 封送、结果类型和配方版本。

浙公网安备 33010602011771号