资讯动态

OpenCV入门实战:绘制三角形并计算三个内角(附完整代码)

发布时间:2026/10/15 8:37:42 来源:尧图企业网站定制
初看这个项目标题我猜很多人第一反应是“画个三角形算什么cv2.line敲三行代码的事”。但真正做下去你会发现这个小项目几乎是OpenCV入门阶段性价比最高的一次练习绘图API、图像坐标系、鼠标交互、几何学换算、字符绘制全都能塞进这几十行代码里。尤其是“计算3个角的大小”这一步远比你想的容易踩坑。坐标原点在左上角、y轴向下、图像坐标系和数学坐标系不是一回事这些基础认知不梳理清楚角度算出来就是错的而且你还不知道错在哪。这篇文章我会按照我实际动手时的思路走一遍先拆解项目里真正要掌握的知识点再把角度计算的数学原理讲透最后给一份能直接跑的完整代码附带我实测踩过的坑。无论你是刚接触OpenCV还是已经会画图但没认真算过几何量这篇应该都能给你点东西。1. 项目拆解一个三角形背后藏着哪些OpenCV基本功1.1 为什么选“画三角形算角度”作为练手项目我见过不少OpenCV初学者上来就啃人脸识别、目标跟踪结果被一堆抽象名词砸懵。其实计算机视觉的核心工作说白了就是“在图像上找到点、线、面然后用量化指标描述它们”。这个项目正好覆盖了这条主线的完整闭环用鼠标或固定坐标在图像上确定三个点用线段把点连成闭合图形再用点的坐标反推几何属性——角度。这是一套标准的“图像→数据→结果”处理流程规模小到一个人一晚上能搞定但逻辑链完整。另外一个原因很实际OpenCV本身不提供“直接算多边形角度”的函数。你必须自己从坐标出发把几何公式翻译成代码。这个过程强迫你去查API、查坐标系定义、查浮点精度问题而不是复制粘贴一把梭。1.2 整体方案与模块划分我把整个项目拆成四块每块正好对应一个OpenCV知识点图像创建与初始化创建一块纯色画布理解Mat的数据组织方式。顶点确定用鼠标回调函数在画布上点击三个点或者直接硬编码三组坐标。图形绘制用line函数把三点连成三角形用circle标出顶点用putText写标注。角度计算根据三顶点坐标用余弦定理或向量点积求三个内角再把结果写回图像。这种拆分方式也是我平时做视觉项目时的习惯。不管需求多小先切模块再逐个实现。切完你会发现真正困难的不是画线不是算角度而是把“图像里的点”和“数学里的点”在脑子里统一起来。坐标系这个概念搞通了后面学透视变换、ROI提取都会顺很多。2. 动手前的关键认知图像坐标系与数学坐标系2.1 原点在左上角y轴向下所有计算都要适配这个规则这是整个项目最隐蔽的坑没有之一。我们中学学的平面直角坐标系原点在左下角x轴向右y轴向上。但数字图像按“从上到下、从左到右”的方式存储像素所以OpenCV的坐标系里原点在图像的左上角x轴向右增大y轴向下增大。你用某点坐标做任何几何计算之前必须第一时间反应过来这个点的y值大不代表它“高”只代表它“靠下”。放到这个项目里影响很直接。比如你设了三个顶点 A(100, 100)、B(300, 100)、C(200, 300)在数学直觉里C应该“高于”A和B但在图像上C反而显示在A、B的下方。这个本身不影响角度计算因为角度只取决于边长跟坐标系方向无关。可一旦你脑子里想着“图像上方是y正方向”去推导公式或者调试时想手动验证结果就很容易被绕晕。我建议动手前先花两分钟做一次纸上推导把三顶点坐标画在方格纸上标注清楚哪个点在图像的哪个方位之后再写代码。2.2 绘制与计算是两条独立链路别混在一起我见过初学者这么写代码每点击一个点就立刻调line把上一根线画出来然后接着计算角度。思路没错但容易把绘制逻辑和计算逻辑耦合在一起后边想改个画布大小或刷新画面就搅成一团。我的做法是分两步先交互收集坐标再统一绘制和计算。收集阶段只维护一个点列表不做任何图像输出点够了一次性把三角形画出来、角度算出来、文字标上去。分工清楚后面加功能也好加。比如你想支持“重置重新画”只需要清空点列表、重新填充画布绘制的代码完全不用动。3. 核心算法角度计算的两种思路3.1 余弦定理法先算三条边长再反推角度已知三角形的三个顶点坐标就能算出三条边的长度。假设三个顶点为A、B、C对应的边长为BC顶点A对边、CA顶点B对边、AB顶点C对边。任意一个角比如顶点A处的角度可以根据余弦定理求cos(A) (AB² AC² - BC²) / (2 * AB * AC)然后A arccos(cos(A))得到的值是弧度再用角度制显示就乘以180/π。这个方法的优点是公式固定、不容易错缺点也明显当三角形退化或接近退化时也就是三个点几乎共线时余弦定理算出来的cos值可能略大于1或略小于-1导致arccos传入非法值结果是NaN。我在实际测试时用户随缘点出来的点经常很接近共线所以还要加一步防错把cos值clip到[-1, 1]区间内。3.2 向量点积法用相邻两条边的方向向量直接求夹角另一种更稳健的做法是用向量点积。顶点A处的两条边向量是AB向量 B - A AC向量 C - A两个向量的夹角就是三角形在A处的内角。夹角公式cos(θ) (AB · AC) / (|AB| * |AC|)这里的θ就是A角度。本质上跟余弦定理是同一个公式差别只是你提前把边长算出来了还是直接拿向量算。实际代码里我推荐先得到两条边向量用OpenCV的Point2f类型保存然后自己写一个向量点积和模长计算。这样代码语义清晰以后扩展到多边形角度计算也方便。3.3 为什么我推荐用atan2做最终角度换算用acos求角度有个隐患上面已经提了数值边界问题。更稳的做法是借助atan2函数。原理是这样的如果你把两个向量的夹角问题转换成“求某个方向的极角”问题atan2天然能处理四个象限返回值范围是[-π, π]精度和稳定性都比acos好。具体做法是先求两条边的单位向量再算它们的夹角。但这里有一个细节很容易踩坑如果你直接用atan2(y, x)对两个向量各自求极角再做差得到的是一个带符号的角度范围是[-π, π]你可能还需要判断取补角还是原角。我经过实测最省心的写法是先把两条向量归一化再对它们的点积结果acos但归一化之后点积受到浮点误差的影响更小或者直接用下文代码里的“atan2叉积/点积”写法。这里我提供一份模板你可以在自己的代码里对比测试#include cmath struct Vec2f { double x, y; }; double dot(const Vec2f a, const Vec2f b) { return a.x * b.x a.y * b.y; } double cross(const Vec2f a, const Vec2f b) { return a.x * b.y - a.y * b.x; } double angleBetween(const Vec2f a, const Vec2f b) { double d dot(a, b); double c cross(a, b); return std::atan2(std::abs(c), d) * 180.0 / CV_PI; }这里atan2的分子用了叉积的绝对值所以返回值一定是正角度范围0到180度。这个写法在三个点比较接近共线时也稳定不会NaN。4. 实操完整代码实现与逐步讲解4.1 环境准备与版本选择我用的是OpenCV 3.4.16C接口。其实这个项目对版本不敏感你用4.x甚至5.x都行基础绘图API这些版本间几乎没有变化。如果你用的是Python版本代码结构类似只是把Point换成tuple把Scalar换成(B, G, R)元组。我这边演示用C主要因为OpenCV的经典教程以C为主且后续做图像处理性能对比时C还是绕不开。环境上你只需要能编译OpenCV的C环境。Windows上可以用预编译的库文件Linux下用apt装libopencv-devmacOS用Homebrew。这些细节网上一搜一大把我不展开只提醒一句装完先跑一版最简单的示例代码确认头文件和库都链上了再往下写否则排查编译问题会混在一起。4.2 用鼠标交互收集三个顶点鼠标回调是OpenCV交互里最常用的机制。你需要注册一个回调函数OpenCV会在鼠标事件发生时调用它。这一步的代码逻辑很简单如果是左键按下记录当前坐标存入一个vector存满三个点之后自动进入绘制阶段。#include opencv2/opencv.hpp #include vector #include string using namespace cv; using namespace std; vectorPoint pts; bool ready false; void onMouse(int event, int x, int y, int flags, void* userdata) { if (event EVENT_LBUTTONDOWN !ready) { pts.push_back(Point(x, y)); if (pts.size() 3) { ready true; } } }这里我对回调函数做了防重复执行处理一旦收集满三个点ready置true后续点击不再生效。运行后你在窗口里依次点三个位置程序就会拿到三组坐标。注意回调函数里不能做耗时操作也别在里面直接调用imshow刷新窗口否则可能引发事件循环问题。我一般只在回调里存点等回到主循环里再统一刷新显示。4.3 画三角形、标顶点、标边长拿到三个点之后绘制就很简单了。先用line把闭合三角形的三条边画出来再用circle给三个顶点画实心小圆点。为了让画面看起来专业一点我还会在顶点旁边用putText写上编号。int main() { Mat canvas(480, 640, CV_8UC3, Scalar(255, 255, 255)); namedWindow(triangle); setMouseCallback(triangle, onMouse); imshow(triangle, canvas); while (true) { imshow(triangle, canvas); int key waitKey(20); if (key r) { pts.clear(); ready false; canvas Mat(480, 640, CV_8UC3, Scalar(255, 255, 255)); } if (key 27) { // ESC break; } if (ready) { drawTriangle(canvas, pts); computeAndDrawAngles(canvas, pts); ready false; // 只处理一次避免反复覆盖 } } return 0; }drawTriangle的代码基本就是三条line加三个circle没什么特别的。颜色我选的是BGR三个分量不同的值比如BGR(200, 80, 80)的红色系和BGR(80, 160, 240)的蓝色系背景用白色能最大程度避免视觉干扰。线条厚度我选的2像素顶点圆点半径5像素、内部填充这样既清楚又不会过分干扰图像。4.4 角度计算与putText标注完整核心实现这一步是整个项目最核心的部分。一个容易忽略的点是cv::Point默认是int类型而计算时需要浮点运算。所以我在函数入口就把三个点转成Point2f类型再进行向量和长度计算。下面是我测试通过的核心代码void computeAndDrawAngles(Mat img, const vectorPoint pts) { // 三点编号A、B、C Vec2f AB, AC, BA, BC, CA, CB; AB.x pts[1].x - pts[0].x; AB.y pts[1].y - pts[0].y; AC.x pts[2].x - pts[0].x; AC.y pts[2].y - pts[0].y; BA.x pts[0].x - pts[1].x; BA.y pts[0].y - pts[1].y; BC.x pts[2].x - pts[1].x; BC.y pts[2].y - pts[1].y; CA.x pts[0].x - pts[2].x; CA.y pts[0].y - pts[2].y; CB.x pts[1].x - pts[2].x; CB.y pts[1].y - pts[2].y; double angleA angleBetween(AB, AC); double angleB angleBetween(BA, BC); double angleC angleBetween(CA, CB); // 绘制角度文本 putText(img, format(A:%.1f, angleA), Point(pts[0].x 10, pts[0].y - 10), FONT_HERSHEY_SIMPLEX, 0.6, Scalar(200, 20, 20), 2); putText(img, format(B:%.1f, angleB), Point(pts[1].x 10, pts[1].y - 10), FONT_HERSHEY_SIMPLEX, 0.6, Scalar(200, 20, 20), 2); putText(img, format(C:%.1f, angleC), Point(pts[2].x 10, pts[2].y - 10), FONT_HERSHEY_SIMPLEX, 0.6, Scalar(200, 20, 20), 2); }再说几个细节。第一文本位置我统一在顶点右下方偏移了(10, -10)也就是点坐标加一个固定的像素偏移。看起来舒服一点不会盖住顶点。但如果你画的三角形靠近图像边缘文本可能被截断这个偏移量根据实际情况调就行。第二putText的字体我选的FONT_HERSHEY_SIMPLEX这个字体比较常见清晰度也够。尺寸0.6配2像素线宽实测在640x480的画布上很合适字不会糊成一团。第三format函数返回的是C风格字符串包装的std::string可以直接作为putText的输入参数。我习惯用”%.1f”保留一位小数既够精确又不会导致太长的字符串在画面上显得杂乱。最后验证一下三点共线的退化情况。假设你点的三个点是(100, 100)、(200, 100)、(300, 100)这三个点完全水平。这时AB和AC两条向量方向相同点积等于模长乘积叉积为0atan2计算出0度。acos版本则会大概率算出NaN。代码会在屏幕上显示A:0.0 B:0.0 C:0.0而不会崩溃。这个表现我实测过符合预期。4.5 完整可运行代码汇总为了让你少走弯路我把所有代码串成一个能直接编译的版本。这版包含了鼠标交互、绘制、角度计算、刷新、重置功能完整实测于OpenCV 3.4.16。#include opencv2/opencv.hpp #include vector #include string #include cmath using namespace cv; using namespace std; struct Vec2f { double x, y; }; vectorPoint pts; bool ready false; double dot(Vec2f a, Vec2f b) { return a.x * b.x a.y * b.y; } double cross(Vec2f a, Vec2f b) { return a.x * b.y - a.y * b.x; } double angleBetween(Vec2f a, Vec2f b) { double d dot(a, b); double c cross(a, b); return atan2(abs(c), d) * 180.0 / CV_PI; } void onMouse(int event, int x, int y, int flags, void* userdata) { if (event EVENT_LBUTTONDOWN !ready) { pts.push_back(Point(x, y)); if (pts.size() 3) { ready true; } } } void drawTriangle(Mat img, const vectorPoint pts) { // 画三条边 line(img, pts[0], pts[1], Scalar(0, 128, 255), 2); line(img, pts[1], pts[2], Scalar(0, 128, 255), 2); line(img, pts[2], pts[0], Scalar(0, 128, 255), 2); // 画顶点 for (size_t i 0; i pts.size(); i) { circle(img, pts[i], 5, Scalar(0, 0, 255), -1); } } void computeAndDrawAngles(Mat img, const vectorPoint pts) { Vec2f AB, AC, BA, BC, CA, CB; AB.x pts[1].x - pts[0].x; AB.y pts[1].y - pts[0].y; AC.x pts[2].x - pts[0].x; AC.y pts[2].y - pts[0].y; BA.x pts[0].x - pts[1].x; BA.y pts[0].y - pts[1].y; BC.x pts[2].x - pts[1].x; BC.y pts[2].y - pts[1].y; CA.x pts[0].x - pts[2].x; CA.y pts[0].y - pts[2].y; CB.x pts[1].x - pts[2].x; CB.y pts[1].y - pts[2].y; double angleA angleBetween(AB, AC); double angleB angleBetween(BA, BC); double angleC angleBetween(CA, CB); // 可以加一个检查和三个角加起来应约等于180 putText(img, format(A:%.1f, angleA), Point(pts[0].x 10, pts[0].y - 10), FONT_HERSHEY_SIMPLEX, 0.6, Scalar(200, 20, 20), 2); putText(img, format(B:%.1f, angleB), Point(pts[1].x 10, pts[1].y - 10), FONT_HERSHEY_SIMPLEX, 0.6, Scalar(200, 20, 20), 2); putText(img, format(C:%.1f, angleC), Point(pts[2].x 10, pts[2].y - 10), FONT_HERSHEY_SIMPLEX, 0.6, Scalar(200, 20, 20), 2); // 画角度弧线选做 // 这里简单起见省略可自行用ellipse或arcLength实现 } int main() { Mat canvas(480, 640, CV_8UC3, Scalar(255, 255, 255)); namedWindow(triangle); setMouseCallback(triangle, onMouse); imshow(triangle, canvas); while (true) { imshow(triangle, canvas); int key waitKey(20); if (key r) { pts.clear(); ready false; canvas Mat(480, 640, CV_8UC3, Scalar(255, 255, 255)); } if (key 27) { // ESC break; } if (ready) { drawTriangle(canvas, pts); computeAndDrawAngles(canvas, pts); ready false; } } return 0; }如果你只需要固定坐标版不想要鼠标交互把main里那段while循环换成下面这样就行vectorPoint pts {Point(100, 300), Point(400, 200), Point(300, 60)}; Mat canvas(480, 640, CV_8UC3, Scalar(255, 255, 255)); drawTriangle(canvas, pts); computeAndDrawAngles(canvas, pts); imshow(triangle, canvas); waitKey(0);两种版本的代码我都跑过结果一致。4.6 一个验证角度准确性的小技巧写完代码第一件事不是直接看结果而是用一组已知坐标手工验证。比如取A(0, 0)、B(300, 0)、C(0, 400)这是个直角三角形直角在A点角度A90度B和C可以按反正切算出来B atan2(400, 300) ≈ 53.13度C ≈ 36.87度。把这三个坐标填进去跑一遍如果输出是90.0、53.1、36.9说明代码没问题。这一步能在五分钟内定位到底是绘制问题、坐标问题还是计算问题别嫌繁琐。我还建议加一个小的自动校验块三个角度加起来是否约等于180。虽然由于浮点误差可能会有0.1度左右的偏差但只要在179.5到180.5之间基本就能放心。5. 常见问题与排查技巧实录5.1 putText中文显示乱码OpenCV的putText对英文、数字是没问题的但中文大概率显示成问号因为内置的字体库不支持中文。解决方案有几个一是用英文标签A/B/C或者Angle A二是用PIL或其他库把中文渲染到Mat上再贴回来三是用freetype模块需要额外编译。对新手来说直接用英文标签最简单不会影响学习效果。5.2 角度计算结果出现NaN或180度差我最初用acos版本的时候就踩过这个坑。三个点如果几乎共线cos值会因为浮点误差轻微超出[-1, 1]acos返回NaN。解决办法就是前面说的用angleBetween的atan2写法。如果你的代码坚持要用acos记得用std::max和std::min把cos值clip到[-1, 1]再做acos。另一个容易错的是求“补角”的问题。向量夹角公式返回的是两个向量之间的小角范围0到180度。但对于三角形的内角如果三个点的排列顺序导致某条边的方向取反求出来的角可能是外角而不是内角。我在代码里用6条向量把每个顶点两条邻边都往“从该顶点出发”的方向取了一遍就是为了避免这种问题。你如果想简化成“只算三条边向量两两夹角”很可能在钝角三角形上翻车。5.3 画布显示不出来或窗口一闪而过新手最常见的两个问题一是在while循环里waitKey后没有imshow导致画面只更新一次二是窗口一闪而过因为waitKey(0)没被调用、或程序直接跑完了。解决方式就是看清上面main函数的结构while循环里每帧重新imshowwaitKey(20)控制帧率ESC退出按r重置。这个结构是OpenCV交互程序的标准写法建议背下来。5.4 点坐标记录的是窗口坐标还是图像坐标鼠标回调里拿到的x、y是相对窗口内容的坐标。如果你的窗口显示了完整图像没有缩放它和图像坐标是相等的。但如果你改了窗口大小OpenCV默认会用固定窗口比例显示这时候坐标就要按比例换算否则会出现“点不扎在图像那个位置上”的情况。解决方式很简单显示图像前调用namedWindow(triangle, WINDOW_NORMAL)但窗口可调整时代码里就要用resize过后的坐标映射或者干脆锁死窗口大小。6. 启发与扩展从三角形走向更复杂的几何可视化画三角形只是热身。这个项目的框架稍微改一改就能扩展到很多方向多边形任意顶点角度计算把存储点改成一个循环数组对每个顶点用前后相邻两个点算向量夹角一套代码直接支持四边形、五边形乃至任意多边形。弧线可视化角度现在画面上只有数字你可以用椭圆弧或圆弧把角度区域涂出来更直观地展示某个角是锐角还是钝角。OpenCV里的ellipse配合圆弧角度可以实现视觉效果会好很多。边长实时显示在每条边上取中点用putText写上边长值顺手还能算周长和面积。面积用叉积法三角形面积 0.5 * |AB × AC|代码一行就搞定。三角形形状判定根据边长或角度大小判它是锐角三角形、直角三角形还是钝角三角形再在窗口标题栏或画布上输出。这些几何判定在后续做轮廓分析、形状匹配时很常见提前练一遍手感很好。用滑动条动态调整顶点位置把三个顶点的像素坐标绑定到滑动条上就能看着三角形连续变形、角度数值实时刷新对理解余弦函数变化非常有帮助。我自己的体会是每做一个这样的小练习之后再去碰轮廓检测、特征匹配这些复杂主题心里就多一分底。所谓的“基础扎实”不是你看完了多少篇教程而是你亲手把坐标系、矩阵、几何换算这些概念从公式变成行跑过的代码。这个项目我建议你最好别直接抄代码看完原理之后先自己写一版再对照我这版修问题收获会差很多。

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

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

免费获取报价 →
↑