3D视觉入门:从相机坐标系到像素坐标系的完整转换指南(附Python代码)
3D视觉入门:从相机坐标系到像素坐标系的完整转换指南(附Python代码)
如果你刚开始接触计算机视觉,尤其是3D视觉相关的项目,面对“世界坐标系”、“相机坐标系”、“像素坐标系”这些术语时,可能会感到一阵眩晕。教科书和理论文章往往堆满了矩阵和公式,但当你真正坐下来,打开编辑器,准备用代码把现实世界中的一个点映射到屏幕上时,却常常不知从何下手。这篇文章就是为你准备的。我们不打算重复那些复杂的数学推导,而是直接切入核心:如何用Python代码,一步步实现从现实世界到数字图像的完整坐标转换。无论你是想构建一个简单的AR应用,还是处理机器人视觉数据,理解并亲手实现这套转换流程,都是你绕不开的第一步。我们将从最基础的坐标系定义开始,用清晰的代码片段和实际可运行的例子,带你走通整个链路,并分享一些在真实项目中调试参数、验证结果的实用经验。
1. 理解四大坐标系:从现实世界到数字像素
在开始写代码之前,我们必须对涉及的四个坐标系有一个清晰、直观的认识。很多初学者卡在第一步,就是因为对这些坐标系所处的“舞台”不够了解。
- 世界坐标系 (World Coordinate System):这是我们的“上帝视角”。它是一个三维直角坐标系,用于定义我们关心的物体在真实物理空间中的位置。比如,在机器人导航中,世界坐标系的原点可能设在地图上的某个固定点;在AR应用中,它可能就是你放置虚拟物体的那个桌面中心。它的单位通常是米(m)或毫米(mm)。
- 相机坐标系 (Camera Coordinate System):这是相机的“主观视角”。原点设在相机的光心(可以简单理解为镜头中心),Z轴沿着光轴指向拍摄方向,X轴和Y轴分别平行于图像的右方和下方。这个坐标系描述了“从相机看过去,物体在哪里”。
- 图像坐标系 (Image Coordinate System):这是一个二维坐标系,但它仍然使用物理单位(如毫米)。原点位于相机光轴与成像平面的交点(通常接近图像中心),X轴和Y轴方向与相机坐标系的X、Y轴平行。它描述的是三维点投影到二维成像平面上的物理位置。
- 像素坐标系 (Pixel Coordinate System):这是我们最终在电脑屏幕上看到的数字图像的坐标系。原点通常在图像的左上角,u轴(列)向右,v轴(行)向下。坐标值是整数,单位是像素(pixel)。这是所有图像处理库(如OpenCV)直接操作的坐标系。
注意:从图像坐标系到像素坐标系的转换,本质上是将物理尺寸(毫米)离散化为数字图像上的格子(像素)。这个转换由相机的传感器特性决定。
它们之间的转换关系链可以概括为:世界坐标 -> (刚体变换) -> 相机坐标 -> (透视投影) -> 图像坐标 -> (仿射变换) -> 像素坐标。下面这个表格帮你快速理清每个转换步骤的核心:
| 转换步骤 | 数学本质 | 所需关键参数 | 作用 |
|---|---|---|---|
| 世界->相机 | 刚体变换 (旋转R + 平移t) | 外参矩阵 [R | t] | 将点从全局世界描述,转换到以相机为中心的局部描述。 |
| 相机->图像 | 透视投影 (小孔成像模型) | 焦距 f | 将三维点投影到二维成像平面上,丢失深度信息。 |
| 图像->像素 | 仿射变换 (缩放 + 平移) | 内参矩阵 K | 将物理成像位置转换为图像传感器上的像素行列号。 |
理解了这条主线,我们就可以用代码来具象化每一个环节了。
2. 核心工具:NumPy与OpenCV的准备
在Python中,我们将主要依赖两个库:NumPy用于高效的矩阵运算,OpenCV (cv2) 则提供了计算机视觉相关的丰富函数,包括相机标定等。确保你已经安装了它们。
pip install numpy opencv-python
让我们先导入必要的库,并初始化一些后续会用到的示例数据。
import numpy as np
import cv2
# 为了方便演示,我们定义一个在世界坐标系中的点,例如一个距离原点1米远,在X、Y、Z方向上各有一些偏移的点。
# 单位:米 (m)
point_3d_world = np.array([0.5, 0.3, 2.0, 1.0]) # 注意这里使用了齐次坐标 [X, Y, Z, 1]
print(f"世界坐标系下的点 (齐次坐标): {point_3d_world}")
这里出现了一个关键概念:齐次坐标。为了能用统一的矩阵乘法表示平移变换,我们在三维坐标 [X, Y, Z] 后面添加一个 1,变成 [X, Y, Z, 1]。这使得旋转和平移可以合并到一个4x4的变换矩阵中。在后续所有涉及坐标变换的步骤中,我们都会使用齐次坐标形式。
3. 第一步:从世界到相机——外参矩阵的构建与应用
这一步的目标是计算一个点相对于相机的位置。我们需要知道相机在世界坐标系中的“姿态”(旋转)和“位置”(平移),这些参数构成了相机外参。
假设我们通过某种方式(如标定板、传感器融合)得到了相机的姿态。通常,旋转可以用一个3x3的旋转矩阵 R 表示,平移用一个3x1的向量 t 表示。
# 示例:假设相机绕Y轴旋转了30度,并在世界坐标系中位于 (0.1, 0.2, 0) 的位置。
theta = np.radians(30) # 将角度转换为弧度
# 绕Y轴的旋转矩阵
R = np.array([
[np.cos(theta), 0, np.sin(theta)],
[0, 1, 0],
[-np.sin(theta), 0, np.cos(theta)]
])
t = np.array([[0.1], [0.2], [0.0]]) # 平移向量,形状为(3,1)
print("旋转矩阵 R:")
print(R)
print("\n平移向量 t:")
print(t)
为了将旋转和平移合并为一个变换矩阵,我们构建一个4x4的外参矩阵 [R | t]。
# 构建外参矩阵 [R | t; 0 0 0 1]
extrinsic_matrix = np.eye(4) # 先创建一个4x4单位矩阵
extrinsic_matrix[:3, :3] = R # 左上角3x3块放入旋转矩阵
extrinsic_matrix[:3, 3:4] = t # 右边3x1块放入平移向量
# 最后一行保持为 [0, 0, 0, 1]
print("外参矩阵 (4x4):")
print(extrinsic_matrix)
现在,我们可以将世界坐标点转换到相机坐标系:
# 将世界坐标点转换到相机坐标系
point_3d_camera_homo = np.dot(extrinsic_matrix, point_3d_world)
# 注意:结果仍然是齐次坐标,但此时是相对于相机坐标系的原点
point_3d_camera = point_3d_camera_homo[:3] # 取前三个元素,得到非齐次的3D相机坐标
print(f"\n转换后的相机坐标系下的点 (Xc, Yc, Zc): {point_3d_camera}")
这个 point_3d_camera 的第三个分量 Zc(有时也写作 Z)非常重要,它代表了该点到相机光心的深度或距离,在下一步透视投影中会作为分母出现。
4. 第二步:从相机到图像——透视投影与内参矩阵
接下来,我们通过小孔成像模型,将三维的相机坐标点投影到二维的成像平面上。这一步的核心是焦距 f。同时,为了最终得到像素坐标,我们需要引入完整的相机内参矩阵K。
内参矩阵 K 包含了焦距、主点坐标以及可能的轴倾斜参数。
# 定义相机的内参。这些参数通常通过相机标定获得。
fx = 800.0 # x轴方向的焦距 (像素单位)
fy = 800.0 # y轴方向的焦距 (像素单位)
cx = 320.0 # 主点(光心)在像素坐标系中的u坐标
cy = 240.0 # 主点(光心)在像素坐标系中的v坐标
# 构建内参矩阵 K
K = np.array([
[fx, 0, cx],
[0, fy, cy],
[0, 0, 1]
])
print("相机内参矩阵 K:")
print(K)
提示:
fx = f / dx,fy = f / dy。其中f是物理焦距,dx,dy是传感器单个像素的物理尺寸。标定工具(如OpenCV的calibrateCamera)直接给出的是以像素为单位的fx,fy,这更方便我们使用。
透视投影的公式是:x' = Xc / Zc, y' = Yc / Zc。然后,通过内参矩阵将其转换为像素坐标的齐次形式:[u, v, 1]^T = K * [x', y', 1]^T。
让我们用代码实现:
# 从相机坐标系进行透视投影
Xc, Yc, Zc = point_3d_camera
# 避免除零错误
if Zc <= 0:
print("警告:点在相机后方,无法成像!")
point_2d_image_normalized = None
else:
# 归一化图像坐标 (在焦距f=1的成像平面上的坐标)
x_normalized = Xc / Zc
y_normalized = Yc / Zc
# 使用内参矩阵转换到像素坐标系 (齐次坐标)
point_2d_homo = np.dot(K, np.array([x_normalized, y_normalized, 1.0]))
# 将齐次坐标转换为二维像素坐标
u_pixel = int(point_2d_homo[0] / point_2d_homo[2]) # u = (fx*x' + cx)
v_pixel = int(point_2d_homo[1] / point_2d_homo[2]) # v = (fy*y' + cy)
print(f"\n归一化图像坐标 (x', y'): ({x_normalized:.3f}, {y_normalized:.3f})")
print(f"最终像素坐标 (u, v): ({u_pixel}, {v_pixel})")
实际上,OpenCV提供了一些辅助函数来简化这些步骤。例如,cv2.projectPoints 函数可以直接将一组3D点投影到图像平面,但理解其背后的手动计算过程至关重要,尤其是在调试和自定义投影模型时。
5. 整合与验证:完整的转换流程与代码封装
现在,我们把前两步合并,形成一个从世界坐标直接到像素坐标的完整函数。这个函数的核心是下面这个等式:
s * [u, v, 1]^T = K * [R | t] * [Xw, Yw, Zw, 1]^T
其中 s 是一个非零的尺度因子(实际上就是深度 Zc)。
让我们封装这个功能,并加入一些实用的验证和可视化。
def world_to_pixel(point_3d_world, R, t, K):
"""
将世界坐标系下的3D点转换到像素坐标系。
参数:
point_3d_world: 世界坐标系下的3D点,形状为(4,)的齐次坐标 [X, Y, Z, 1]。
R: 3x3旋转矩阵。
t: 3x1平移向量。
K: 3x3相机内参矩阵。
返回:
(u, v): 像素坐标。如果点在相机后方,返回 (None, None)。
"""
# 1. 构建外参矩阵
extrinsic = np.eye(4)
extrinsic[:3, :3] = R
extrinsic[:3, 3:4] = t.reshape(3, 1) # 确保t是列向量
# 2. 世界坐标 -> 相机坐标
point_cam_homo = np.dot(extrinsic, point_3d_world)
Xc, Yc, Zc = point_cam_homo[:3]
# 3. 检查深度
if Zc <= 1e-6: # 使用一个很小的阈值
print(f"点 {point_3d_world[:3]} 在相机后方或深度为0。")
return None, None
# 4. 透视投影 + 内参变换
# 方法一:分步计算(易于理解)
x_normalized = Xc / Zc
y_normalized = Yc / Zc
point_pixel_homo = np.dot(K, np.array([x_normalized, y_normalized, 1.0]))
u = int(round(point_pixel_homo[0] / point_pixel_homo[2]))
v = int(round(point_pixel_homo[1] / point_pixel_homo[2]))
# 方法二:合并计算(更高效)
# 合并投影矩阵 P = K * [R | t]
# P = np.dot(K, np.hstack((R, t.reshape(3,1))))
# point_pixel_homo = np.dot(P, point_3d_world)
# u = int(round(point_pixel_homo[0] / point_pixel_homo[2]))
# v = int(round(point_pixel_homo[1] / point_pixel_homo[2]))
return u, v
# 测试我们的函数
u_test, v_test = world_to_pixel(point_3d_world, R, t, K)
if u_test is not None:
print(f"\n使用封装函数计算得到的像素坐标: ({u_test}, {v_test})")
为了更直观地验证,我们可以创建一个简单的“虚拟图像”来标记投影点。
# 创建一个黑色的“虚拟图像”来可视化投影点
height, width = 480, 640 # 假设图像分辨率是640x480
virtual_img = np.zeros((height, width, 3), dtype=np.uint8)
if u_test is not None and 0 <= u_test < width and 0 <= v_test < height:
# 在图像上画一个红色的圆标记该点
cv2.circle(virtual_img, (u_test, v_test), 5, (0, 0, 255), -1)
# 添加文字标签
cv2.putText(virtual_img, f'({u_test},{v_test})', (u_test+10, v_test-10),
cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 255, 255), 1)
print(f"点已被投影到图像位置 ({u_test}, {v_test})")
# 在实际环境中,你可以用cv2.imshow显示图像,这里我们简单描述
# cv2.imshow('Projection', virtual_img); cv2.waitKey(0); cv2.destroyAllWindows()
else:
print("点未投影到图像有效区域内。")
6. 实战技巧:标定、畸变校正与逆向查找
理论上的理想模型在实际中会遇到各种问题。两个最常见的挑战是镜头畸变和参数获取(标定)。
相机标定是获取高精度内参(K矩阵)和畸变系数的标准流程。OpenCV的 cv2.calibrateCamera 函数是行业标准工具。你需要打印一张棋盘格标定板,从不同角度拍摄多张照片。
# 这是一个标定流程的伪代码框架,展示核心步骤
def calibrate_camera(image_paths, pattern_size=(9, 6)):
"""
使用棋盘格标定相机。
pattern_size: 棋盘格内角点的数量 (列数-1, 行数-1)。
"""
# 准备对象点 (3D, Z=0) 和图像点 (2D) 的列表
objpoints = [] # 真实世界中的3D点
imgpoints = [] # 图像中对应的2D点
# 为棋盘格定义3D坐标
objp = np.zeros((pattern_size[0]*pattern_size[1], 3), np.float32)
objp[:, :2] = np.mgrid[0:pattern_size[0], 0:pattern_size[1]].T.reshape(-1, 2)
for fname in image_paths:
img = cv2.imread(fname)
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
# 查找棋盘格角点
ret, corners = cv2.findChessboardCorners(gray, pattern_size, None)
if ret:
objpoints.append(objp)
# 提高角点检测精度
corners_refined = cv2.cornerSubPix(gray, corners, (11,11), (-1,-1),
criteria=(cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 30, 0.001))
imgpoints.append(corners_refined)
# 进行标定
ret, K, dist_coeffs, rvecs, tvecs = cv2.calibrateCamera(objpoints, imgpoints, gray.shape[::-1], None, None)
return ret, K, dist_coeffs, rvecs, tvecs
标定得到的 dist_coeffs 就是畸变系数(通常包含k1, k2, p1, p2, k3等)。在将点投影到图像时,必须先进行畸变校正。OpenCV提供了 cv2.undistortPoints 或 cv2.projectPoints(该函数已集成畸变校正)来处理。
# 假设我们已有标定得到的内参K和畸变系数dist
# 在投影时,使用cv2.projectPoints可以自动处理畸变
def project_points_with_distortion(object_points, rvec, tvec, K, dist):
"""
使用OpenCV函数投影点,包含畸变校正。
object_points: 世界坐标系下的点集,形状(N,3)。
rvec, tvec: 旋转向量和平移向量(OpenCV常用形式)。
"""
# 将旋转向量转换为旋转矩阵(如果需要)
R, _ = cv2.Rodrigues(rvec)
# 投影
projected_points, _ = cv2.projectPoints(object_points, rvec, tvec, K, dist)
# projected_points 的形状是 (N, 1, 2)
return projected_points.reshape(-1, 2)
最后,有时我们需要进行逆向操作:从像素坐标反推对应的三维射线(在相机坐标系下)。这被称为反投影。由于深度信息在投影过程中丢失,我们只能得到一条从光心出发穿过该像素的射线。
def pixel_to_camera_ray(u, v, K, dist_coeffs=None):
"""
将像素坐标反投影到相机坐标系下的归一化射线方向。
返回一个单位向量,表示从相机光心指向该像素方向。
"""
# 1. 将像素坐标转换为归一化平面坐标(去除内参和畸变影响)
point_pixel = np.array([[[u, v]]], dtype=np.float32)
if dist_coeffs is not None:
# 如果有畸变,先校正
point_undistorted = cv2.undistortPoints(point_pixel, K, dist_coeffs, P=K)
else:
# 无畸变,直接逆变换内参
point_undistorted = point_pixel
# point_undistorted 输出是在归一化平面上的坐标 (x', y', 1)
x_norm = point_undistorted[0, 0, 0]
y_norm = point_undistorted[0, 0, 1]
# 2. 构建相机坐标系下的射线方向向量 (Xc, Yc, 1),并归一化
ray_dir = np.array([x_norm, y_norm, 1.0])
ray_dir_normalized = ray_dir / np.linalg.norm(ray_dir)
return ray_dir_normalized
# 示例:反投影我们之前计算得到的像素点
ray = pixel_to_camera_ray(u_test, v_test, K)
print(f"\n从像素({u_test},{v_test})反投影得到的相机坐标系射线方向: {ray}")
掌握从正投影到反投影的完整循环,你就能在3D视觉应用中游刃有余,无论是进行三维重建、物体定位还是AR叠加。记住,所有理论最终都要服务于代码实现,而清晰的代码逻辑又反过来加深你对理论的理解。多动手实验,用不同的参数和点去测试你的转换函数,观察输出如何变化,这是掌握3D视觉坐标转换最快的方式。
更多推荐
所有评论(0)