对于基于Halcon的一种Winform自定义控件的MVVM化改造的一种方式

1. 开始


前面写了一片视觉定位的设计博文,那是一个winform的自定义控件,配有UI面板和二次开发方法。原文。但是,实际使用中会存在一个问题,也是winform大多数自定义控件的问题。在使用时,需要将其实例化一个控件对象(拉一个控件到界面),当一个软件中要存在多个显示视觉位置且显示的是同一个相机定位内容时,要么把同一个控件带着一起跑,要么就要实例化多个控件对象,然后让他们之间做共享。这就带来很大的不方便。对此,主要修改就是通过MVVM思想改造,使得调用者只持有业务和数据,调用者只要给不同实例赋值同一个vm,那么他们之间就是共享的。

2.总体架构


在前一篇中,其实已经有介绍过架构,MVVM改造版本中虽大体相同,但细节上还有地方有所不同。这篇只做一些简单介绍和流程图展示:

2.1分层结构


flowchart TB Input["外部输入图像<br/>HImage / HObject / Bitmap"] subgraph View["View:VisionMain"] Controls["WinForms 参数与命令控件"] Window["HALCON 图像窗口"] RoiController["ROIController<br/>交互式 ROI"] end subgraph VM["ViewModel:VisionViewModel"] State["Image / MatchParam / RoiParams<br/>Caliper / 当前配方状态"] MatchFlow["模板业务流程"] MeasureFlow["卡尺业务流程"] Events["刷新、绘制、缩略图、结果事件"] end subgraph Core["Core / Math"] MatchEngine["MatchEngine<br/>HALCON ShapeModel"] Calipers["Caliper 策略族<br/>HALCON Metrology"] Geometry["AffMath / LineMath / CircleMath"] Results["MatchResult / CaliperResult"] end subgraph Recipe["应用基目录下的配方"] VRecipe["VRecipe:模板"] PreRecipe["PreRecipe:示教参考点"] MRecipe["MRecipe:卡尺"] end Input --> View View -->|"同步图像与纯数据参数"| State Controls --> State State --> MatchFlow State --> MeasureFlow MatchFlow --> MatchEngine MeasureFlow --> Calipers Calipers --> Geometry MatchEngine --> Results Calipers --> Results MatchFlow <--> VRecipe MatchFlow <--> PreRecipe MeasureFlow <--> MRecipe Events -->|"请求,不直接操作控件"| View RoiController -->|"ROI 数据进入策略或 ViewModel"| State Results --> Events

2.2 组件职责


组件 核心职责 不应承担的职责
VisionMain 绑定控件、显示图像、绘制和编辑 ROI、响应 ViewModel 事件 模板文件组织和测量业务编排
VisionViewModel 保存当前状态、编排模板/卡尺流程、持久化配方、发出显示通知 持有窗口句柄或直接操作控件
MatchEngine 创建、加载、保存和查找 HALCON ShapeModel 用户输入、消息框、窗口刷新
Caliper 抽象类 统一卡尺参数、策略创建、ROI 保存加载和测量入口 决定业务页面如何展示结果
卡尺子类 把具体 ROI 转为 HALCON Metrology 对象并生成强语义结果对象 管理模板配方和 UI 生命周期
AffMath 模板参考位姿到当前位姿的点或圆弧变换 选择匹配结果或判断测量是否合格
CaliperResult 保存拟合数据、输出 HTuple、按需绘制结果 再次执行测量或访问配方文件

3. 定位


3.1 模板定位流程


flowchart TD A["设置示教图像"] --> B["绘制搜索 ROI"] B --> C["绘制模板 ROI"] C --> D["View 将 ROI 转为 ROIMatchParams"] D --> E["CreateTemplate 构造模板 HRegion"] E --> F["ReduceDomain + EdgesSubPix"] F --> G["筛选并合并 XLD 轮廓"] G --> H["CreateAnisoShapeModelXld"] H --> I["保存 .shm / .xml / .txt / .jpeg / .bmp"] I --> J["在示教图上执行一次模板匹配"] J --> K["保存模板参考中心到 PreRecipe"] K --> L["绘制卡尺 ROI 并设置测量参数"] L --> M["保存卡尺 ROI XML 与参数 TXT"]

