☰
OpenCV入门实战:绘制三角形并计算三个内角(附完整代码)
2026/10/15 8:37:38 网站建设 项目流程

初看这个项目标题,我猜很多人第一反应是“画个三角形算什么,cv2.line敲三行代码的事”。但真正做下去你会发现,这个小项目几乎是OpenCV入门阶段性价比最高的一次练习:绘图API、图像坐标系、鼠标交互、几何学换算、字符绘制,全都能塞进这几十行代码里。尤其是“计算3个角的大小”这一步,远比你想的容易踩坑。坐标原点在左上角、y轴向下、图像坐标系和数学坐标系不是一回事,这些基础认知不梳理清楚,角度算出来就是错的,而且你还不知道错在哪。

这篇文章我会按照我实际动手时的思路走一遍:先拆解项目里真正要掌握的知识点,再把角度计算的数学原理讲透,最后给一份能直接跑的完整代码,附带我实测踩过的坑。无论你是刚接触OpenCV,还是已经会画图但没认真算过几何量,这篇应该都能给你点东西。

1. 项目拆解:一个三角形背后藏着哪些OpenCV基本功

1.1 为什么选“画三角形+算角度”作为练手项目

我见过不少OpenCV初学者,上来就啃人脸识别、目标跟踪,结果被一堆抽象名词砸懵。其实计算机视觉的核心工作,说白了就是“在图像上找到点、线、面,然后用量化指标描述它们”。这个项目正好覆盖了这条主线的完整闭环:用鼠标或固定坐标在图像上确定三个点,用线段把点连成闭合图形,再用点的坐标反推几何属性——角度。这是一套标准的“图像→数据→结果”处理流程,规模小到一个人一晚上能搞定,但逻辑链完整。

另外一个原因很实际:OpenCV本身不提供“直接算多边形角度”的函数。你必须自己从坐标出发,把几何公式翻译成代码。这个过程强迫你去查API、查坐标系定义、查浮点精度问题,而不是复制粘贴一把梭。

1.2 整体方案与模块划分

我把整个项目拆成四块,每块正好对应一个OpenCV知识点:

  1. 图像创建与初始化:创建一块纯色画布,理解Mat的数据组织方式。
  2. 顶点确定:用鼠标回调函数在画布上点击三个点,或者直接硬编码三组坐标。
  3. 图形绘制:用line函数把三点连成三角形,用circle标出顶点,用putText写标注。
  4. 角度计算:根据三顶点坐标,用余弦定理或向量点积求三个内角,再把结果写回图像。

这种拆分方式也是我平时做视觉项目时的习惯。不管需求多小,先切模块,再逐个实现。切完你会发现,真正困难的不是画线,不是算角度,而是把“图像里的点”和“数学里的点”在脑子里统一起来。坐标系这个概念搞通了,后面学透视变换、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.16,C++接口。其实这个项目对版本不敏感,你用4.x甚至5.x都行,基础绘图API这些版本间几乎没有变化。如果你用的是Python版本,代码结构类似,只是把Point换成tuple,把Scalar换成(B, G, R)元组。我这边演示用C++,主要因为OpenCV的经典教程以C++为主,且后续做图像处理性能对比时,C++还是绕不开。

环境上,你只需要能编译OpenCV的C++环境。Windows上可以用预编译的库文件,Linux下用apt装libopencv-dev,macOS用Homebrew。这些细节网上一搜一大把,我不展开,只提醒一句:装完先跑一版最简单的示例代码,确认头文件和库都链上了,再往下写,否则排查编译问题会混在一起。

4.2 用鼠标交互收集三个顶点

鼠标回调是OpenCV交互里最常用的机制。你需要注册一个回调函数,OpenCV会在鼠标事件发生时调用它。这一步的代码逻辑很简单:如果是左键按下,记录当前坐标,存入一个vector;存满三个点之后,自动进入绘制阶段。

#include <opencv2/opencv.hpp> #include <vector> #include <string> using namespace cv; using namespace std; vector<Point> 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 vector<Point>& 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两条向量方向相同,点积等于模长乘积,叉积为0,atan2计算出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; }; vector<Point> 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 vector<Point>& 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 vector<Point>& 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循环换成下面这样就行:

vector<Point> 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点,角度A=90度,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循环里每帧重新imshow,waitKey(20)控制帧率,ESC退出,按r重置。这个结构是OpenCV交互程序的标准写法,建议背下来。

5.4 点坐标记录的是窗口坐标还是图像坐标

鼠标回调里拿到的x、y,是相对窗口内容的坐标。如果你的窗口显示了完整图像没有缩放,它和图像坐标是相等的。但如果你改了窗口大小,OpenCV默认会用固定窗口比例显示,这时候坐标就要按比例换算,否则会出现“点不扎在图像那个位置上”的情况。解决方式很简单:显示图像前调用namedWindow("triangle", WINDOW_NORMAL),但窗口可调整时,代码里就要用resize过后的坐标映射,或者干脆锁死窗口大小。

6. 启发与扩展:从三角形走向更复杂的几何可视化

画三角形只是热身。这个项目的框架稍微改一改,就能扩展到很多方向:

  1. 多边形任意顶点角度计算:把存储点改成一个循环数组,对每个顶点用前后相邻两个点算向量夹角,一套代码直接支持四边形、五边形乃至任意多边形。

  2. 弧线可视化角度:现在画面上只有数字,你可以用椭圆弧或圆弧把角度区域涂出来,更直观地展示某个角是锐角还是钝角。OpenCV里的ellipse配合圆弧角度可以实现,视觉效果会好很多。

  3. 边长实时显示:在每条边上取中点,用putText写上边长值,顺手还能算周长和面积。面积用叉积法:三角形面积 = 0.5 * |AB × AC|,代码一行就搞定。

  4. 三角形形状判定:根据边长或角度大小,判它是锐角三角形、直角三角形还是钝角三角形,再在窗口标题栏或画布上输出。这些几何判定在后续做轮廓分析、形状匹配时很常见,提前练一遍手感很好。

  5. 用滑动条动态调整顶点位置:把三个顶点的像素坐标绑定到滑动条上,就能看着三角形连续变形、角度数值实时刷新,对理解余弦函数变化非常有帮助。

我自己的体会是,每做一个这样的小练习,之后再去碰轮廓检测、特征匹配这些复杂主题,心里就多一分底。所谓的“基础扎实”,不是你看完了多少篇教程,而是你亲手把坐标系、矩阵、几何换算这些概念从公式变成行跑过的代码。这个项目我建议你最好别直接抄代码,看完原理之后先自己写一版,再对照我这版修问题,收获会差很多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询