2. 坐标系与变换:世界坐标系、相机坐标系、图像坐标系、像素坐标系及其转换关系

做视觉标定,说白了就是搞清楚「东西在哪」和「相机看到的东西在哪」之间的数学关系。我刚开始接触这个领域时,被这四个坐标系绕得晕头转向。后来在产线上调一个机械臂抓取项目,才真正体会到——坐标系搞不清楚,标定就是空中楼阁。

今天我们就把这四个坐标系彻底捋清楚。你想想看,一个物体从真实世界到相机照片,中间经历了什么?

2.1 四个坐标系,一个故事

我们先给每个坐标系一个直观的定义:

坐标系 符号 维度 一句话理解
世界坐标系 Ow-XwYwZw 3D (m/mm) 物体在真实世界中的位置,我们说了算
相机坐标系 Oc-XcYcZc 3D (m/mm) 以相机光心为原点,Z轴指向镜头前方
图像坐标系 O-xy 2D (mm) 物理成像平面上的位置,单位是毫米
像素坐标系 O0-uv 2D (pixel) 我们最终看到的图像,单位是像素

嗯,这里要注意:世界坐标系是我们自己定义的。你可以把原点放在工作台角落,也可以放在机械臂基座上。我个人习惯把世界坐标系原点放在标定板的第一个角点上,这样后续计算会省很多事。

2.2 从世界到相机:刚体变换

物体从世界坐标系到相机坐标系,只经历了一件事——刚体变换。说白了就是旋转加平移,物体本身不会变形。

数学表达很简单:

| Xc |   | R11 R12 R13 | | Xw |   | Tx |
| Yc | = | R21 R22 R23 | | Yw | + | Ty |
| Zc |   | R31 R32 R33 | | Zw |   | Tz |

写成齐次坐标形式更简洁:

| Xc |   | R11 R12 R13 Tx | | Xw |
| Yc | = | R21 R22 R23 Ty | | Yw |
| Zc |   | R31 R32 R33 Tz | | Zw |
| 1  |   | 0   0   0   1  | | 1  |

这里的 R 是 3×3 旋转矩阵,T 是 3×1 平移向量。它们合起来就是相机的外参。

我的经验:旋转矩阵有9个参数,但自由度只有3个(绕X、Y、Z轴的旋转角)。实际标定时,我们通常用旋转向量或欧拉角来表示,再转换成矩阵。OpenCV 的 Rodrigues() 函数就是干这个的。

2.3 从相机到图像:透视投影

这一步是核心。相机坐标系下的3D点,通过小孔成像模型投影到2D图像平面上。

公式很简单:

x = f * Xc / Zc
y = f * Yc / Zc

其中 f 是焦距,单位是毫米。这里有个关键点——Zc 不能为0,否则点就在相机光心平面上,投影无意义。

写成矩阵形式:

| x |   | f  0  0  0 | | Xc |
| y | = | 0  f  0  0 | | Yc |
| 1 |   | 0  0  1  0 | | Zc |
                      | 1  |
避坑指南:我曾经在一个项目中,把标定板放得离相机太近,导致部分角点的 Zc 值接近0,结果投影计算全乱了。后来我规定:标定板到相机的距离至少是焦距的5倍以上。

2.4 从图像到像素:离散化

图像坐标系是连续的毫米单位,但像素坐标系是离散的整数。这一步就是把物理尺寸转换成像素坐标。

公式:

u = x / dx + u0
v = y / dy + v0

其中:

  • dx, dy:每个像素在x和y方向的物理尺寸(mm/pixel)
  • u0, v0:图像中心(主点)的像素坐标

齐次形式:

| u |   | 1/dx  0    u0 | | x |
| v | = | 0     1/dy v0 | | y |
| 1 |   | 0     0    1  | | 1 |

2.5 合在一起:完整的投影方程

把上面三步串起来,就是完整的相机模型:

