
第一次在OpenCV里画三角形的时候我以为三根线一连就完事。结果真到实际项目里要给纸板标出三个角的大小才发现画图本身只是最表层的事坐标系方向、API版本差异、浮点误差、文字显示一个比一个能折腾。这篇东西就把我用OpenCV 3实现“画出三角形并计算三个角大小”的完整过程复盘一遍代码可以直接抄里面踩过的坑也全部列出来省得你再走一遍。先说清楚这个项目到底解决什么问题在图像上绘制一个指定三点坐标的三角形然后自动算出它的三个内角并把角度数值标注到图像上。适合刚接触OpenCV绘图的初学者也适合做几何测量、标定板识别、姿态估计这类需要角度计算的人参考。核心涉及三块——绘图API、向量几何、坐标系统逻辑都不难但串起来有几个很容易翻车的细节。1. 项目拆解画三角形的API选型与坐标系陷阱1.1 先分清两套坐标系图像坐标和几何坐标OpenCV的图像坐标系原点在左上角x轴向右y轴向下。这跟我们上学时用的数学坐标系正好相反。数学坐标系里y轴向上所以一个点“往上走”y值变大在OpenCV里“往上走”y值反而变小。这个看似基础的区别在我第一次做角度标注时就把我坑了。比如我想画一个以底边在下方、顶点在上方的三角形直觉上会写顶点坐标200, 100底边两个点100, 300和300, 300。这其实是对的但如果你在脑子里套数学坐标系很容易把顶点当成200, 300、底边当成100, 100之类结果画出来的三角形整个倒过来。所以第一步先把脑子里那套坐标轴忘掉接受OpenCV的y轴向下规则。还有一个容易忽略的点OpenCV的坐标全部是整型像素坐标没有小数像素的概念。你传一个浮点数进去它不会四舍五入而是直接截断。虽然画三角形时误差一两个像素看不出问题但如果你用浮点坐标去算角度然后想精确画出来最好把坐标取整后再传给绘制函数。1.2 三种绘图方式怎么选画三角形可以用三种基本API用途不一样API作用适用场景cv2.line画一条线段只需要连接两个点cv2.polylines画多边形轮廓一次画出整个三角形边框cv2.fillPoly填充多边形内部需要实心三角形色块第一次做的时候我习惯用三行cv2.line后来发现polylines一次就能搞定代码更干净。polylines的调用方式是cv2.polylines(canvas, [pts], isClosedTrue, color(0, 0, 255), thickness2)这里的pts是numpy数组必须是int32类型shape为(N, 2)N是顶点数。还要注意pts外层再套一个列表写成[pts]因为polylines支持同时画多个多边形每个多边形是一个独立数组。isClosedTrue表示首尾闭合不传的话三角形最后一条边会缺失这个参数很容易漏。fillPoly的用法和polylines几乎一样只是不需要thickness因为它是填充整个区域。如果你想让三角形既有填充色又有边框就先用fillPoly填充再用polylines描边。还有一个新手必踩的坑OpenCV 3里的绘图函数都是在原图上原地修改返回值是None。很多从C转过来的朋友习惯写img cv2.polylines(img, ...)结果执行完发现img变成了None后面一画就报错。Python版不需要接收返回值直接调用就行cv2.polylines(canvas, [pts], True, (0, 0, 255), 2) # 不要写 canvas cv2.polylines(...)2. 角度计算的数学原理余弦定理为何是正解2.1 斜率法和向量法谁更稳计算三个角的大小我在网上看到不少教程用斜率法先算两条边的斜率再用arctan求出每条边与水平方向的夹角然后相减得到两边夹角。逻辑没问题但有一个致命弱点——当某条边接近竖直时斜率趋近于无穷大数值极不稳定arctan算出来的角度误差会非常大。打个比方斜率法就像你站在路口用指南针分别测两条路的方向角然后相减。如果路正好指向正北指南针读数会变得很敏感稍微动一下方位就漂移几十度。而向量法不依赖方向角它直接用两个向量之间的关系算夹角不存在这种奇点。实际做几何测量时三角形的边可能以任意角度出现用斜率法等于埋了一颗雷。所以我的建议是直接用向量点积公式cosθ (v1 · v2) / (|v1| × |v2|)其中v1、v2是共享同一个顶点的两条边的方向向量。有了cosθ再用math.acos反解出弧度最后转成角度。这个公式也叫余弦定理的向量形式等价于课本里的cosA (b² c² - a²) / 2bc但代码实现上向量法更直观因为你已经有点坐标了不需要再算每条边的边长。2.2 角度计算函数浮点误差一定要处理代码写起来很短import math import numpy as np def calc_angle(p1, p2, p3): # 计算以 p2 为顶点的夹角 v1 np.array(p1) - np.array(p2) v2 np.array(p3) - np.array(p2) cos_theta np.dot(v1, v2) / (np.linalg.norm(v1) * np.linalg.norm(v2)) cos_theta np.clip(cos_theta, -1.0, 1.0) angle math.degrees(math.acos(cos_theta)) return anglep1是角的一边经过的点p2是顶点p3是另一边经过的点。v1和v2就是从顶点出发的两条向量。np.dot计算点积np.linalg.norm计算向量长度。这里有一行我一开始没写后来被逼着加上的——np.clip。浮点运算里有个常见问题由于精度限制cos_theta理论上应该在[-1, 1]范围内但实际可能算出1.0000000002或-1.0000000001。如果不做截断math.acos会直接返回nan后面所有角度计算全崩。尤其是三个点几乎共线时cos值接近±1误差最明显。实测下来clip这一行是必须的不是锦上添花。2.3 为什么内角和校验一定要做三角形内角和等于180度这是我们的天然校验工具。算出三个角后求和如果误差在0.01度以内说明计算逻辑基本可靠如果明显不对比如加起来199度那大概率是顶点传参顺序错了。我之前就犯过这个错写calc_angle(B, A, C)算角A结果参数顺序搞反把顶点放到了p1的位置向量变成从其他点指向顶点算出来的夹角完全不是目标角。所以建议每次算完都打印一下三个角print(fangles {angle_A:.2f}, {angle_B:.2f}, {angle_C:.2f}, sum {angle_A angle_B angle_C:.2f})不要嫌麻烦这个打印能帮你快速定位问题。三个点的坐标、传参顺序、顶点编号任何一步出错sum都不会等于180。3. 完整实操画出三角形并标注三个角3.1 创建画布与规划顶点坐标我用一张500像素宽、400像素高的白色画布来演示。OpenCV默认创建的是黑色画布想要白色背景需要这样写canvas np.ones((400, 500, 3), dtypenp.uint8) * 255np.ones创建全1的三通道数组再乘255就得到一张白色BGR图像。注意乘255后dtype仍然是uint8数值正好是255没有溢出问题。顶点坐标选择有讲究。我用的三个点是pts np.array([[200, 100], [100, 300], [350, 250]], dtypenp.int32)A点(200, 100)是三角形的顶点B点(100, 300)是左下角C点(350, 250)在右下偏上的位置。这个布局让三角形整体居中边缘离画布边界还有距离方便后面标注文字。如果你把顶点放在太靠近图像边缘的位置角度文字很容易被裁掉体验很差。这个三角形的三个角大致是角A约71.6度角B约52.1度角C约56.3度。先记下这几个数运行完代码后对照一下结果一致就说明写对了。3.2 绘制三角形、顶点与边框先把填充色画上再画边框。顺序不能反先填充再描边否则边框会被填充色盖住。cv2.fillPoly(canvas, [pts], color(220, 220, 220)) cv2.polylines(canvas, [pts], isClosedTrue, color(0, 0, 255), thickness2)fillPoly填的是浅灰色polylines画红色边框。颜色顺序是BGR所以(0, 0, 255)是纯红色。如果你写(255, 0, 0)那就是蓝色很多新手第一次在这里懵。三个顶点我额外用圆点标出来方便后续标注字母for x, y in pts: cv2.circle(canvas, (int(x), int(y)), radius5, color(0, 0, 255), thickness-1)thickness填-1表示实心圆。为什么用-1因为如果thickness等于2画出来的就是空心圆顶点位置看着不够明显。5个像素半径在400像素高的画布上比例合适太大显得笨重太小看不清。3.3 计算角度并在图上标注角度计算的调用方式angle_A calc_angle(pts[1], pts[0], pts[2]) # 顶点是A angle_B calc_angle(pts[0], pts[1], pts[2]) # 顶点是B angle_C calc_angle(pts[1], pts[2], pts[0]) # 顶点是C注意calc_angle的第二个参数是顶点。以A为例角A的两边分别经过B和C所以第一个参数传pts[1]第三个参数传pts[2]第二个参数传pts[0]。参数顺序写错结果肯定不对。标注文字时直接用cv2.putTextcv2.putText(canvas, fA{angle_A:.1f}, (200, 80), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 0, 0), 2)但这里有个问题如果把三个顶点附近的文字都写死在一个偏移位置比如都向上偏移20像素三个标签可能互相重叠或者被画布边界截断。我后来用了一个更通用的办法——根据顶点相对三角形重心方向做偏移让标签往三角形内侧移动。cx int(np.mean(pts[:, 0])) cy int(np.mean(pts[:, 1])) label_positions [] for x, y in pts: dx x - cx dy y - cy norm math.hypot(dx, dy) tx int(x - dx / norm * 25) ty int(y - dy / norm * 25) label_positions.append((tx, ty))这个思路是计算三角形的重心然后每个顶点朝重心方向偏移25像素。比如顶点A在重心上方那么dx、dy的方向是朝上的用x - dx/norm * 25就会让标注点往重心方向走也就是朝三角形内部移动。这样不管三角形怎么旋转标签都会落在内部空档处比写死坐标健壮得多。3.4 可以复制的完整脚本把上面所有步骤拼起来就是一个可以直接运行并保存图片的脚本import cv2 import numpy as np import math def calc_angle(p1, p2, p3): v1 np.array(p1) - np.array(p2) v2 np.array(p3) - np.array(p2) cos_theta np.dot(v1, v2) / (np.linalg.norm(v1) * np.linalg.norm(v2)) cos_theta np.clip(cos_theta, -1.0, 1.0) return math.degrees(math.acos(cos_theta)) # 1. 创建画布 canvas np.ones((400, 500, 3), dtypenp.uint8) * 255 # 2. 定义三角形顶点 pts np.array([[200, 100], [100, 300], [350, 250]], dtypenp.int32) # 3. 绘制三角形 cv2.fillPoly(canvas, [pts], color(220, 220, 220)) cv2.polylines(canvas, [pts], isClosedTrue, color(0, 0, 255), thickness2) for x, y in pts: cv2.circle(canvas, (int(x), int(y)), 5, (0, 0, 255), -1) # 4. 计算三个角 angle_A calc_angle(pts[1], pts[0], pts[2]) angle_B calc_angle(pts[0], pts[1], pts[2]) angle_C calc_angle(pts[1], pts[2], pts[0]) print(fangle_A {angle_A:.2f}) print(fangle_B {angle_B:.2f}) print(fangle_C {angle_C:.2f}) print(fsum {angle_A angle_B angle_C:.2f}) # 5. 标注角度文字 cx int(np.mean(pts[:, 0])) cy int(np.mean(pts[:, 1])) labels [(A, angle_A), (B, angle_B), (C, angle_C)] for i, (name, angle_val) in enumerate(labels): dx pts[i][0] - cx dy pts[i][1] - cy norm math.hypot(dx, dy) tx int(pts[i][0] - dx / norm * 25) ty int(pts[i][1] - dy / norm * 25) cv2.putText(canvas, f{name}{angle_val:.1f}, (tx, ty), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 0, 0), 2) # 6. 显示并保存 cv2.imshow(Triangle Angles, canvas) cv2.imwrite(triangle_angles.png, canvas) cv2.waitKey(0) cv2.destroyAllWindows()如果你在服务器上跑没有显示窗口把cv2.imshow那行注释掉单独用cv2.imwrite保存图片也一样能看结果。我平时调试多半是直接保存图片比弹窗方便还能反复对比不同坐标的效果。4. 踩坑实录显示、坐标与精度问题4.1 putText不支持特殊符号怎么办这个坑我印象很深。想在图上标注“角A 71.6°”于是写了类似fA{angle:.1f}°的字符串结果运行后图像上出现一个问号或者方块。原因是OpenCV内置的Hershey字体只支持基本ASCII字符°这个符号不在支持范围内。同样中文也画不出来会变成一串问号。解决方案有三个一是直接用“deg”代替°写成A71.6 deg二是用英文字母“A71.6”简洁标注三是自己通过绘图叠加小圆点模拟度分秒符号但没必要。我实际项目里全部用“A71.6”这种格式简洁又不占地方。另外putText的字体大小0.6、线宽2在500x400画布上看起来合适但如果你把画布放大到1920x1080同样的字号会显得很小。文字大小和线宽要按画布比例调整没有统一值建议先跑一版看效果再微调。4.2 y轴反向让方向角变了角度大小却不变有朋友问过我既然OpenCV的y轴向下用向量点积算出来的角度会不会不对答案是角度大小完全不受影响。点积公式只依赖两个向量的坐标分量坐标系无论怎么翻转两个向量之间的夹角大小是不变的。就像你把一张纸翻过来看纸上画的角还是那个角。真正会出问题的是如果你用atan2计算每条边的方向角再相减因为atan2在图像坐标系下返回的角度旋转方向和数学坐标系相反你可能会得到负角度或者看起来方向不对。解决办法是在计算方向角时对y取负或者干脆像我一样用向量点积绕开方向角问题。这里有个经验当你发现某个角度算出来是负数或者超过180度先不要怀疑公式去检查坐标系。把三个顶点坐标打印出来用纸笔画一下看是不是你脑子里的坐标方向和代码里的不一致。大概率是你把y轴方向搞反了。4.3 用等腰三角形验证误差来自哪里验证角度计算是否正确我推荐用一个等腰三角形。取A(250, 100)、B(150, 300)、C(350, 300)理论上角B和角C应该相等。为什么用等腰三角形因为它的对称性给你提供了一个额外的检查维度如果代码算完发现两个底角不相等哪怕只差0.1度说明坐标或者参数顺序有问题。如果算出来相等那基本可以放心。实测这个三角形的角B和角C都应该是63.43度左右角A是53.13度左右三者之和180。由于浮点数表示和acos计算的误差最后一位可能会差一点点这是正常现象。如果你看到角度是nan十有八九是cos值没clip如果你看到某个角是0度或者180度说明三个点接近共线也就是坐标选得太极端了。4.4 环境差异OpenCV 3.x和4.x的API几乎一致我写这篇代码时用的OpenCV 3.x但直接复制到OpenCV 4.x环境里也能运行。polylines、fillPoly、putText这些API从3.x到4.x基本没有变化唯一要留意的是pts数组必须是int32在OpenCV 3里这个限制就已经存在。如果你用的是更高版本也一样。另外如果你安装了opencv-contrib-python可能同时装了多个OpenCV版本导入时一定要确认用的是哪一个。我遇到过明明装了4.x却因为环境变量指向旧版而炸掉的情况排查了半天才发现是路径问题。遇到奇怪报错可以先打印cv2.__version__确认版本。5. 扩展从三角形到任意多边形内角计算5.1 通用内角函数三角形算完了思路其实可以直接扩展到任意多边形。每个顶点的内角本质上就是相邻两条边之间的夹角。用一个循环就能搞定def polygon_angles(poly): n len(poly) angles [] for i in range(n): p_prev poly[(i - 1) % n] p_curr poly[i] p_next poly[(i 1) % n] v1 np.array(p_prev) - np.array(p_curr) v2 np.array(p_next) - np.array(p_curr) cos_theta np.dot(v1, v2) / (np.linalg.norm(v1) * np.linalg.norm(v2)) cos_theta np.clip(cos_theta, -1.0, 1.0) angles.append(math.degrees(math.acos(cos_theta))) return anglesn是顶点数取模运算(i-1)%n让最后一个顶点能正确取到前一个顶点也就是多边形闭合。这个函数放在三角形项目里可以直接用传入三个点就返回三个角。5.2 凹凸边形的内角问题上面的函数有个隐藏问题acos只能返回0到180度之间的夹角也就是说结果总是小于等于180度。对于凸多边形这个值就是内角但对于凹多边形凹进去的那个顶点内角其实超过180度这时候直接拿来用会出错。判断凹点的方法是用向量叉积的符号。三个相邻点的叉积z分量如果与其他顶点符号不一致说明该点是凹点实际内角应该是360度减去刚才算出的夹角。这个逻辑在代码里要多几行三角形因为是凸图形用不到但扩展成四边形、五边形时就要考虑。我后来在做某个多边形标定项目时就是先把角度计算封装成polygon_angles函数再单独处理凹点修正。三角形的经验在这里帮我省了不少事因为底层角度计算逻辑完全一样。画三角形的项目到这里基本就讲透了。回头看整件事画线本身只占了很小一部分工作量真正花时间的是坐标系理解、参数传递顺序、浮点精度这些看起来不起眼的地方。我个人在实际操作中的体会是别急着跑代码先在草稿纸上标出三个坐标点手算一遍角度再跟程序输出对照这种方法能最快定位问题的根源。最后再分享一个小技巧当你需要调试角度标注的位置时可以把putText的坐标值临时打印出来看看是不是落在你预期的地方有时候文字飞了并不是计算错而是偏移方向写反了。