简介:这是一份面向C# WinForm开发者的图像交互学习资料,聚焦在自定义图像控件上绘制与管理ROI(感兴趣区域)的完整实现思路。资源以UImageWindow控件为核心,该控件继承自UImage,通过ROI基类与List集合统一存储矩形、旋转矩形、圆形等多种形状对象,新增形状只需继承基类即可纳入统一管理,适合需要为视觉软件、标注工具或检测界面搭建可扩展ROI模块的中级开发者参考。压缩包共34个文件,约159KB,其中14个cs源码文件承载控件与各形状类实现,另有resx、resources资源文件、csproj工程文件及exe、config、pdb等编译与配置产物,结构紧凑、便于直接运行调试。目前已有189人学习浏览。读者可从中获得一套可复用的ROI基类设计范式、集合式管理方案以及自定义控件扩展方法,理解如何将绘制、选中与形状管理解耦,并据此快速移植到自己的图像项目中。
1. 从一次产线标注工具的返工说起:C# WinForm ROI 自定义控件到底解决什么问题
去年帮一个做视觉检测的朋友改工具,他们原本用 PictureBox 加鼠标事件硬画矩形,结果客户要求加旋转矩形和圆形 ROI,代码直接崩成一团——鼠标按下、移动、抬起三个事件里塞了七八个状态标志位,改一处崩三处。这种场景在 C# WinForm 的机器视觉、图像标注、医疗影像工具里太常见了:ROI(Region of Interest,感兴趣区域)看着简单,真要做到矩形、旋转矩形、圆形、多边形都能画、能拖、能缩放、能序列化,没有一套清晰的自定义控件架构,后期维护就是灾难。这份资源给的正是一套完整的 C# WinForm ROI 绘制管理实现,把交互逻辑、图形模型、命中检测、坐标变换拆成独立层次,适合正在做标注工具、检测软件、图像处理 Demo 的开发者直接落地或二次改造。下面按「架构怎么分 → 每种 ROI 怎么画 → 坑在哪 → 怎么验证」的顺序拆开讲。
2. ROI 控件的分层架构:为什么不能把绘制逻辑塞进鼠标事件
2.1 三层拆分:图形模型、交互控制器、渲染层
很多人上手就把所有逻辑写进MouseDown/MouseMove/MouseUp,短期能跑,一旦要支持撤销、序列化、多选就全乱。合理的做法是拆三层:
- 图形模型层:每个 ROI 是一个独立对象,持有自己的几何数据(矩形用左上右下,旋转矩形用中心点+宽高+角度,圆形用圆心+半径),提供
Contains(Point)、GetBounds()、Serialize()等方法。 - 交互控制器层:管理当前状态机(空闲 / 绘制中 / 拖拽中 / 缩放中),把鼠标事件翻译成对图形模型的操作,不碰任何绘制代码。
- 渲染层:只负责把图形模型画到
Graphics上,画手柄、画选中框、画辅助线。
这样拆的好处是:加一种新 ROI 形状,只需要新增一个模型类 + 在控制器里注册,渲染层几乎不用动。
2.2 图形模型的基类设计
先定义一个抽象基类,把公共能力收口:
public abstract class RoiShape { public Guid Id { get; } = Guid.NewGuid(); public bool IsSelected { get; set; } public Color StrokeColor { get; set; } = Color.Lime; public float StrokeWidth { get; set; } = 1.5f; // 命中检测:判断某个点是否落在图形内部或边缘 public abstract bool Contains(PointF pt, float tolerance = 3f); // 获取包围盒,用于选中框和序列化 public abstract RectangleF GetBounds(); // 绘制自身 public abstract void Draw(Graphics g); // 绘制控制手柄(选中时显示) public abstract void DrawHandles(Graphics g); // 拖拽整体位移 public abstract void Translate(float dx, float dy); // 序列化为可存储的键值对 public abstract Dictionary<string, float> Serialize(); }这里几个设计点值得说清楚。Contains带tolerance参数是因为鼠标点击很难精确落在 1px 的边框上,通常给 3~5px 容差,否则用户会觉得「点不中」。GetBounds返回RectangleF而不是Rectangle,因为旋转矩形的包围盒往往是非整数坐标。Serialize返回字典而不是直接写 JSON,是为了让上层决定存储格式,模型层不依赖任何序列化库。
2.3 交互控制器的状态机
控制器是整个控件的大脑,核心是一个枚举状态加当前操作对象:
public enum RoiEditMode { None, Rectangle, RotatedRect, Circle, Polygon } public enum InteractionState { Idle, Drawing, Moving, Resizing, Rotating } public class RoiController { private readonly List<RoiShape> _shapes = new(); private RoiShape _activeShape; private InteractionState _state = InteractionState.Idle; private PointF _lastMouse; private int _activeHandleIndex = -1; public RoiEditMode CurrentMode { get; set; } = RoiEditMode.Rectangle; public void OnMouseDown(PointF pt, MouseButtons button) { if (button == MouseButtons.Right) { // 右键取消当前绘制 if (_state == InteractionState.Drawing) CancelDrawing(); return; } // 先做命中检测,从后往前(后画的在上层) for (int i = _shapes.Count - 1; i >= 0; i--) { var handleIdx = HitTestHandle(_shapes[i], pt); if (handleIdx >= 0) { _activeShape = _shapes[i]; _activeHandleIndex = handleIdx; _state = InteractionState.Resizing; _lastMouse = pt; return; } if (_shapes[i].Contains(pt)) { _activeShape = _shapes[i]; _state = InteractionState.Moving; _lastMouse = pt; SelectOnly(_shapes[i]); return; } } // 没命中任何图形,开始新建 _activeShape = CreateShape(CurrentMode, pt); if (_activeShape != null) { _shapes.Add(_activeShape); _state = InteractionState.Drawing; _lastMouse = pt; } } }HitTestHandle优先于Contains判断,是因为手柄通常画在图形边缘外侧,如果先判断Contains,用户拖手柄时会变成拖整体。这个顺序搞反是新手最常见的翻车点之一。
2.4 渲染与双缓冲
WinForm 的Panel或PictureBox默认绘制会闪烁,必须开双缓冲。如果继承Control自绘,在构造函数里设置:
public class RoiCanvas : Control { public RoiCanvas() { SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint | ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true); DoubleBuffered = true; } protected override void OnPaint(PaintEventArgs e) { e.Graphics.SmoothingMode = SmoothingMode.AntiAlias; e.Graphics.InterpolationMode = InterpolationMode.HighQualityBicubic; // 先画背景图(如果有) if (BackgroundImage != null) e.Graphics.DrawImage(BackgroundImage, ClientRectangle); // 再画所有 ROI foreach (var shape in _controller.Shapes) shape.Draw(e.Graphics); // 最后画选中手柄,保证在最上层 foreach (var shape in _controller.Shapes.Where(s => s.IsSelected)) shape.DrawHandles(e.Graphics); } }SmoothingMode.AntiAlias让斜线和圆形边缘不锯齿,但会略微增加绘制开销。如果 ROI 数量超过几百个,可以考虑只在选中图形上开抗锯齿,其余用默认模式。ResizeRedraw保证窗口缩放时重绘,否则拉伸窗口后 ROI 会残留。
3. 四种 ROI 的绘制实现:矩形、旋转矩形、圆形、多边形的关键代码
3.1 普通矩形:从 MouseDown 到 MouseUp 的坐标归一化
矩形看着最简单,但坐标归一化不做,从右下往左上拖就会画出负宽高的矩形,后续所有计算全错。
public class RectangleRoi : RoiShape { public PointF Start { get; set; } public PointF End { get; set; } public RectangleF Rect { get { float x = Math.Min(Start.X, End.X); float y = Math.Min(Start.Y, End.Y); float w = Math.Abs(End.X - Start.X); float h = Math.Abs(End.Y - Start.Y); return new RectangleF(x, y, w, h); } } public override bool Contains(PointF pt, float tolerance = 3f) { var r = Rect; // 边缘容差判断:点在矩形边框附近也算命中 var outer = new RectangleF(r.X - tolerance, r.Y - tolerance, r.Width + tolerance * 2, r.Height + tolerance * 2); var inner = new RectangleF(r.X + tolerance, r.Y + tolerance, r.Width - tolerance * 2, r.Height - tolerance * 2); return outer.Contains(pt) && !inner.Contains(pt); } public override void Draw(Graphics g) { using var pen = new Pen(StrokeColor, StrokeWidth); g.DrawRectangle(pen, Rect.X, Rect.Y, Rect.Width, Rect.Height); } }Contains这里用的是「边框命中」而不是「区域命中」,因为 ROI 工具通常希望点击内部是选中整体,点击边框是准备缩放。如果业务需要点击内部也算命中,把inner判断去掉即可。这个取舍取决于你的交互设计,没有绝对对错,但要在文档里写清楚。
3.2 旋转矩形:中心点+宽高+角度的表示法与手柄旋转
旋转矩形是坑最多的一个。用四个角点存储会导致浮点误差累积,拖几次之后矩形就变形了。正确做法是用中心点、宽、高、旋转角度四个参数表示:
public class RotatedRectRoi : RoiShape { public PointF Center { get; set; } public float Width { get; set; } public float Height { get; set; } public float Angle { get; set; } // 弧度,逆时针为正 // 获取四个角点(世界坐标) public PointF[] GetCorners() { float hw = Width / 2f, hh = Height / 2f; float cos = (float)Math.Cos(Angle); float sin = (float)Math.Sin(Angle); PointF Rotate(float x, float y) => new PointF( Center.X + x * cos - y * sin, Center.Y + x * sin + y * cos); return new[] { Rotate(-hw, -hh), // 左上 Rotate( hw, -hh), // 右上 Rotate( hw, hh), // 右下 Rotate(-hw, hh) // 左下 }; } public override bool Contains(PointF pt, float tolerance = 3f) { // 把点反向旋转到矩形本地坐标系,再判断是否在矩形内 float dx = pt.X - Center.X; float dy = pt.Y - Center.Y; float cos = (float)Math.Cos(-Angle); float sin = (float)Math.Sin(-Angle); float localX = dx * cos - dy * sin; float localY = dx * sin + dy * cos; return Math.Abs(localX) <= Width / 2f + tolerance && Math.Abs(localY) <= Height / 2f + tolerance; } public override void Draw(Graphics g) { var corners = GetCorners(); using var pen = new Pen(StrokeColor, StrokeWidth); g.DrawPolygon(pen, corners); } }命中检测用「反向旋转到本地坐标系」是标准做法,比逐边判断快且不易出错。旋转手柄通常放在矩形某条边的中点外侧,拖拽时计算鼠标相对于中心点的角度变化量,累加到Angle上。注意角度要归一化到[0, 2π),否则长时间旋转后浮点数会溢出精度。
3.3 圆形:圆心半径表示与椭圆退化的边界
圆形 ROI 用圆心加半径表示,但要注意用户可能拖出一个椭圆。如果业务只允许正圆,在MouseMove里取max(|dx|, |dy|)作为半径;如果允许椭圆,就存两个半径。
public class CircleRoi : RoiShape { public PointF Center { get; set; } public float RadiusX { get; set; } public float RadiusY { get; set; } public override bool Contains(PointF pt, float tolerance = 3f) { if (RadiusX <= 0 || RadiusY <= 0) return false; float dx = (pt.X - Center.X) / (RadiusX + tolerance); float dy = (pt.Y - Center.Y) / (RadiusY + tolerance); return dx * dx + dy * dy <= 1f; } public override void Draw(Graphics g) { using var pen = new Pen(StrokeColor, StrokeWidth); g.DrawEllipse(pen, Center.X - RadiusX, Center.Y - RadiusY, RadiusX * 2, RadiusY * 2); } }Contains里除以(RadiusX + tolerance)而不是先算距离再比较,是为了同时支持椭圆和容差,避免开根号。当RadiusX或RadiusY为 0 时直接返回 false,防止除零。这个边界在用户快速点击(没拖动)时会触发,不加判断就是崩溃。
3.4 多边形:动态顶点列表与闭合判断
多边形 ROI 的交互和其他三种不同:需要多次点击添加顶点,双击或回车闭合。顶点列表用List<PointF>存储,绘制时用DrawPolygon,命中检测用射线法。
public class PolygonRoi : RoiShape { public List<PointF> Points { get; } = new(); public bool IsClosed { get; set; } public override bool Contains(PointF pt, float tolerance = 3f) { if (Points.Count < 3 || !IsClosed) return false; // 射线法:从点向右发射水平射线,统计与多边形边的交点数 bool inside = false; for (int i = 0, j = Points.Count - 1; i < Points.Count; j = i++) { var pi = Points[i]; var pj = Points[j]; if ((pi.Y > pt.Y) != (pj.Y > pt.Y) && pt.X < (pj.X - pi.X) * (pt.Y - pi.Y) / (pj.Y - pi.Y) + pi.X) { inside = !inside; } } return inside; } public override void Draw(Graphics g) { if (Points.Count < 2) return; using var pen = new Pen(StrokeColor, StrokeWidth); if (IsClosed) g.DrawPolygon(pen, Points.ToArray()); else g.DrawLines(pen, Points.ToArray()); } }射线法的边界情况(点正好在顶点上、射线穿过水平边)在浮点坐标下概率极低,但如果你的应用对精度要求高,可以在判断前把坐标 round 到 0.5 像素。多边形不闭合时Contains直接返回 false,避免用户还没画完就被选中。
3.5 序列化与反序列化:把 ROI 存成可复用的配置
工具做完,客户一定会问「能不能保存标注结果」。序列化用前面的Serialize()字典,配合形状类型标识:
public static string SaveToJson(List<RoiShape> shapes) { var list = shapes.Select(s => new { Type = s.GetType().Name, Data = s.Serialize() }); return JsonConvert.SerializeObject(list, Formatting.Indented); }反序列化时根据Type字段分发到对应的构造函数。注意Dictionary<string, float>里的键名要和反序列化代码严格一致,改一个键名就会导致旧文件读不出来。常见做法是在模型类里用const string定义键名,避免手写字符串拼错。
4. 避坑与排查:ROI 控件开发中最容易翻车的五个点
4.1 现象:鼠标拖动时图形抖动或跳变 → 原因:坐标在屏幕空间和图像空间之间混用 → 解决:统一坐标系
如果控件有缩放或平移(比如图像放大 2 倍显示),鼠标坐标是屏幕坐标,而 ROI 存的是图像坐标。不做变换直接混用,拖动时就会跳。正确做法是在控制器入口处把屏幕坐标转成图像坐标,出口处再转回去。变换矩阵用Matrix类维护,缩放和平移都走同一个矩阵,不要手动乘除。
4.2 现象:选中手柄点不中,或者点手柄变成了拖整体 → 原因:命中检测顺序错误 → 解决:先手柄后图形
前面提过,HitTestHandle必须在Contains之前调用。另外手柄的命中区域要比视觉尺寸大,通常视觉画 6x6 像素,命中判断用 10x10。手柄索引要和DrawHandles里的绘制顺序严格对应,否则会出现「点左上角手柄,右下角动了」的玄学问题。
4.3 现象:旋转矩形拖几次后角度漂移 → 原因:角度累加没有归一化 → 解决:每次更新后取模
Angle在拖拽旋转时不断累加,浮点数精度有限,转几十圈后Angle可能变成 1000 多弧度,Math.Cos的精度下降。每次更新后执行Angle = Angle % (2 * Mathf.PI),如果为负再加2π。这个操作开销极小,但能避免长时间使用后的精度问题。
4.4 现象:ROI 数量多了之后界面卡顿 → 原因:每帧重绘所有图形且都开抗锯齿 → 解决:脏矩形重绘或分层绘制
如果 ROI 超过 200 个,全量重绘会明显掉帧。常见做法是把 ROI 画到一个离屏Bitmap上,只有增删改时才重绘离屏图,鼠标移动时只重绘当前操作的那个图形。WinForm 里可以用BufferedGraphics或者手动管理Bitmap。另一个简单优化是只对选中图形开AntiAlias,其余用None。
4.5 现象:反序列化旧文件报错或图形错位 → 原因:序列化键名或坐标系版本变更 → 解决:加版本号字段
在 JSON 根对象里加一个Version字段,反序列化时先读版本号,不同版本走不同的解析分支。键名变更时保留旧键的读取逻辑,新键只用于写入。这个习惯在工具交付后客户换版本时能省大量沟通成本。
5. 验证与进阶:用单元测试锁住几何计算,用命中优先级提升手感
几何计算是 ROI 控件里最容易出隐蔽 bug 的地方,而且靠手点很难覆盖全。我一般会给每个形状类写一组单元测试,重点测Contains和GetBounds的边界情况。比如旋转矩形,测四个角点稍微外扩一点应该返回 false,稍微内缩应该返回 true,角度为 0、90、180、270 度时结果要一致。圆形测半径为零、鼠标正好在圆心、正好在圆周上。多边形测凹多边形和自相交多边形(自相交时射线法结果不确定,业务上应该禁止自相交,在添加顶点时做检测)。
[TestMethod] public void RotatedRect_Contains_CornerOutside_ReturnsFalse() { var roi = new RotatedRectRoi { Center = new PointF(100, 100), Width = 80, Height = 40, Angle = (float)(Math.PI / 4) // 45度 }; // 角点外侧 5 像素,应该不命中 var corners = roi.GetCorners(); var outside = new PointF(corners[0].X - 5, corners[0].Y - 5); Assert.IsFalse(roi.Contains(outside, tolerance: 2f)); // 中心点应该命中 Assert.IsTrue(roi.Contains(new PointF(100, 100))); }另一个提升手感的关键是命中优先级。当多个 ROI 重叠时,应该优先选中「手柄被点中」的那个,其次是「面积最小」的那个,最后才是「最后添加」的那个。面积小的优先是因为小图形容易被大图形盖住,用户点不到会很烦躁。这个策略在控制器里用一个排序委托实现,几行代码就能明显改善体验。
还有一个实际项目里总结的习惯:每次改完几何计算相关代码,先跑一遍单元测试,再手动拖二十次旋转矩形,确认角度和位置没有累积误差。这个流程看着笨,但比出了 bug 再回头查省时间得多。希望帮到你。
本文还有配套的精品资源,点击获取