| u |   | fx  0   u0  0 | | R11 R12 R13 Tx | | Xw |
| v | = | 0   fy  v0  0 | | R21 R22 R23 Ty | | Yw |
| 1 |   | 0   0   1   0 | | R31 R32 R33 Tz | | Zw |
                           | 0   0   0   1  | | 1  |

简化写法:

s * [u, v, 1]^T = K * [R|T] * [Xw, Yw, Zw, 1]^T

其中:

  • K:内参矩阵(fx, fy, u0, v0)
  • [R|T]:外参矩阵
  • s:尺度因子(其实就是 Zc)
核心理解:整个标定过程,就是通过已知的3D点(世界坐标)和对应的2D点(像素坐标),反推出 K 和 [R|T] 中的参数。这就是张正友标定法的基本原理。

2.6 知识体系总览

下面这张图把整个变换链路画清楚了:

坐标系变换链路总览 世界坐标系 Ow-XwYwZw 刚体变换 相机坐标系 Oc-XcYcZc 透视投影 图像坐标系 O-xy (mm) 离散化 像素坐标系 O0-uv (pixel) 外参 [R|T] • 旋转矩阵 R (3×3) • 平移向量 T (3×1) • 描述相机在世界中的位姿 • 每个视角不同 内参 K • 焦距 fx, fy • 主点 u0, v0 • 畸变系数 k1, k2, p1, p2 • 相机固定后不变 完整投影方程:s·[u, v, 1]^T = K · [R|T] · [Xw, Yw, Zw, 1]^T

2.7 实际项目中的坐标系选择

我在几个项目中总结了一些坐标系选择的经验,分享给你:

  • 单相机2D测量:世界坐标系可以直接用图像坐标系代替,省去外参标定。但要注意,这样只能做平面测量,不能测高度。
  • 机械臂抓取:世界坐标系必须与机械臂基座标系对齐。我习惯用标定板建立两者之间的转换关系。
  • 多相机拼接:每个相机有自己的世界坐标系,最后统一到一个全局坐标系下。这里容易出问题的是——不同相机定义的Z轴方向可能不一致。
我曾经踩过的坑:在一个双目视觉项目中,我直接把左相机的相机坐标系当成了世界坐标系。结果右相机的标定结果怎么都对不上。后来才意识到——世界坐标系应该是独立的,不能偷懒用某个相机的坐标系代替。

2.8 坐标系变换的工程实现

在实际代码中,我们通常用齐次坐标来简化计算。下面是一个常用的变换函数:

// 世界坐标转像素坐标
cv::Point2f worldToPixel(cv::Point3f worldPt, 
                         cv::Mat cameraMatrix, 
                         cv::Mat distCoeffs, 
                         cv::Mat rvec, 
                         cv::Mat tvec) 
{
    std::vector<cv::Point3f> objectPoints;
    objectPoints.push_back(worldPt);
    
    std::vector<cv::Point2f> imagePoints;
    cv::projectPoints(objectPoints, rvec, tvec, 
                      cameraMatrix, distCoeffs, imagePoints);
    
    return imagePoints[0];
}

反过来,从像素坐标到世界坐标需要知道深度信息:

// 像素坐标转相机坐标(已知深度Zc)
cv::Point3f pixelToCamera(cv::Point2f pixelPt, float depth,
                          cv::Mat cameraMatrix) 
{
    float fx = cameraMatrix.at<double>(0,0);
    float fy = cameraMatrix.at<double>(1,1);
    float cx = cameraMatrix.at<double>(0,2);
    float cy = cameraMatrix.at<double>(1,2);
    
    float x = (pixelPt.x - cx) * depth / fx;
    float y = (pixelPt.y - cy) * depth / fy;
    
    return cv::Point3f(x, y, depth);
}
小技巧:调试时,我经常用已知尺寸的标定板来验证变换是否正确。比如标定板上两个角点的实际距离是30mm,投影到像素坐标后反算距离,如果误差在0.5像素以内,说明变换没问题。

好了,坐标系变换这部分就讲到这里。记住一句话:标定的本质,就是找到从世界坐标到像素坐标的映射关系。后面的章节我们会在这个基础上,一步步搭建完整的标定流程。