做视觉项目,绕不开“把结果画出来”这一步。目标检测要画矩形框,分割要画轮廓多边形,调试光流要画轨迹线,很多场景还要在画面上加一行说明文字。Emgu CV 的绘制接口就是干这个的:直线、圆形、椭圆、矩形、多边形、文字,全部集中在CvInvoke静态方法和Image扩展方法里。我最早接触 OpenCV 是在 Python 环境,后来转到 C# 用 Emgu CV,头几周几乎每天都被坐标顺序、颜色通道、实心填充这些细节卡住。这篇文章沿着“先搞懂底层约定,再逐个函数实操,最后组装成完整标注工具”这条线写,适合刚入门的读者,也适合已经动手写过几个 Demo、但想把绘制细节彻底搞对的开发者。
先说结论:新项目我建议统一走Mat + CvInvoke这套组合,别再用老旧的Image<,>泛型类做绘制主流程。原因后面会详细讲,这里先记住这个方向。
1. 绘制前的底层准备:坐标系、颜色与画布
1.1 坐标系与点位约定
Emgu CV 里所有绘制接口默认都在图像坐标系上操作:原点在左上角,x 轴向右,y 轴向下。画直线要两个端点,画矩形要左上角点加宽高,画多边形要一串顶点,都是这个坐标系,和你在 WinForms、WPF 里画图的直觉一致。
最容易翻车的地方是Mat构造函数。new Mat(rows, cols, DepthType.Cv8U, 3)里rows是行数,cols是列数,也就是rows对应高度、cols对应宽度。想要 1280×720 的画布,必须写new Mat(720, 1280, ...)。我见过太多人写成new Mat(1280, 720, ...),结果画出来的图像长宽反转,还查了半天找不到原因。这个错误之所以隐蔽,是因为很多绘制操作照样能跑,只有输出尺寸和布局全乱时才会意识到不对劲。
另一个细节是Point类型。CvInvoke里用的点大多是System.Drawing.Point,也就是整型坐标。如果你的坐标来自算法计算,类似 12.6、8.3 这种浮点值,最好先四舍五入再传进去。直接传PointF到接受Point的重载,代码能编译,但小数部分会被静默截断,视觉上偶尔会有 1 像素偏移,排查起来很费神。
1.2 颜色模型:为什么是 Bgr 而不是 Rgb
新手问得最多的问题就是“为什么红色传成 (0,0,255)”。因为 OpenCV 内部约定是 BGR 通道顺序,Emgu CV 继承了这套约定。使用Bgr(blue, green, red)构造,或者直接用底层MCvScalar(b, g, r, a)构造:
new Bgr(0, 0, 255).MCvScalar; // 纯红色 new Bgr(0, 255, 0).MCvScalar; // 纯绿色 new Bgr(255, 0, 0).MCvScalar; // 纯蓝色如果你习惯了 System.Drawing 的Color,脑子里总想直接写Color.Red。我建议不要这样混用,就算代码能写new Bgr(Color.Red)也没必要。一旦你需要同时调三个通道的参数,RGB 思维会让你调试速度慢一半。记住一个动作就行:看到 Bgr,参数顺序就是蓝、绿、红,视觉上的“红色”永远在第三个位置。
提示:
MCvScalar有 4 个分量,最后一个 alpha 通道在普通 3 通道 Mat 上是无效的,绘制时不用管它,统一填 0 就行。
1.3 画布:Mat 还是 Image<Bgr, Byte>
Emgu CV 有两种主流用法:老项目常见的Image<Bgr, Byte>和新版本主推的Mat。Image<,>在做像素级操作、ROI 裁剪时比较直观,但它的底层绕了一层托管包装,很多新接口都要再转一次。绘制这个场景,我推荐直接用Mat加CvInvoke静态方法,理由有三点。
第一,新版本 Emgu CV 的算法接口大多面向IInputArray/IOutputArray,Mat是核心传递类型。第二,CvInvoke的函数签名和 OpenCV C++ 基本一一对应,遇到问题去查 OpenCV 官方文档、搜英文解决方案时,不容易被命名差异绕晕。第三,Mat直接参与InRange、FindContours、BitwiseNot这些操作,不需要在一个绘制流程里反复做类型转换。
创建画布非常简单:
using Emgu.CV; using Emgu.CV.CvEnum; using Emgu.CV.Structure; using System.Drawing; int width = 640; int height = 480; Mat canvas = new Mat(height, width, DepthType.Cv8U, 3); // 行 = 高,列 = 宽 canvas.SetTo(new MCvScalar(0, 0, 0)); // 黑色底也可用一行简化写法:
Mat canvas = Mat.Zeros(height, width, DepthType.Cv8U, 3);Mat.Zeros等价于先new再SetTo全 0,代码更短,我日常写示例都是直接用它。注意参数顺序依然是rows在前、cols在后,和构造函数完全一致。
1.4 CvInvoke 绘制是“原地修改”
CvInvoke.Line、CvInvoke.Rectangle、CvInvoke.Circle这些方法直接修改传入的Mat,不会返回一个新的Mat。这和其他托管类库“渲染出新图层”的习惯很不一样,是最容易踩的坑。
Mat a = canvas.Clone(); // 复制一份 CvInvoke.Rectangle(a, rect, color, 2); // 只改 a,canvas 不受影响如果直接写CvInvoke.Rectangle(canvas, ...),canvas 本身就被改了。想保留原图,必须先把原图Clone一份,再在副本上画。做实时监控画面、同一张图要画多遍标注的时候,这个区别非常关键,否则画第一遍就把原图污染了,后面所有标注都叠在同一层上。
另外要知道Mat本质上是非托管对象的一层托管壳,引用计数由内部管理。你在工具类里做绘制时,最好约定“谁创建 Mat,谁负责 Dispose”,别到处Clone又不管释放。后面第 5 章会专门讲内存问题。
2. 基本图形绘制:直线、矩形、圆、椭圆
2.1 画直线:起终点、粗细和抗锯齿
直线是最基础的绘制元素,代码也最直白:
CvInvoke.Line(canvas, new Point(20, 20), new Point(300, 200), new Bgr(0, 0, 255).MCvScalar, 3, LineType.AntiAlias);参数顺序是:输入 Mat、起点、终点、颜色、线宽、线型。线宽单位是像素,3 就是 3 像素粗。线型LineType.AntiAlias会做抗锯齿,边缘平滑;LineType.EightConnected是 8 连通直线,速度快但边缘能看出锯齿。静态标注图我推荐用抗锯齿,实时视频里如果画线数量特别多,可以退回EightConnected,动态画面上几乎看不出差别。
很多人没注意到签名里还有shift参数,它用来做亚像素绘制。简单理解:坐标为x * (1 << shift),比如想画一条从 (10.5, 20.25) 到 (30.75, 40.5) 的线,shift = 2,就把点写成 (42, 81) 和 (123, 162)。普通标注用不到,但做相机标定叠加网格、精度要求高的定位场景会派上用场。先知道有这回事,等真遇到“直线偏了半个像素”的问题时,能想起来往这个方向排查。
2.2 画矩形:两种写法和标签条
矩形在目标检测场景里用得最多。CvInvoke.Rectangle有两种常用重载,一种给左上角加宽高,一种给两个对角点:
// 左上角 (60, 50),宽 200,高 120 CvInvoke.Rectangle(canvas, new Rectangle(60, 50, 200, 120), new Bgr(0, 255, 0).MCvScalar, 2); // 两个对角点 CvInvoke.Rectangle(canvas, new Point(60, 50), new Point(260, 170), new Bgr(0, 255, 0).MCvScalar, 2, LineType.AntiAlias);注意System.Drawing.Rectangle的 x、y 是左上角,width、height 必须为正数。检测算法输出的框一般是正的,但自己写交互框选时,鼠标从右下往左上拖会出现负宽高,画出来要么报错,要么画出一个错误位置的框。封装绘制函数时,第一步永远是做坐标矫正:如果 width 小于 0,就把 x 加上 width,width 取反;height 同理。
画标签条是个经典需求,但很多人直接在框上写文字,导致文字压在检测框里,可读性很差。正确做法是先画一个实心小矩形作为底条,再把文字放在矩形上面:
Size txtSize = CvInvoke.GetTextSize(label, FontFace.HersheySimplex, 0.6, 1, out int baseline); CvInvoke.Rectangle(canvas, new Rectangle(x, y - txtSize.Height - baseline, txtSize.Width + 6, txtSize.Height + baseline), new Bgr(0, 0, 255).MCvScalar, -1); // 实心底条 CvInvoke.PutText(canvas, label, new Point(x + 3, y - baseline), FontFace.HersheySimplex, 0.6, new Bgr(255, 255, 255).MCvScalar, 1);这里有个细节:GetTextSize返回的是文本宽高,baseline参数是基线到文本底部的距离。PutText的 org 点是文本左下角,不是左上角,所以文字起点要先减去 baseline。很多初学画出来的文字整体偏下、偏上,都是没处理 baseline 造成的。
2.3 画圆形:半径和填充
画圆形就是圆心加半径:
CvInvoke.Circle(canvas, new Point(200, 120), 50, new Bgr(255, 0, 0).MCvScalar, 2);thickness 传 -1 就是填充圆:
CvInvoke.Circle(canvas, new Point(200, 120), 50, new Bgr(255, 0, 0).MCvScalar, -1);半径必须大于 0。如果半径是算法算出来的浮点数,记得先取整再传。填充圆不要觉得难控制,只要按绘制顺序来——先画底色内容,再画填充圆,最后画边框——就能做出“实心圆加外圈边框”的效果。这在关键点检测和热力图标点场景里很实用,比如人脸关键点标注,内圈实心、外圈描边,看得非常清楚。
2.4 画椭圆:理解半轴、角度和弧
椭圆比圆形多三个参数:半轴长、旋转角和弧范围。最常用的重载是这种写法:
CvInvoke.Ellipse(canvas, new Point(320, 240), // 圆心 new Size(120, 60), // 半轴长度,不是直径 30, // 旋转角 0, 360, // 起始角、结束角 new Bgr(0, 0, 255).MCvScalar, 2);这里最容易被误会的是Size(120, 60)。它表示水平半轴 120、垂直半轴 60,所以这个椭圆外接矩形的实际宽度是 240、高度是 120,不是 120×60。旋转角单位是度,0 度是水平方向,正方向在图像坐标系里视觉上看起来像顺时针。起始角和结束角从 0 到 360 是完整椭圆,想画弧线就改这两个值,比如 0 到 180 画上半弧。
检测旋转目标时,MinAreaRect返回的是RotatedRect,它可以直接传给CvInvoke.Ellipse画出旋转椭圆,或者取四个顶点画旋转矩形:
RotatedRect rr = new RotatedRect( new PointF(300, 200), new SizeF(160, 90), 30f); CvInvoke.Ellipse(canvas, rr, new Bgr(255, 0, 0).MCvScalar, 2); // 画旋转矩形的四条边 PointF[] vp = rr.GetVertices(); Point[] p = Array.ConvertAll(vp, t => new Point( (int)Math.Round(t.X), (int)Math.Round(t.Y))); CvInvoke.Polylines(canvas, p, true, new Bgr(0, 255, 0).MCvScalar, 2);这个组合在工业检测里很常用:目标不是水平摆放时,不要自己去算四个顶点或用轴对齐矩形硬套,直接让RotatedRect帮你输出。
3. 多边形与文字:标注里最容易被忽略的细节
3.1 折线闭合:Polylines 的 Point 数组
多边形绘制用CvInvoke.Polylines。它接受一个Point数组,isClosed参数决定首尾是否自动连线:
Point[] quad = new Point[] { new Point(120, 260), new Point(220, 120), new Point(340, 180), new Point(260, 320) }; CvInvoke.Polylines(canvas, quad, true, new Bgr(0, 255, 255).MCvScalar, 2);isClosed传 true 时,会从最后一个点自动连回第一个点;false 就是一条开放的折线,画轨迹、画路径的时候用得上。另一个重载接收Point[][],用来一次画多个多边形。这在画一组 ROI 区域时性能更好,也比反复调用单点数组版本代码干净。
从轮廓数据画图也很简单。FindContours拿到的VectorOfVectorOfPoint,每个元素本身是VectorOfPoint,转成Point[]再传给Polylines即可:
using (VectorOfVectorOfPoint contours = new VectorOfVectorOfPoint()) { CvInvoke.FindContours(gray, contours, null, RetrType.External, ChainApproxMethod.ChainApproxSimple); for (int i = 0; i < contours.Size; i++) { CvInvoke.Polylines(canvas, contours[i].ToArray(), true, new Bgr(0, 255, 0).MCvScalar, 2); } }如果只是想在调试时快速看轮廓,用CvInvoke.DrawContours更省事,但它按轮廓索引上色,适合临时可视化,不适合最终输出。
3.2 填充多边形:FillPoly 与 FillConvexPoly 的区别
填充有两个函数,名字接近但限制完全不同:
// 任意多边形(允许凹、允许非凸) CvInvoke.FillPoly(canvas, new Point[][] { quad }, new Bgr(255, 0, 0).MCvScalar); // 凸多边形,要求所有顶点严格满足凸性 CvInvoke.FillConvexPoly(canvas, quad, new Bgr(255, 0, 0).MCvScalar);理论上FillConvexPoly更快,因为凸多边形的扫描线处理更简单。但如果顶点顺序不对,或者多边形本身是凹的,画出来就是一坨乱线。我的经验是:对算法自动生成的轮廓,无脑用FillPoly;只有手写矩形区域、棋盘格角点这类确定是凸多边形时,才用FillConvexPoly。
还有个很常见的坑:FillPoly的入参是Point[][],很多人第一次用总想传Point[]。Emgu 的签名就是多层数组,因为 OpenCV 的fillPoly本来就是画多个轮廓。哪怕只画一个,也要包一层数组。这个包裹动作很重要,漏掉就是编译错误,看着报错信息一时反应不过来。
3.3 文字标注:PutText 的定位和字体
PutText是所有绘制里最容易上手的,也是最容易出问题的:
CvInvoke.PutText(canvas, "Hello Emgu CV", new Point(30, 100), // 文本左下角 FontFace.HersheySimplex, 1.0, // 字体、缩放系数 new Bgr(0, 255, 255).MCvScalar, 2, LineType.AntiAlias);参数顺序是:图像、字符串、左下角坐标、字体、缩放系数、颜色、线宽、线型。关键点就是 org 是文本左下角。很多人想往画面顶部写标题,写new Point(30, 20),结果文字跑到画面外,就是因为忘了它是“左下角”。想让文字显示在离顶 20 像素的位置,org 的 y 应该写成20 + 文字高度,一般用GetTextSize精确计算,或者估个 35 到 40。
字体用FontFace.HersheySimplex一般够用,但这个字体族不支持中文。往PutText里传中文字符串,常见结果是输出问号、乱码,或者直接不画。这不是编码问题,是字体没有对应的字形,调试再久也没用。
中文标注的正确姿势是绕开 Hershey 字体:先把 Mat 转成System.Drawing.Bitmap,用Graphics.DrawString把中文画上去,再转换回来。示例代码:
static void DrawChineseText(Mat canvas, string text, Point org, Bgr color, float fontSize) { using (Bitmap bmp = canvas.ToBitmap()) { using (Graphics g = Graphics.FromImage(bmp)) using (Font font = new Font("微软雅黑", fontSize, FontStyle.Bold)) using (Brush brush = new SolidBrush(color.ToColor())) { g.DrawString(text, font, brush, org); } using (Mat back = bmp.ToMat()) { back.CopyTo(canvas); } } }注意ToBitmap返回的是当前 Mat 内容的托管拷贝,在Graphics上画完以后必须再转回 Mat 并CopyTo,否则画了等于没画。这套路慢,但稳定。批量离线任务这么干完全没问题;实时预览建议英文数字走PutText,中文只在结果输出阶段用。
4. 综合示例:检测框、标签和轮廓一次画完
4.1 需求场景和设计
单讲函数不够过瘾,我拿一个最常见的综合场景来组装:目标检测模型输出若干个 bounding box 和对应标签,同时给一张分割掩码,你要把矩形框、标签条、轮廓多边形全部打在原图上。这个场景覆盖了矩形、实心矩形、文字、多边形四种操作,可以直接当模板抄。
数据结构约定:boxes是List<Rectangle>,labels是List<string>,mask是单通道灰度图,非零区域代表目标轮廓。
4.2 完整绘制函数
static void DrawAnnotations(Mat frame, List<Rectangle> boxes, List<string> labels, Mat mask) { for (int i = 0; i < boxes.Count; i++) { Rectangle box = boxes[i]; string label = labels[i]; // 画检测框 CvInvoke.Rectangle(frame, box, new Bgr(0, 0, 255).MCvScalar, 2); // 画标签底条:先用 GetTextSize 量出文字尺寸 Size txtSize = CvInvoke.GetTextSize(label, FontFace.HersheySimplex, 0.6, 1, out int baseline); int barHeight = txtSize.Height + baseline + 4; int barY = box.Y - barHeight; if (barY < 0) barY = box.Y + box.Height; // 框太靠上,标签条放到框下方 CvInvoke.Rectangle(frame, new Rectangle(box.X, barY, txtSize.Width + 8, barHeight), new Bgr(0, 0, 255).MCvScalar, -1); // 实心底条 CvInvoke.PutText(frame, label, new Point(box.X + 4, barY + txtSize.Height + 2), FontFace.HersheySimplex, 0.6, new Bgr(255, 255, 255).MCvScalar, 1); } // 根据掩码画轮廓多边形 using (VectorOfVectorOfPoint contours = new VectorOfVectorOfPoint()) { CvInvoke.FindContours(mask, contours, null, RetrType.External, ChainApproxMethod.ChainApproxSimple); for (int i = 0; i < contours.Size; i++) { CvInvoke.Polylines(frame, contours[i].ToArray(), true, new Bgr(0, 255, 0).MCvScalar, 2); } } }这段代码有两个值得说的设计点。第一个是标签条越界处理:检测框贴着图像上边缘时,标签条画上去会被裁剪,所以做了个判断,放不下就挪到框的下方。第二个是实心底条:先用GetTextSize量尺寸,再画一个矩形,文字始终压在矩形顶上,视觉上比文字描边干净很多。
4.3 保存、预览和输出
画完以后要把结果交出去。保存用CvInvoke.Imwrite:
CvInvoke.Imwrite("annotated.png", frame);WinForms 里预览直接用frame.ToBitmap()赋给PictureBox.Image。控制台调试就用Imshow:
CvInvoke.Imshow("result", frame); CvInvoke.WaitKey(0);一个容易被忽略的问题:在循环里连续处理视频帧,每帧画完并输出 Bitmap 后,要给Mat、Bitmap做释放。托管那层看着没问题,非托管那一层会悄悄涨,跑几分钟后程序开始卡顿。我习惯用using包住中间量,主 Mat 不再需要就回收。做长时间运行的程序,这个习惯能帮你避免大量玄学内存问题。
5. 常见问题与排查实录
5.1 颜色完全不对:BGR 和 RGB 混用
画出来该红的变蓝、该蓝的变青,十有八九是颜色通道填反了。检查两个位置:一是new Bgr(...)的参数顺序,二是MCvScalar的构造顺序。如果你是从System.Drawing.Color里拆 RGB 分量再手动填进 Bgr,必踩这个坑。从Color转换到Bgr,最稳的办法是用类型自带的转换,不要手动换位。
5.2 文字画出来是问号、方块
凡是出现问号、方块、空白,第一反应就是字体不支持。Hershey 字体族只支持拉丁字符,中文必须走 3.3 的 Bitmap 方案。补充一点:Graphics.DrawString用的字体要选系统里真实存在的字体名,否则会落到默认字体,有些默认字体连中文都渲染不了。字体大小是浮点数,PutText的 scale 是另一个体系,多试几次找到观感接近的参数即可。
5.3 thickness 传 -1 是填充,传 0 是什么
在 Emgu CV 绘制接口里,封闭图形(矩形、圆、椭圆、多边形)thickness 传 -1,就是填充内部;正整数就是描边。直线没有填充概念,不要传负数。至于 0,OpenCV 老版本有些函数会把 0 当成 1 像素画,新版本有些会直接不画或报错。代码里老老实实用 1 或正整数,别依赖 0 的“隐藏默认行为”。
5.4 多边形画成开口或变形
Polylines的isClosed忘传 true,尾点和起点不连线,视觉上就是一个缺边的多边形。排查方法很简单:看最后一个点和第一个点之间有没有连线。另一种“变形”是FillConvexPoly用在凹多边形上,顶点顺序稍有失误就出现条纹、乱线。记住:FillConvexPoly只对严格凸的图形可靠,凹多边形用FillPoly。
5.5 绘制性能:实时场景怎么优化
如果是在视频流里逐帧画几十个框和文字,性能优化就很重要。第一个优化点:不用抗锯齿,动态画面上边缘锯齿影响很小,但代价是每个像素多算几步。第二个优化点:不要每帧都 newBgr、Rectangle、临时数组,把这些对象提到循环外复用。第三个优化点:纯文字场景,可以先把带文字的透明小图生成好,每帧按位置CopyTo,比每帧调PutText快得多。第四个优化点:设置 ROI。如果只绘制画面局部区域,先把 frame 的 ROI 切出来,在 ROI 上绘制完再恢复 ROI。
提示:
CvInvoke绘制是原地修改,帧处理结束记得释放 Mat。优先using。
5.6 Mat 释放和引用混淆
Mat是非托管对象的一层托管壳,你把多个 Mat 放进List里一直不清,GC 不会主动回收非托管资源,内存就会缓慢增长。我写工具类时约定:谁创建 Mat,谁负责 Dispose;函数返回新 Mat 的地方,调用方负责释放。另外不要到处Clone,能传引用就传引用,能原地画就原地画,减少不必要的拷贝。
我个人的体会是:绘制问题九成不是 API 不会用,而是底层约定没想清楚。下笔之前先问自己三个问题——我是在原图上改还是副本上画?颜色是 Bgr 还是 Rgb?坐标是左上角规则还是搞混了宽高?这三个问题想明白,剩下就是查签名的事。建议把 4.2 的函数抄下来改一改:先画一个矩形,再追加标签条、轮廓、中文,完整跑通一遍。画满一整张图之后,你对这套接口的体感就完全不一样了。