简介:面向C# WinForm开发者的ROI绘制与管理示例,解决在自定义图像控件上交互式绘制矩形、旋转矩形、圆形等区域并统一存储的问题。代码采用ROI基类加List 集合的方式组织,所有形状对象创建后统一加入集合即可完成管理;矩形、旋转矩形和圆形均以独立类实现各自的绘制与命中逻辑。如需新增形状类型,继承ROI基类实现即可,扩展性良好。压缩包共34个文件,以14个C#源文件为核心,涵盖UImageWindow自定义图像窗体控件、多种形状实现类、主窗体示例及程序入口,配套resx/resources资源文件、exe可执行文件、config配置和pdb调试符号等,整包约159KB,结构紧凑便于直接参考或嵌入现有项目。项目内按控件层、ROI封装、示例窗体分层组织,且包含可运行的exe程序,便于直观查看演示效果。目前已有185人学习下载,适合具备WinForm基础、想了解自定义控件与图形交互管理的初中级开发者。通过示例可快速掌握ROI绘制、存储与扩展的关键写法,为图像标注、视觉定位等场景提供可直接套用的交互框架。 做视觉上位机开发的朋友应该都有体会,ROI(Region of Interest,感兴趣区域)工具几乎是所有检测软件里绕不开的基础功能。不管是做缺陷检测、尺寸测量还是定位引导,第一步永远是让用户在图像上框出要处理的区域。我第一次在C# WinForms里自己做这个功能的时候,也踩了不少坑,尤其是旋转矩形这一块,牵扯到坐标变换、鼠标交互和GDI+渲染的配合,远比想象中琐碎。这篇就把我实现矩形、旋转矩形、圆形ROI绘制管理的完整思路和关键代码整理出来,给正在做类似功能的朋友一个参考。
1. 需求拆解与整体设计思路
1.1 为什么需要自研ROI工具
很多人在做C#上位机或者视觉项目时,第一反应是找现成控件。工业相机SDK自带的有些简单矩形框选,VisionPro、Halcon这类商业库虽然有成熟的ROI交互工具,但一方面授权费用不低,另一方面自定义程度受限——比如客户要求ROI能手动旋转,或者要限定拖动范围,用别人的库往往要绕很多弯。
自己实现ROI绘制管理的好处很直接:完全控制交互逻辑,想怎么扩展都行。而且整个功能的核心并不复杂,无非是数据结构定义、命中测试、鼠标交互、GDI+绘制这几块,工程量大但技术天花板不高,非常适合作为自研工具沉淀下来。
1.2 数据结构选型:基类加派生类的架构
ROI类型虽然多,但共性很强:都有位置、尺寸、选中状态、绘制状态。我建议直接在基类里把这些公共成员定义好,后面所有ROI类型继承它。
public abstract class RoiBase { public string Name { get; set; } public bool IsSelected { get; set; } public bool IsVisible { get; set; } = true; public bool IsDrawing { get; set; } public Color StrokeColor { get; set; } = Color.LimeGreen; public float StrokeWidth { get; set; } = 1.5f; public abstract bool HitTest(PointF pt, float tolerance); public abstract void Draw(Graphics g); public abstract RectangleF GetBounds(); public abstract void Move(PointF offset); public abstract void Transform(Matrix matrix); }这个基类设计的核心思想是“面向接口编程”。上层只跟RoiBase打交道,不管新增圆形、椭圆还是多边形,交互层代码基本不用动。我后来实际开发中还加过Transform(Matrix)方法,用途是当图片缩放或平移时,ROI坐标能跟着一起变换,这个后面细说。
1.3 旋转矩形才是真正的分水岭
普通矩形和圆形实现起来没什么挑战,RectangleF和Graphics.DrawEllipse就搞定了。旋转矩形的难点在于:标准的RectangleF不支持角度,而视觉项目里ROI带角度几乎是刚需——比如芯片引脚检测时,ROI需要顺着引脚方向倾斜。
旋转矩形的本质是:用一个中心点、一个宽度、一个高度、一个角度来描述一个区域。角度值代表矩形绕中心点旋转的角度。理解了这一点,后续所有计算都围绕这个模型展开,就不会被绕晕。
2. 核心数据结构与数学原理
2.1 旋转矩形的坐标计算
旋转矩形的四个顶点是后续所有操作的基础。计算方式其实很简单:先把矩形的四个角点看作相对中心点的偏移量,然后套旋转矩阵公式,最后加上中心点坐标。
public class RoiRotatedRectangle : RoiBase { public PointF Center { get; set; } public float Width { get; set; } public float Height { get; set; } public float Angle { get; set; } // 单位:度,顺时针为正 public PointF[] GetCornerPoints() { float halfW = Width / 2f; float halfH = Height / 2f; float rad = Angle * MathF.PI / 180f; float cos = MathF.Cos(rad); float sin = MathF.Sin(rad); // 相对中心点的四个偏移(未旋转) float[] dx = { -halfW, halfW, halfW, -halfW }; float[] dy = { -halfH, -halfH, halfH, halfH }; PointF[] corners = new PointF[4]; for (int i = 0; i < 4; i++) { float x = dx[i] * cos - dy[i] * sin + Center.X; float y = dx[i] * sin + dy[i] * cos + Center.Y; corners[i] = new PointF(x, y); } return corners; } }这里有个细节必须提醒:GDI+旋转坐标系的公式中,如果Angle为正值,实际是顺时针旋转(因为WinForms的Y轴向下)。如果你需要逆时针为正,就传负角度或者自己调整公式符号。不同项目习惯不一样,我建议在ROI数据类里统一注释清楚,否则后面交接给算法工程师时容易出偏差。
2.2 命中测试:怎么判断点落在ROI内
绘制归绘制,点击选中也要可靠。旋转矩形的命中测试如果自己写数学公式,要判断点是否在一个旋转后的矩形内,最稳的方案不是去解不等式,而是直接利用GDI+的GraphicsPath。
public override bool HitTest(PointF pt, float tolerance) { using (GraphicsPath path = new GraphicsPath()) { PointF[] corners = GetCornerPoints(); path.AddPolygon(corners); return path.IsVisible(pt) || IsNearEdge(path, pt, tolerance); } } private bool IsNearEdge(GraphicsPath path, PointF pt, float tolerance) { using (Pen pen = new Pen(Color.Black, tolerance * 2)) { return path.IsOutlineVisible(pt, pen); } }GraphicsPath.IsOutlineVisible这个方法很实用,它可以直接判断点是否落在路径轮廓线的附近,省去了自己计算点到线段距离的麻烦。配合容差值,我可以实现“点击ROI边缘也能选中”的效果,比单纯判断点是否在内部好用得多。
2.3 圆形和普通矩形的数据结构
圆形用Center加Radius表示,普通矩形用RectangleF表示。虽然简单,但建议同样实现统一的接口方法,别偷懒。
public class RoiCircle : RoiBase { public PointF Center { get; set; } public float Radius { get; set; } public override bool HitTest(PointF pt, float tolerance) { float dist = MathF.Sqrt(MathF.Pow(pt.X - Center.X, 2) + MathF.Pow(pt.Y - Center.Y, 2)); return dist <= Radius + tolerance || MathF.Abs(dist - Radius) <= tolerance; } }这里的命中测试我同时处理了“点击内部”和“点击边缘”两种情况,实际使用中体验比较好。
3. 绘制与交互实现细节
3.1 为什么要用双缓冲
WinForms里直接在Paint事件里画图,连续刷新时很容易闪屏。ROI拖动过程中,每帧都要重绘图像和ROI,闪屏问题会被无限放大。解决办法就是双缓冲:在内存中先画好整幅画面,再一次DrawImage到控件上。
BufferedGraphicsContext context = BufferedGraphicsManager.Current; using (BufferedGraphics buffer = context.Allocate(e.Graphics, this.ClientRectangle)) { Graphics g = buffer.Graphics; // 先绘制图像,再绘制ROI DrawImage(g); DrawAllRois(g); buffer.Render(e.Graphics); }实际项目中,如果图像比较大(比如500万像素的工业相机图),直接双缓冲每一帧都重新绘制整张图,性能会成为瓶颈。我常用的策略是:图像层用缓存好的Bitmap,ROI层单独绘制,两层合成后再上屏。这样拖动ROI时只重绘ROI层,画面流畅度有明显提升。
注意:
BufferedGraphics用完后一定要释放,否则内存占用会持续上升。using是基本操作。
3.2 鼠标交互状态机
ROI交互的核心是状态机。按下、移动、抬起三个事件里,必须清楚地知道当前处于什么状态,否则拖动、缩放、旋转会互相打架。
我定义了一个枚举:
public enum RoiInteractState { None, Dragging, Resizing, Rotating }交互逻辑大致如下:
MouseDown:先做命中测试。命中ROI内部 → 进入Dragging状态;命中角点手柄 → 进入Resizing状态;命中旋转手柄(比如顶部中间的小圆点) → 进入Rotating状态。MouseMove:根据当前状态执行对应的计算并更新ROI参数。MouseUp:重置状态,触发射击后的最终确认逻辑。
这里有个关键点:MouseDown时,要先判断“是否点击了手柄”,再判断“是否点击了ROI内部”。因为手柄通常在ROI边缘或特殊位置,如果在ROI内部点击却命中了手柄,应该优先执行手柄操作。
3.3 拖动与缩放的数学处理
拖动最简单——把鼠标移动的偏移量加到ROI中心上即可。
缩放的难点在于旋转矩形:当矩形有角度时,用户拖动角点时,不是简单改宽高就行。角点移动时,要反解出宽高变化。
我的做法是:以角点相对中心点的距离作为缩放参考。拖动某个角点时,计算该角点在旋转后的坐标系中的位置变化,再反算出新的宽高。具体思路如下:
// 将鼠标点变换到ROI局部坐标系(未旋转的坐标系) public PointF ToLocalCoordinates(PointF globalPoint) { float rad = -Angle * MathF.PI / 180f; float cos = MathF.Cos(rad); float sin = MathF.Sin(rad); float dx = globalPoint.X - Center.X; float dy = globalPoint.Y - Center.Y; return new PointF(dx * cos - dy * sin, dx * sin + dy * cos); }把鼠标点转成局部坐标后,宽高计算就退化成了普通矩形缩放逻辑,对用户来说直观,对代码来说也好维护。
旋转操作更直接:在Rotating状态下,计算鼠标位置与中心点的连线角度,将ROI角度更新为连线角度与初始角度的差值即可。
3.4 GDI+绘制:旋转矩形的实现
绘制旋转矩形时,很多人习惯用Graphics的RotateTransform,但这有个坑——它会改变整个画布坐标系,如果同时绘制文本、标注、其他ROI,很容易乱套。我的建议是直接手动画四边形,不进RotateTransform。
public override void Draw(Graphics g) { if (!IsVisible) return; PointF[] corners = GetCornerPoints(); using (Pen pen = new Pen(StrokeColor, StrokeWidth)) { g.DrawPolygon(pen, corners); } if (IsSelected) { DrawHandles(g, corners); } }这样绘制的好处是:坐标系全局统一,后续要做坐标转换、图像缩放叠加都很方便。绘制手柄时,我在角点画实心小方块,在顶部中间画一个小圆圈,作为旋转手柄。这两个手柄就提供了缩放和旋转的交互入口。
4. 实操过程:完整实现流程与代码骨架
4.1 工具类整体框架与依赖关系
我把ROI管理类封装成一个RoiManager,负责ROI的增删、选中、序列化、绘制,以及将输入设备的鼠标事件转发给对应的ROI对象。核心依赖如下:
public class RoiManager { private List<RoiBase> _rois = new List<RoiBase>(); private RoiBase _selectedRoi; private RoiInteractState _state = RoiInteractState.None; private PointF _lastMousePos; private int _resizeHandleIndex = -1; public void AddRoi(RoiBase roi) { ... } public void RemoveSelectedRoi() { ... } public void ClearAll() { ... } public void OnMouseDown(PointF pt) { ... } public void OnMouseMove(PointF pt) { ... } public void OnMouseUp() { ... } public void DrawAll(Graphics g) { ... } }这个管理器不依赖具体控件,把鼠标坐标传进去就行。这样在PictureBox里能用,在自定义控件里也能用,将来如果界面重构,逻辑层几乎不用动。
4.2 交互代码的骨架实现
我贴一下核心交互代码,帮你快速搭起框架:
public void OnMouseDown(PointF pt) { // 倒序遍历,让最上层的ROI先命中 for (int i = _rois.Count - 1; i >= 0; i--) { RoiBase roi = _rois[i]; _selectedRoi = roi; if (roi is RoiRotatedRectangle rr) { PointF[] corners = rr.GetCornerPoints(); // 命中角点 → Resizing for (int j = 0; j < corners.Length; j++) { if (Distance(pt, corners[j]) < HandleSize) { _state = RoiInteractState.Resizing; _resizeHandleIndex = j; return; } } // 命中顶部旋转手柄 → Rotating PointF rotHandle = GetRotateHandlePoint(rr); if (Distance(pt, rotHandle) < HandleSize) { _state = RoiInteractState.Rotating; return; } } if (roi.HitTest(pt, 5f)) { _state = RoiInteractState.Dragging; return; } } _selectedRoi = null; } public void OnMouseMove(PointF pt) { if (_selectedRoi == null) return; switch (_state) { case RoiInteractState.Dragging: PointF offset = new PointF(pt.X - _lastMousePos.X, pt.Y - _lastMousePos.Y); _selectedRoi.Move(offset); break; case RoiInteractState.Resizing: // 根据拖动角点反算宽高 break; case RoiInteractState.Rotating: // 计算角度差 break; } _lastMousePos = pt; }这段代码的意图很明确:优先判断手柄命中,其次是ROI内部。倒序遍历的原因是多ROI重叠时,后面添加的ROI应该在视觉上位于上层,鼠标点击时也应该优先命中它。
4.3 旋转手柄绘制与角度计算
旋转手柄的画法很简单,在顶部边中点画一个空心小圆即可:
private PointF GetRotateHandlePoint(RoiRotatedRectangle rr) { PointF[] corners = rr.GetCornerPoints(); return new PointF((corners[0].X + corners[1].X) / 2f, (corners[0].Y + corners[1].Y) / 2f); }角度更新的核心逻辑如下:
case RoiInteractState.Rotating: if (_selectedRoi is RoiRotatedRectangle rr) { float angle = MathF.Atan2(pt.Y - rr.Center.Y, pt.X - rr.Center.X) * 180f / MathF.PI; rr.Angle = angle + 90f; // 因为旋转手柄在顶部,要加90度修正 } break;这里加90度是因为Atan2算出来的角度是以X轴正方向为0度的,而旋转手柄在矩形顶部,也就是Y轴负方向(屏幕坐标系),所以需要做角度修正。第一次实现时我在这卡了很久,直觉上怎么调都不对,后来画了张图才领会到。
4.4 坐标映射:图片缩放时ROI如何同步
实际项目中,图像往往不会一比一显示在PictureBox里,会涉及缩放和居中。ROI坐标是图像坐标,点击事件拿到的是控件坐标,两者之间必须做换算。
public PointF ImageToControl(PointF imgPt, RectangleF displayRect, float scale) { return new PointF(displayRect.X + imgPt.X * scale, displayRect.Y + imgPt.Y * scale); } public PointF ControlToImage(PointF ctlPt, RectangleF displayRect, float scale) { return new PointF((ctlPt.X - displayRect.X) / scale, (ctlPt.Y - displayRect.Y) / scale); }在OnMouseDown和OnMouseMove里,统一先把控件坐标转成图像坐标,再交给RoiManager处理。绘制时再把图像坐标转回控件坐标。我封装好这两个函数后,交互层和渲染层都非常清爽,不用于每个事件里重复写转换公式。
4.5 ROI的序列化保存
ROI参数最终要保存成文件,方便下次加载。我通常直接把ROI对象序列化成JSON:
public string SerializeRois() { var list = _rois.Select(roi => { if (roi is RoiRotatedRectangle rr) return new { Type = "RotatedRectangle", rr.Center.X, rr.Center.Y, rr.Width, rr.Height, rr.Angle }; if (roi is RoiCircle c) return new { Type = "Circle", c.Center.X, c.Center.Y, c.Radius }; if (roi is RoiRectangle r) return new { Type = "Rectangle", X = r.Rect.X, Y = r.Rect.Y, r.Rect.Width, r.Rect.Height }; return null; }).ToList(); return JsonSerializer.Serialize(list); }反序列化时根据Type字段创建对应类型的实例。这里有个小建议:序列化时把坐标系标记带上,比如“图像坐标系原点在左上角,Y轴向下”。不同的图像处理库坐标系定义不一样,以后对接算法模块时能省去很多扯皮的麻烦。
5. 常见问题与排查技巧实录
5.1 拖动时画面闪烁严重
前面提过双缓冲,但很多人用了还是会闪。排查顺序建议是:
- 是否在
Paint中每次都重新绘制大图? - 是否设置了
DoubleBuffered = true? - 是否过度调用了
Invalidate()?
我实测下来,一个关键优化是避免在MouseMove里直接Invalidate整个控件。只调用ROI所在区域的Invalidate(region),刷新的范围小了,闪烁感自然就下降了。
5.2 旋转矩形的角度方向不对
这个问题十有八九是坐标系Y轴方向造成的。数学课件上Y轴向上,WinForms的屏幕坐标Y轴向下,所以同一个角度公式算出来的方向是反的。解决方法是画参考线验证:在界面上同时画出X轴、Y轴、角度为0度的矩形,慢慢调公式,直到符合预期。
我建议在调试阶段把角度值实时显示在ROI旁边(DrawString),这样拖动旋转时能直观看到角度变化,有利于快速定位问题。
5.3 角点拖拽时ROI突然跳变
这通常是因为MouseDown时没有记录初始状态。缩放的实现依赖“鼠标点相对于ROI中心的初始偏移量”,如果没在MouseDown时保存这个基准值,MouseMove时计算出来的宽高就会跳动。
正确做法是:在MouseDown命中角点时,保存该时刻的ROI宽高和中心点,MouseMove时基于保存的初值计算新宽高,而不是基于上一次的ROI状态推导。这是一个典型的“状态累积误差”问题,排除了很久才想到。
5.4 多ROI重叠时选择错乱
解决办法前面提过——倒序遍历、命中即返回。但还要注意:命中手柄的判断要优先于命中内部的判断。否则用户明明点击了角点,却被内部判断截胡,直接进入拖动状态,缩放手柄就永远点不到。
我建议给每个ROI的角点手柄起一个合理的命中范围,比如HandleSize = 8f像素。太小了不好点,太大了会互相干扰,8到10是比较折中的值。
5.5 旋转矩形边界的视觉平滑
GDI+默认的DrawPolygon抗锯齿效果一般,边角会有明显的锯齿感。在Graphics上设置g.SmoothingMode = SmoothingMode.AntiAlias能明显改善。绘制手柄和旋转小圆时也建议保持这个模式,视觉整体会精致很多。
这个设置虽然基础,但对用户体验影响很大。做图像软件的用户对着屏幕看一天,锯齿感强的界面真的会让人很疲劳。
6. 扩展与建议
ROI功能做好后,还可以顺手扩展一些高频需求。比如:
- 复制粘贴:选中ROI后
Ctrl+C、Ctrl+V复制一份,批量放置同尺寸ROI时非常实用。 - 支持键盘微调:方向键对选中ROI做1像素级移动,精细调位置时比鼠标拖拽精准。
- 显示区域面积或尺寸:绘制完成后直接在ROI旁显示宽高,方便操作人员实时查看。
- 支持自定义绘制回调:让上层传入
Action<Graphics, RoiBase>,比如画十字线、画箭头标注等,工具类不做死。
我个人在实际项目里的体会是:ROI工具看起来简单,但交互细节决定成败。用户在拖动、缩放、旋转时的手感是否顺畅,直接影响了整套软件的专业度评分。在前期多花一些精力把坐标系、状态机、命中测试这些基础做扎实,后面加功能会非常顺手;反过来,如果基础没打好,边写边补逻辑,代码很容易变成一团乱麻。
最后再分享一个小技巧:开发时把ROI的关键参数(中心点、宽高、角度)实时显示在一个调试面板上,对你的联调和问题排查帮助极大。很多光靠眼睛看不出来的问题,看一眼数值变化走势,基本上就能定位到原因。
本文还有配套的精品资源,点击获取