这里要注意的是我们要造一个方法去构造halcon里面的Hregion,尽量不要从窗口控件获取,而是从参数模块获取。另外,创建模板后需要执行一次原地匹配并记录结果,这是预匹配功能的示教基础。ROI可以设置为三类,其中匹配要用到搜索框和模板框,卡尺测量要用到卡尺ROI。

ROI 数据类型 目的 生命周期
搜索 ROI ROIMatchParams.Search* 限定模板查找范围,降低误匹配和计算量 随模板配方保存
模板 ROI ROIMatchParams.Template* 指定创建形状模型的示教区域 随模板配方保存
卡尺 ROI ROIMeasureParams 中的类型专用字段 定义局部边缘测量的初始几何位置 随卡尺配方保存,并由具体卡尺持有对应 ROI 对象

3.2 运行阶段


sequenceDiagram participant Caller as 调用方 participant View as VisionMain participant VM as VisionViewModel participant Match as MatchEngine participant Caliper as Caliper 策略 participant Halcon as HALCON Metrology Caller->>View: 提供待测图像 View->>VM: 同步 Image Caller->>VM: LoadTemplate / LoadMeasure VM->>Match: ReadShapeModel VM-->>View: 请求恢复模板与卡尺 ROI Caller->>VM: GetMeasureResult() alt 开启预匹配 VM->>Match: FindAnisoShapeModel Match-->>VM: row, column, angle, scale, score VM->>Caliper: ApplyPreMatch(参考中心, 当前位姿) VM-->>View: 请求绘制变换后的模板 ROI end VM->>Caliper: GetResult(Image) Caliper->>Halcon: 创建并应用 Metrology 模型 Halcon-->>Caliper: 拟合参数、测量点和轮廓 Caliper-->>VM: CaliperResult VM-->>View: MeasureCompleted View->>View: Draw(ViewWindow) 并显示 HTuple

这里,预匹配的功能被设置为:运行时如果启用预匹配,模板结果先改变卡尺内部的几何位置;随后才把当前图像交给具体卡尺测量。若关闭预匹配,卡尺直接使用加载或绘制时保存的绝对坐标。

3.3模板的加载和匹配机制


创建模板 的当前处理顺序是:

  1. ReduceDomain 把输入图像限制到模板区域;
  2. EdgesSubPix(..., "canny", 10, 20, 40) 提取亚像素 XLD;
  3. SelectShapeXld 保留轮廓长度大于等于 150 的对象;
  4. 若没有轮廓,以另一组 Canny 参数重试,并把最小轮廓长度放宽到 120;
  5. UnionAdjacentContoursXld 合并相邻轮廓;
  6. CreateAnisoShapeModelXld 创建允许行、列方向独立缩放的形状模型;
  7. 读取模板轮廓并平移到 ROI 中心,保存到变量供 View 显示;
  8. 设置 标志位或状态机。

加载模板:

加载模板用 加载方法 把 .shm 文件载入 模板变量。ViewModel 同时恢复:

  • .shm 形状模型;
  • .xml 模板与搜索 ROI 参数;
  • .txt 匹配参数,并间接读取 PreRecipe 中的模板参考中心;
  • .jpeg 缩略图路径;
  • View 刷新和 ROI 重绘请求。

对于模板查找:
查找函数 先对搜索区域执行 ReduceDomain,再用 MeanImage 平滑,然后调用 FindAnisoShapeModel。HALCON 返回多个并行数组:行坐标、列坐标、角度、行缩放、列缩放和得分;代码把它们转换为 List<Result>。

整个模板加卡尺的所有参数都保存为一个"配方":

