资讯动态

Emgu CV绘制接口实战:Mat坐标系、BGR颜色与检测标注

发布时间:2026/10/18 2:17:16 来源:尧图企业网站定制
做视觉项目绕不开“把结果画出来”这一步。目标检测要画矩形框分割要画轮廓多边形调试光流要画轨迹线很多场景还要在画面上加一行说明文字。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 还是 ImageBgr, ByteEmgu CV 有两种主流用法老项目常见的ImageBgr, Byte和新版本主推的Mat。Image,在做像素级操作、ROI 裁剪时比较直观但它的底层绕了一层托管包装很多新接口都要再转一次。绘制这个场景我推荐直接用Mat加CvInvoke静态方法理由有三点。第一新版本 Emgu CV 的算法接口大多面向IInputArray/IOutputArrayMat是核心传递类型。第二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); // 只改 acanvas 不受影响如果直接写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 加上 widthwidth 取反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是ListRectanglelabels是Liststringmask是单通道灰度图非零区域代表目标轮廓。4.2 完整绘制函数static void DrawAnnotations(Mat frame, ListRectangle boxes, Liststring 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.ImwriteCvInvoke.Imwrite(annotated.png, frame);WinForms 里预览直接用frame.ToBitmap()赋给PictureBox.Image。控制台调试就用ImshowCvInvoke.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就是填充内部正整数就是描边。直线没有填充概念不要传负数。至于 0OpenCV 老版本有些函数会把 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 的函数抄下来改一改先画一个矩形再追加标签条、轮廓、中文完整跑通一遍。画满一整张图之后你对这套接口的体感就完全不一样了。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