第一次在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], isClosed=True, color=(0, 0, 255), thickness=2)这里的pts是numpy数组,必须是int32类型,shape为(N, 2),N是顶点数。还要注意pts外层再套一个列表,写成[pts],因为polylines支持同时画多个多边形,每个多边形是一个独立数组。isClosed=True表示首尾闭合,不传的话三角形最后一条边会缺失,这个参数很容易漏。
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(f"angles = {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), dtype=np.uint8) * 255np.ones创建全1的三通道数组,再乘255,就得到一张白色BGR图像。注意乘255后dtype仍然是uint8,数值正好是255,没有溢出问题。
顶点坐标选择有讲究。我用的三个点是:
pts = np.array([[200, 100], [100, 300], [350, 250]], dtype=np.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], isClosed=True, color=(0, 0, 255), thickness=2)fillPoly填的是浅灰色,polylines画红色边框。颜色顺序是BGR,所以(0, 0, 255)是纯红色。如果你写(255, 0, 0),那就是蓝色,很多新手第一次在这里懵。
三个顶点我额外用圆点标出来,方便后续标注字母:
for x, y in pts: cv2.circle(canvas, (int(x), int(y)), radius=5, 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.putText:
cv2.putText(canvas, f"A={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), dtype=np.uint8) * 255 # 2. 定义三角形顶点 pts = np.array([[200, 100], [100, 300], [350, 250]], dtype=np.int32) # 3. 绘制三角形 cv2.fillPoly(canvas, [pts], color=(220, 220, 220)) cv2.polylines(canvas, [pts], isClosed=True, color=(0, 0, 255), thickness=2) 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(f"angle_A = {angle_A:.2f}") print(f"angle_B = {angle_B:.2f}") print(f"angle_C = {angle_C:.2f}") print(f"sum = {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°”,于是写了类似f"A={angle:.1f}°"的字符串,结果运行后图像上出现一个问号或者方块。原因是OpenCV内置的Hershey字体只支持基本ASCII字符,°这个符号不在支持范围内。同样,中文也画不出来,会变成一串问号。
解决方案有三个:一是直接用“deg”代替°,写成A=71.6 deg;二是用英文字母“A=71.6”简洁标注;三是自己通过绘图叠加小圆点模拟度分秒符号,但没必要。我实际项目里全部用“A=71.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的坐标值临时打印出来,看看是不是落在你预期的地方,有时候文字飞了并不是计算错,而是偏移方向写反了。