flowchart LR Base["AppDomain.CurrentDomain.BaseDirectory"] Base --> V["VRecipe / 模板名"] V --> SHM[".shm<br/>HALCON ShapeModel"] V --> VXML[".xml<br/>ROIMatchParams"] V --> VTXT[".txt<br/>MatchParam"] V --> JPEG[".jpeg<br/>裁剪缩略图"] V --> BMP[".bmp<br/>完整示教图"] Base --> P["PreRecipe / 模板名.txt"] P --> Center["row / col<br/>模板参考中心"] Base --> M["MRecipe / 卡尺名"] M --> MXML[".xml<br/>ROIMeasureParams"] M --> MTXT[".txt<br/>CaliperParam"]

如果是要配备二次开发调用函数,建议做三个重载以适应不同需求。

方法 方法功能
GetTemplateResult(out score, out row, out col, out angle) 返回首个结果,并通过事件发布完整结果列表
GetTemplateResult(templateName, out ...) 自动加载模型、ROI、匹配参数和参考中心,再委托给无名称重载
GetTemplateResult(bitmap, templateName, out ...) 把 Bitmap 转为 HImage 写入 Image,再委托给模板名重载

至于模板参数(包括创建模板和寻找模板以及后面的卡尺参数就不展开了,halcon手册中带有详细信息)

4. 测量卡尺


classDiagram class Caliper { <<abstract>> +CaliperParam Param +GetResult(HObject) CaliperResult +DrawROI(ROIController, row, col) +ApplyPreMatch(templateRow, templateCol, matchRow, matchCol, angle) +SaveMeasureROIParams() ROIMeasureParams +LoadROIParams(ROIMeasureParams, ROIController) +CreateCaliper(string) Caliper$ } class LineCaliper class CircleCaliper class ArcCaliper class CrossTCaliper class RectCaliper { } Caliper <|-- LineCaliper Caliper <|-- CircleCaliper Caliper <|-- ArcCaliper Caliper <|-- CrossTCaliper Caliper <|-- RectCaliper Caliper <|-- ThreeArcCaliper Caliper <|-- EllipseCaliperResult

卡尺中,测量的流程和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,也可以在测试中只订阅事件而不创建窗口。

sequenceDiagram participant User as 用户操作 participant View as VisionMain participant VM as VisionViewModel participant Engine as MatchEngine / Caliper participant Other as 其他 View User->>View: 点击测试或测量 View->>VM: 调用 GetTemplateResult / GetMeasureResult VM->>Engine: 执行模板或 Metrology Engine-->>VM: 纯数据结果 VM-->>View: TemplateMatched / MeasureCompleted VM-->>Other: 同一事件广播 View->>View: 绘制十字、轮廓和状态 Other->>Other: 绘制自己的窗口

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。

  1. 盘点状态:把字段分成业务参数、当前输入、配方状态、窗口对象和显示缓存;
  2. 建立通知基类:实现 ObservableObject.Set 和 PropertyChanged,先让参数对象可观察;
  3. 移动参数模型:把 MatchParam、CreateMatchesParam、CaliperParam 的变化通知接入绑定;
  4. 创建 ViewModel:集中持有引擎、参数、图像、当前模板、当前卡尺和流程开关;
  5. 定义事件契约:先为刷新、绘制、缩略图、匹配完成和测量完成定义事件参数;
  6. 迁移模板业务:把文件 I/O、模型创建、匹配和 ROI 纯数据序列化移入 ViewModel;
  7. 迁移卡尺业务:把创建、加载、预匹配和结果获取移入 ViewModel,把 ROI 绘制留在 View;
  8. 拆分结果副作用:用无参数据方法和显式绘制方法替代“Get 方法顺便画图”;
  9. 改造 View:按钮只取输入和调用 VM,事件处理器只转换为控件操作;
  10. 验证边界:搜索 ViewModel 是否引用窗口控件,验证配方往返、失败传播、线程和解绑;
  11. 清理重复实现:删除原文件中的业务副本,保留纯显示和交互方法;
  12. 补齐生产能力:统一错误契约、资源所有权、UI 封送、结果类型和配方版本。
posted @ 2026-09-03 17:43  好久不見  阅读(11)  评论(0)    收藏  举报