简介:这份面向 WPF 与机器视觉开发者的 C# 图像控件项目,围绕仿 Halcon HSmartWindowControl 的交互式图像显示与 ROI 绘制管理展开,适合已有一定 C#/WPF 基础、希望在工业检测、医学成像或视频监控场景中复用图像标注能力的中级开发者。压缩包共86个文件,约319KB,以31个 cs 源码为核心,配合 xaml 界面、csproj/sln 工程配置、dll 与 resources 等运行依赖,以及 json/settings 等辅助文件,便于直接打开解决方案梳理结构。已有412人学习。内容包含自定义图像控件、ROI 增删改与鼠标交互、图像加载显示、ROI 数据结构与接口设计等模块,并预留 OpenCV 图像处理与模板匹配扩展思路,可用于对照实现、拆解架构和二次开发。
1. 从 HSmartWindowControl 换到纯 WPF:一个能画 ROI 的图像控件值在哪
在 WPF 里做机器视觉界面,图像显示和 ROI 交互这两件事经常被低估。Halcon 的 HSmartWindowControl 之所以顺手,是因为它把缩放、平移、region 交互、鼠标消息全封在一个原生窗口里;搬到 WPF 之后,多数人只能用 WindowsFormsHost 把它嵌进来,结果 DPI 缩放发虚、鼠标事件被截、跟 WPF 的样式体系完全两套体系。这份资源走的是另一条路:纯 WPF 自定义图像控件重写这套能力,WriteableBitmap 负责像素上传,矩阵负责坐标变换,ROI 管理层负责矩形、圆、椭圆、线段、多边形的绘制、拖动、改尺寸与序列化,最后再把 ROI 翻译成 Halcon region 或 OpenCV mask 交给算法。它适合正在写视觉上位机、缺陷标注工具、检测流程配置界面的 C# 开发者,也适合想把 Halcon 那套交互习惯平移到 WPF 上的团队。
2. 控件骨架:先定渲染落点,再拆视图、变换、ROI 三层
动手之前得先把“图像画在哪”这件事定死,因为后面所有 ROI 的坐标、命中测试、缩放行为都挂在这个选择上。选错了,写到一半再换渲染方式,等于把交互层重写一遍。
2.1 三种渲染落点,按帧率和分辨率选
WPF 显示位图常见三条路,代价差别很大:
| 方案 | 上手成本 | 帧率表现 | 适合场景 |
|---|---|---|---|
| Image 控件 + WriteableBitmap | 低 | 1080p 30fps 无压力,4K 偏高 | 中低速相机、离线图查看 |
| FrameworkElement + OnRender 直绘 | 中 | 与上者相近,可控性更好 | 需要叠 ROI、文字、标尺 |
| D3DImage + 共享纹理 | 高 | 高帧率、可做到零拷贝 | 高速相机、4K 以上高刷 |
资源里用的是第二种。理由很实际:ROI、十字线、标尺、文字提示都要跟图像共用同一套变换,如果图像放在 Image 控件里、ROI 放在上层 Canvas 里,就变成两个视觉树,缩放时要同步两个容器的 Transform,鼠标命中也得在两套坐标系之间来回折返,稍有疏漏就是“框偏了”。把图像和 ROI 塞进同一个OnRender,一次PushTransform全部搞定。
D3DImage 不是不能用,而是它的收益只在超高帧率下才体现得出来,而且要和 D3D9 做纹理共享,调试成本高。常规工业相机 20~60fps、2000×2000 以内的图,WriteableBitmap 完全够。
2.2 三层拆分:视图层只管画,变换层只出口一个矩阵,ROI 层只存世界坐标
拆分的核心约定只有一条:所有 ROI 的几何数据一律存世界坐标(也就是图像像素坐标,原点在图像左上角,X 向右,Y 向下),屏幕坐标只出现在渲染和鼠标命中的那一瞬间。
这条约定带来的好处是,缩放、平移、窗口大小变化都不需要去改 ROI 的数据。视图层拿到当前矩阵,PushTransform之后按原样画;鼠标事件进来,先用逆矩阵换算成世界坐标,再去和 ROI 比。少一次“我在哪个坐标系里”的自我怀疑,就少一个通宵。
界面层大致是这几个成员:一个WriteableBitmap持有像素,一个Matrix表示世界到屏幕的变换,一个ObservableCollection<RoiBase>持有 ROI,一组交互状态字段(当前工具、正在操作的 ROI、正在拖的手柄索引)。
2.3 最小可运行的控件骨架
using System; using System.Windows; using System.Windows.Input; using System.Windows.Media; using System.Windows.Media.Imaging; namespace Vision.Controls { /// <summary>纯 WPF 图像显示 + ROI 宿主控件</summary> public class RoiImageView : FrameworkElement { // 世界坐标(图像像素)-> 控件坐标(DIP)的变换 private Matrix _view = Matrix.Identity; private WriteableBitmap _bitmap; public RoiImageView() { // 像素级查看时不要做插值,放大后才不会糊成一团 RenderOptions.SetBitmapScalingMode(this, BitmapScalingMode.NearestNeighbor); ClipToBounds = true; Focusable = true; // 需要接收键盘事件(Esc 取消绘制) } public WriteableBitmap Bitmap { get => _bitmap; set { _bitmap = value; if (_bitmap != null) FitToWindow(); InvalidateVisual(); } } /// <summary>当前变换,供 ROI 层和外部做坐标换算</summary> public Matrix ViewMatrix => _view; protected override void OnRender(DrawingContext dc) { // 铺一层底色,避免透明区域接不到鼠标命中测试 dc.DrawRectangle(Brushes.Black, null, new Rect(RenderSize)); if (_bitmap == null) return; dc.PushTransform(new MatrixTransform(_view)); dc.DrawImage(_bitmap, new Rect(0, 0, _bitmap.PixelWidth, _bitmap.PixelHeight)); // 这里就是 ROI 层的绘制入口,稍后补上 dc.Pop(); } } }几个参数值得单独说。BitmapScalingMode.NearestNeighbor是像素级调试的必需品,默认的线性插值在 8 倍放大下会把一个坏点糊成一片灰雾,看起来像算法漏检,实则是显示骗了你。ClipToBounds保证图像超出控件范围时不会画到隔壁面板上去。Focusable = true是为了让OnKeyDown能收到消息,绘制中途按 Esc 取消这个交互,没有焦点就收不到。
FitToWindow的实现放在下一章,因为它和矩阵是同一件事。
3. 坐标变换:一个矩阵管住缩放、平移和像素对齐
图像控件里 80% 的“玄学 bug”都出在坐标系上:框的位置偏半个像素、放大后手柄点不中、滚轮缩放时光标下的点会漂移。这些都不是 WPF 的锅,是变换的顺序和时机搞错了。
3.1 把 HObject 和 Mat 搬进 WriteableBitmap
不管后面画多少 ROI,第一步得让像素进来。这里的原则是复用同一个 WriteableBitmap,绝不每帧 new。
Halcon 侧,GetImagePointer1能直接拿到像素首地址,配合WritePixels的指针重载可以避免一次中间拷贝:
using HalconDotNet; using System; using System.Windows; using System.Windows.Media; using System.Windows.Media.Imaging; /// <summary>把 Halcon 单通道 8 位图写进已有的 WriteableBitmap(复用,不新建)</summary> public static void UpdateFromHalcon(HObject image, WriteableBitmap target) { HOperatorSet.GetImageSize(image, out HTuple w, out HTuple h); HOperatorSet.GetImagePointer1(image, out HTuple ptr, out HTuple type, out _, out _); int width = w, height = h; if (target.PixelWidth != width || target.PixelHeight != height) throw new ArgumentException("目标位图尺寸与图像不一致,请重建 WriteableBitmap"); // Gray8 下每像素 1 字节,stride 等于 width;宽为奇数时也要按实际行宽校验一次 target.WritePixels(new Int32Rect(0, 0, width, height), ptr, width * height, width); }WritePixels的第三个参数是缓冲区总字节数,第四个是行跨度 stride。Gray8 图 stride 就等于宽度,但如果你处理的是三通道交错图,stride 应该是width * 3,写错会得到一张斜切的图——这是最容易被误判成“相机输出有问题”的坑。另外GetImagePointer1拿到的内存归 Halcon 管,WritePixels是同步拷贝,调用返回后就可以释放 HObject,不存在悬空指针。
OpenCV 侧对应的是Mat,重点在于通道顺序和连续性:
using OpenCvSharp; using System; using System.Windows; using System.Windows.Media; using System.Windows.Media.Imaging; /// <summary>Mat -> WriteableBitmap,支持灰度、BGR、BGRA</summary> public static WriteableBitmap FromMat(Mat mat) { // 子矩阵(ROI 裁剪出来的 Mat)可能不连续,直接读 Data 会读到上一行的尾巴 if (!mat.IsContinuous()) mat = mat.Clone(); PixelFormat fmt = mat.Channels() switch { 1 => PixelFormats.Gray8, 3 => PixelFormats.Bgr24, // OpenCV 默认 BGR,不是 RGB,别转反了 4 => PixelFormats.Bgra32, _ => throw new NotSupportedException($"不支持 {mat.Channels()} 通道") }; var bmp = new WriteableBitmap(mat.Width, mat.Height, 96, 96, fmt, null); bmp.WritePixels(new Int32Rect(0, 0, mat.Width, mat.Height), mat.Data, (int)(mat.Step() * mat.Height), (int)mat.Step()); bmp.Freeze(); // 冻结后可跨线程读,UI 线程只负责渲染 return bmp; }mat.Step()是行字节数,可能大于width * channels(OpenCV 会做内存对齐),所以必须用Step()而不是自己乘。Freeze()这一行很关键:图像往往在采集线程里转换,没冻结的WriteableBitmap属于创建它的线程,跨线程赋值给控件会直接抛InvalidOperationException。
3.2 世界坐标与屏幕坐标的正逆变换
WPF 的Matrix是 3×2 仿射矩阵,点变换公式是x' = x*M11 + y*M21 + OffsetX,y' = x*M12 + y*M22 + OffsetY。Scale改的是 M11/M22,Translate改的是 OffsetX/OffsetY,Rotate会让 M12/M21 非零。图像控件里不做旋转,所以只用得到前两项。
/// <summary>世界坐标 -> 屏幕坐标(DIP)</summary> public Point WorldToScreen(Point world) => _view.Transform(world); /// <summary>屏幕坐标 -> 世界坐标</summary> public Point ScreenToWorld(Point screen) { Matrix inv = _view; inv.Invert(); // 缩放为 0 时会抛异常,调用前保证 M11 > 0 return inv.Transform(screen); } /// <summary>世界坐标下的容差换算:屏幕上 6 个 DIP 对应多少图像像素</summary> public double ScreenToleranceToWorld(double screenPx) => screenPx / _view.M11;最后那个换算方法是整套交互的关键。手柄命中、边框命中如果写死成“世界坐标 3 像素容差”,图放大到 8 倍时手柄在屏幕上只有 0.375 个 DIP,鼠标根本点不中;反过来图缩到 0.1 倍时,容差覆盖 30 个屏幕像素,随便点哪儿都选中。正确做法是把容差定义在屏幕侧(人眼判断的单位),进世界坐标前除以缩放比。
3.3 以光标为锚点的滚轮缩放与拖动平移
滚轮缩放最容易翻车的地方是“先改缩放再算偏移”。顺序反了,光标下的那个像素会从指针底下溜走,连滚几下图像就飞出窗口。
protected override void OnMouseWheel(MouseWheelEventArgs e) { if (_bitmap == null) return; Point anchor = e.GetPosition(this); // 屏幕上不动的那个点 double factor = e.Delta > 0 ? 1.1 : 1 / 1.1; double oldScale = _view.M11; double newScale = Math.Clamp(oldScale * factor, 0.05, 64.0); double k = newScale / oldScale; // 用实际生效比例,防止越界后偏移算错 // 锚点在世界坐标下不变:offset' = anchor - (anchor - offset) * k _view.OffsetX = anchor.X - (anchor.X - _view.OffsetX) * k; _view.OffsetY = anchor.Y - (anchor.Y - _view.OffsetY) * k; _view.M11 = newScale; _view.M22 = newScale; InvalidateVisual(); e.Handled = true; }Math.Clamp的上下限 0.05 和 64 不是随便取的:小于 0.05 时整张图只剩几个像素,用户会以为程序挂了;大于 64 时float精度开始露馅,1 像素的 ROI 拖动会跳。k用实际生效比例而不是factor,是因为夹紧后factor已经不等于真实缩放比,直接拿它算偏移会让光标下的点漂移。
平移就简单了,中键按下记起点,移动时累加偏移:
protected override void OnMouseMove(MouseEventArgs e) { if (_isPanning && e.MiddleButton == MouseButtonState.Pressed) { Point now = e.GetPosition(this); _view.OffsetX += now.X - _panStart.X; _view.OffsetY += now.Y - _panStart.Y; _panStart = now; InvalidateVisual(); } base.OnMouseMove(e); }自适应窗口则是一步到位,注意Scale和Translate的调用顺序,WPF 是追加而非覆盖:
public void FitToWindow() { if (_bitmap == null || ActualWidth <= 0 || ActualHeight <= 0) return; double s = Math.Min(ActualWidth / _bitmap.PixelWidth, ActualHeight / _bitmap.PixelHeight); _view = Matrix.Identity; _view.Scale(s, s); // 先缩放 _view.Translate((ActualWidth - _bitmap.PixelWidth * s) / 2, (ActualHeight - _bitmap.PixelHeight * s) / 2); // 再居中 InvalidateVisual(); }这里得到的s通常带小数,图像实际落在半像素上。如果你做的是测量类应用,需要开启UseLayoutRounding并在最终显示时把偏移取整到 1/s 的整数倍,否则边缘像素会有一层灰边,看起来像图像本身坏了。
4. ROI 绘制与管理:状态机、手柄和算法落地
到这一步图像能显示、能缩放平移了,接下来是最费时间的部分。ROI 不是“画个矩形”这么简单,它要能被选中、拖动、改尺寸、删除、复制、序列化,还要能翻译成下游算法认的格式。
4.1 一个 RoiBase 撑起五种形状
抽象基类只管契约,具体形状各管各的几何。所有坐标一律世界坐标,所有方法都在世界坐标下工作。
using System; using System.Collections.Generic; using System.Windows; using System.Windows.Media; public abstract class RoiBase { public string Id { get; set; } = Guid.NewGuid().ToString("N"); public string Name { get; set; } public Color Color { get; set; } = Colors.Lime; public bool IsVisible { get; set; } = true; public bool IsSelected { get; set; } /// <summary>世界坐标下的包围盒,用于 Z 序过滤和粗命中</summary> public abstract Rect GetBounds(); /// <summary>在世界坐标下绘制;pxPerDip 用于把线宽换算回 1 个屏幕像素</summary> public abstract void Draw(DrawingContext dc, double pxPerDip); /// <summary>命中测试,tol 已换算成世界坐标下的像素容差</summary> public abstract bool HitTest(Point world, double tol); /// <summary>控制点,矩形 4 个,椭圆 8 个,多边形 N 个</summary> public abstract IReadOnlyList<Point> GetHandles(); public abstract void MoveBy(Vector delta); public abstract void MoveHandle(int index, Point world); } public class RectangleRoi : RoiBase { public Point P1, P2; // 两个对角点,允许 P1 > P2,内部用 Rect 归一化 public override Rect GetBounds() => new Rect(P1, P2); public override void Draw(DrawingContext dc, double pxPerDip) { var pen = new Pen(new SolidColorBrush(Color), pxPerDip); var brush = IsSelected ? new SolidColorBrush(Color.FromArgb(40, Color.R, Color.G, Color.B)) : null; dc.DrawRectangle(brush, pen, GetBounds()); } public override bool HitTest(Point world, double tol) { // 只命中边框:内部留给图像平移,否则框大了以后整块区域都拖不动图 Rect outer = GetBounds(); outer.Inflate(tol, tol); Rect inner = GetBounds(); inner.Inflate(-tol, -tol); return outer.Contains(world) && !inner.Contains(world); } public override IReadOnlyList<Point> GetHandles() { Rect r = GetBounds(); return new[] { new Point(r.Left, r.Top), new Point(r.Right, r.Top), new Point(r.Right, r.Bottom), new Point(r.Left, r.Bottom) }; } public override void MoveBy(Vector d) { P1 += d; P2 += d; } public override void MoveHandle(int index, Point w) { // 对角点固定,只更新被拖的那个角 if (index == 0) P1 = new Point(w.X, w.Y); else if (index == 1) P2 = new Point(w.X, P2.Y); // 其余索引同理,实际实现里按 8 个手柄展开更省事 } }Draw里那个pxPerDip参数是整个控件里最容易被忽略的细节。如果OnRender里PushTransform(_view)之后直接传 1.0 的线宽,线宽会被一起缩放:放大 8 倍时边框有 8 个像素粗,缩到 0.1 倍时整个框消失。正确做法是调用时传1.0 / _view.M11,让最终渲染结果始终是 1 个屏幕像素。
HitTest只命中边框的写法,实测比“命中整个矩形”好用。框选一个 800×600 的缺陷区域后,用户还想拖图像看看周边上下文,如果内部也吃鼠标事件,就只能先取消选中再拖,体验很差。
4.2 交互状态机:绘制、拖动、改尺寸
鼠标事件必须收敛到一个显式状态机里,用if嵌套判断按钮状态的写法,加两个形状就乱了。
private enum EditMode { None, Drawing, Moving, Resizing } private EditMode _mode = EditMode.None; private RoiBase _active; private int _handleIndex = -1; private Point _dragStartWorld; private Rect _boundsBeforeDrag; private const double HandleTolDip = 6.0; // 手柄容差定义在屏幕侧 private const double HitTolDip = 4.0; protected override void OnMouseLeftButtonDown(MouseButtonEventArgs e) { Focus(); Point world = ScreenToWorld(e.GetPosition(this)); _dragStartWorld = world; // 1) 从最上层往下找:先命中手柄,再命中边框 for (int i = _rois.Count - 1; i >= 0; i--) { RoiBase roi = _rois[i]; if (!roi.IsVisible) continue; double handleTol = ScreenToleranceToWorld(HandleTolDip); IReadOnlyList<Point> handles = roi.GetHandles(); for (int h = 0; h < handles.Count; h++) { if ((handles[h] - world).Length <= handleTol) { SelectOnly(roi); _active = roi; _handleIndex = h; _mode = EditMode.Resizing; CaptureMouse(); return; } } if (roi.HitTest(world, ScreenToleranceToWorld(HitTolDip))) { SelectOnly(roi); _active = roi; _handleIndex = -1; _mode = EditMode.Moving; _boundsBeforeDrag = roi.GetBounds(); CaptureMouse(); return; } } // 2) 都没命中,按当前工具开始画新 ROI if (CurrentTool != RoiTool.None) { _active = RoiFactory.Create(CurrentTool, world); _rois.Add(_active); SelectOnly(_active); _mode = EditMode.Drawing; CaptureMouse(); } else { ClearSelection(); } }CaptureMouse()那一行不能省。少了它,鼠标拖出控件边界后MouseMove就断了,用户松开左键回来发现 ROI 卡在边界上,是那种“偶发、难复现、用户也说不清”的问题。OnMouseUp里记得ReleaseMouseCapture()。
OnMouseMove里按_mode分派:Drawing时更新_active的第二个角点;Moving时用world - _dragStartWorld作为位移量调MoveBy;Resizing时调MoveHandle。OnMouseUp收尾时要处理“画得太小”的情况——按下和抬起落在同一个像素上会生成一个 0×0 的 ROI,后续生成 mask 时面积为零,算法侧会拿到空区域。
protected override void OnMouseLeftButtonUp(MouseButtonEventArgs e) { if (_mode == EditMode.Drawing && _active != null) { Rect b = _active.GetBounds(); // 小于 3 个像素的框视为误触,直接丢弃 if (b.Width < 3 || b.Height < 3) _rois.Remove(_active); } _mode = EditMode.None; _active = null; _handleIndex = -1; ReleaseMouseCapture(); InvalidateVisual(); }取消绘制靠OnKeyDown捕获 Esc,把_active从集合里移除即可,不需要额外记录“绘制前的快照”——因为新 ROI 是按下鼠标那一刻才加进集合的。
4.3 手柄命中、选中态与 Z 序
Z 序不用单独维护,ObservableCollection的顺序就是绘制顺序,后加入的画在上面,命中测试反向遍历。这个约定省掉了一个ZIndex字段和它的排序逻辑。
多选用 Ctrl 加选,但要注意:加选时不应该触发拖动,否则用户 Ctrl 点第二个框的瞬间就把第一个框挪了。常见做法是Keyboard.Modifiers含 Ctrl 时只改选中集合,不改_mode。
选中态高亮建议同时改两个东西:边框换成强调色,同时画一个小尺寸的填充。只改颜色的话,绿色 ROI 在绿色背景图上完全看不出选中没有。
手柄的绘制也要跟着缩放走,用1.0 / _view.M11作为半径:
public void DrawHandles(DrawingContext dc, RoiBase roi, double pxPerDip) { if (!roi.IsSelected) return; double r = 4 * pxPerDip; // 屏幕上恒为 4 个 DIP foreach (Point h in roi.GetHandles()) dc.DrawRectangle(Brushes.White, new Pen(Brushes.DodgerBlue, pxPerDip), new Rect(h.X - r, h.Y - r, r * 2, r * 2)); }4.4 ROI 落地:Halcon region 与 OpenCV mask 互转
这才是仿 HSmartWindowControl 的真正意义所在——ROI 不只是给人看的,它要变成算法能吃的区域。
Halcon 的坑集中在行列顺序上。GenRectangle1的签名是(Row1, Column1, Row2, Column2),也就是(y, x),而 WPF 的Point是(x, y),顺序反了会得到一个转置的、完全错位的区域。
using HalconDotNet; using System.Windows; public static class RoiToHalcon { public static HObject ToRegion(RoiBase roi) => roi switch { RectangleRoi r => GenRect(r), CircleRoi c => GenCircle(c), _ => throw new NotSupportedException(roi.GetType().Name) }; private static HObject GenRect(RectangleRoi roi) { Rect b = roi.GetBounds(); HOperatorSet.GenRectangle1(out HObject region, b.Top, b.Left, b.Bottom, b.Right); // Row1, Column1, Row2, Column2 return region; } private static HObject GenCircle(CircleRoi roi) { HOperatorSet.GenCircle(out HObject circle, roi.Center.Y, roi.Center.X, roi.Radius); // Row, Column, Radius return circle; } }注意GenRectangle1用的是亚像素坐标系,传浮点数没问题,但如果你的 ROI 是整数像素格,最好在转换前统一加 0.5 做像素中心对齐,否则区域边界会整体偏半像素,面积统计和算法阈值都会跟着飘。
OpenCV 侧生成 mask,最常犯的错是thickness传了 1:
using OpenCvSharp; using System.Collections.Generic; using CvRect = OpenCvSharp.Rect; // 必须起别名,否则和 System.Windows.Rect 打架 using CvPoint = OpenCvSharp.Point; public static Mat ToMask(IEnumerable<RoiBase> rois, int width, int height) { var mask = new Mat(height, width, MatType.CV_8UC1, Scalar.All(0)); foreach (RoiBase roi in rois) { if (!roi.IsVisible) continue; switch (roi) { case RectangleRoi r: CvRect b = ToCvRect(r.GetBounds()); Cv2.Rectangle(mask, b, Scalar.All(255), -1); // -1 = 填充 break; case CircleRoi c: Cv2.Circle(mask, new CvPoint((int)c.Center.X, (int)c.Center.Y), (int)c.Radius, Scalar.All(255), -1); break; case PolygonRoi p: CvPoint[] pts = p.Points.Select(pt => new CvPoint((int)pt.X, (int)pt.Y)).ToArray(); Cv2.FillPoly(mask, new[] { pts }, Scalar.All(255)); // 用 FillPoly 而不是 Polylines break; } } return mask; }System.Windows.Rect和OpenCvSharp.Rect的命名冲突几乎必然会遇到,用using CvRect = ...起别名比每次都写全名清爽。Scalar.All(255)里的 255 是 8UC1 的最大值,传成1得到的是一张在归一化显示下几乎全黑的 mask,本地看没毛病,
本文还有配套的精品资源,点击获取