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.undistortPointscv2.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视觉坐标转换最快的方式。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