☰
用 Eigen 求解两个三维坐标系之间的变换矩阵:从 SVD 到 RT 矩阵的完整推导与代码验证
2026/10/8 12:12:00 网站建设 项目流程

1. 三维坐标系变换矩阵到底在算什么

给定两组一一对应的三维点,一组来自坐标系 A,一组来自坐标系 B,我们要找一个 4×4 的齐次矩阵,把 A 里的点变换到 B 里。这个矩阵就是常说的 RT 矩阵:左上角 3×3 是旋转 R,右上角 3×1 是平移 t,最后一行固定为0 0 0 1。它能做什么?机器人手眼标定、激光雷达与相机外参、点云配准、SLAM 里的位姿估计,本质都是同一件事。适合谁?只要你在做三维视觉、机器人、自动驾驶感知,迟早会碰到它。

为什么不能直接解方程?因为点对通常带噪声,而且旋转矩阵必须满足正交性约束R^T R = I且det(R) = 1。直接最小二乘解出来的 3×3 矩阵往往不是正交的,会把刚体变换算成带缩放的仿射变换。SVD 的价值就在这里:它能在最小二乘意义下,把解投影回合法的旋转群 SO(3),同时处理反射(det 为负)的退化情况。

我试过用 OpenCV 的cv::SVDecomp写过一版,能跑,但工程里更常见的是 Eigen,因为 Eigen 是纯头文件、模板化、和 Ceres/g2o/PCL 无缝衔接。下面从原理到代码,把 SVD 求 RT 的完整链路走一遍,并给一组仿真点云验证残差和正交性。

核心公式先摆出来。设源点集{p_i}、目标点集{q_i},各自去质心后得到p_i'、q_i'。构造 3×3 协方差矩阵:

W = Σ p_i' * q_i'^T

对 W 做 SVD:W = U S V^T。则旋转R = V * diag(1,1,det(V U^T)) * U^T,平移t = centroid_q - R * centroid_p。那个 diag 里的 det 项就是用来修正反射的,当det(V U^T) < 0时把最小奇异值对应的方向翻转,保证det(R) = +1。

注意方向约定:W = Σ p_i' q_i'^T求出的 R 是把源变换到目标;如果你写成Σ q_i' p_i'^T,得到的 R 是反的,转置一下即可。这个坑我在标定时踩过,残差怎么都下不去,最后发现是点对顺序和协方差方向搞反了。

2. Eigen 环境准备与 TaoToken 接入配置

Eigen 本身不需要编译安装,下载解压后在 CMake 里include_directories指向即可。但实际项目里,我经常需要让 AI 辅助生成标定代码、排查 SVD 报错、解释四元数转欧拉角的符号问题,这时候一个稳定的模型调用入口能省很多事。TaoToken 提供统一的 API 入口,兼容 OpenAI 风格的请求格式,适合把模型对话能力接进你的开发流程。

先说 Eigen 的 CMake 配置,这是可复制的最小工程:

cmake_minimum_required(VERSION 3.10) project(rt_svd_demo) set(CMAKE_CXX_STANDARD 14) set(CMAKE_CXX_STANDARD_REQUIRED ON) # 指向你本地的 Eigen 解压目录 include_directories(/usr/local/include/eigen3) add_executable(rt_svd_demo main.cpp)

如果你用 apt 装过libeigen3-dev,头文件通常在/usr/include/eigen3,把路径换掉即可。验证 Eigen 是否可用,写一行#include <Eigen/Dense>能编译过就行。

接下来是 TaoToken 的接入配置。API 基地址是https://taotoken.net/api,注意这个地址不带任何查询参数。模型 ID 按你实际使用的填,比如对话场景常用的通用模型。配置可以写成 JSON,方便脚本读取:

{ "base_url": "https://taotoken.net/api", "api_key": "sk-你的密钥", "model": "你的模型ID", "timeout": 60 }

如果你用 Claude Code 这类编码工具,配置项对应的是 Base URL、API Key、Model ID 三件套,缺一不可。Base URL 填https://taotoken.net/api,Key 在控制台的 API Keys 页面生成,Model ID 按文档里的可用列表选。生成 Key 的入口在https://taotoken.net/api-keys,接入文档在https://taotoken.net/doc,遇到 401 先检查 Key 有没有带Bearer前缀、有没有多余空格。

需要说明的是,TaoToken 在这里的角色是模型调用入口,帮你生成和审查标定代码,不是替代 Eigen 或编译器。真正的矩阵运算还是在你本地跑。把配置准备好之后,就可以让模型帮你解释下面这段 SVD 代码的每一步了。

3. 可复制的 Eigen SVD 求解 RT 完整代码

这一节给全代码,直接复制进main.cpp就能编译运行。我把它拆成几个函数:去质心、构造协方差、SVD 分解、反射修正、组装 4×4 矩阵,最后加一个残差和正交性检查。

#include <Eigen/Dense> #include <Eigen/Geometry> #include <iostream> #include <vector> #include <array> #include <cmath> // 求解 source -> target 的刚体变换 // 返回 4x4 齐次矩阵,失败返回 false bool solveRTBySVD(const std::vector<Eigen::Vector3d>& source, const std::vector<Eigen::Vector3d>& target, Eigen::Matrix4d& T_out) { if (source.size() != target.size() || source.size() < 3) { std::cerr << "点对数量不匹配或少于3组" << std::endl; return false; } const int N = static_cast<int>(source.size()); // 1. 求质心 Eigen::Vector3d c_src = Eigen::Vector3d::Zero(); Eigen::Vector3d c_dst = Eigen::Vector3d::Zero(); for (int i = 0; i < N; ++i) { c_src += source[i]; c_dst += target[i]; } c_src /= N; c_dst /= N; // 2. 去质心并构造协方差 W = sum(src' * dst'^T) Eigen::Matrix3d W = Eigen::Matrix3d::Zero(); for (int i = 0; i < N; ++i) { Eigen::Vector3d ps = source[i] - c_src; Eigen::Vector3d pt = target[i] - c_dst; W += ps * pt.transpose(); } // 3. SVD 分解 Eigen::JacobiSVD<Eigen::Matrix3d> svd( W, Eigen::ComputeFullU | Eigen::ComputeFullV); Eigen::Matrix3d U = svd.matrixU(); Eigen::Matrix3d V = svd.matrixV(); // 4. 反射修正,保证 det(R) = +1 Eigen::Matrix3d R = V * U.transpose(); if (R.determinant() < 0) { Eigen::Matrix3d D = Eigen::Matrix3d::Identity(); D(2, 2) = -1; R = V * D * U.transpose(); } // 5. 平移 Eigen::Vector3d t = c_dst - R * c_src; // 6. 组装 4x4 T_out = Eigen::Matrix4d::Identity(); T_out.block<3, 3>(0, 0) = R; T_out.block<3, 1>(0, 3) = t; return true; }

这段代码和 OpenCV 版的逻辑完全一致,但用 Eigen 写更短、类型更安全。几个关键点:JacobiSVD要显式指定ComputeFullU | ComputeFullV,否则拿不到完整的 U 和 V;反射修正那一步不能省,否则遇到退化点云会算出镜像变换;R = V * U.transpose()的方向取决于 W 的构造方式,我这里是src' * dst'^T,对应 source 到 target。

再补一个四元数转换和欧拉角输出,方便和 RT 矩阵对照:

// R 转四元数 Eigen::Quaterniond q(R); q.normalize(); // 四元数转 ZYX 欧拉角 (yaw, pitch, roll) Eigen::Vector3d euler = q.toRotationMatrix().eulerAngles(2, 1, 0); // euler[0]=yaw(Z), euler[1]=pitch(Y), euler[2]=roll(X)

注意eulerAngles(2,1,0)返回的顺序是 Z、Y、X,也就是 yaw、pitch、roll。很多人在这里搞混,导致和 RT 矩阵对比时对不上。四元数和旋转矩阵是等价的,q.toRotationMatrix()应该和 R 在数值上一致(差一个浮点误差),这可以作为一条验证。

4. 仿真点云验证:残差与正交性约束

光有代码不够,得验证。构造一组已知变换的仿真点云:绕 Z 轴转 90 度,再沿 X 平移 50。取三组点,跑一遍看结果对不对。

int main() { // 已知真值:绕 Z 转 90 度 + X 平移 50 Eigen::Matrix3d R_gt; R_gt = Eigen::AngleAxisd(M_PI / 2.0, Eigen::Vector3d::UnitZ()); Eigen::Vector3d t_gt(50, 0, 0); // 源点 std::vector<Eigen::Vector3d> src = { {100, 0, 0}, {0, 100, 0}, {0, 0, 100} }; // 目标点 = R_gt * src + t_gt std::vector<Eigen::Vector3d> dst; for (auto& p : src) dst.push_back(R_gt * p + t_gt); Eigen::Matrix4d T; if (!solveRTBySVD(src, dst, T)) return -1; std::cout << "求解的 RT 矩阵:\n" << T << std::endl; // 正交性检查 Eigen::Matrix3d R = T.block<3, 3>(0, 0); Eigen::Matrix3d I = R.transpose() * R; std::cout << "R^T * R 与单位阵的误差: " << (I - Eigen::Matrix3d::Identity()).norm() << std::endl; std::cout << "det(R) = " << R.determinant() << std::endl; // 残差检查 double max_err = 0; for (size_t i = 0; i < src.size(); ++i) { Eigen::Vector4d ph(src[i].x(), src[i].y(), src[i].z(), 1.0); Eigen::Vector4d out = T * ph; Eigen::Vector3d diff = out.head<3>() - dst[i]; max_err = std::max(max_err, diff.norm()); } std::cout << "最大残差: " << max_err << std::endl; // 四元数对照 Eigen::Quaterniond q(R); std::cout << "四元数: " << q.coeffs().transpose() << std::endl; return 0; }

预期结果:R^T * R与单位阵误差在 1e-15 量级,det(R)等于 1,最大残差接近 0(浮点误差级别)。四元数应该是(0, 0, sin45°, cos45°)的系数形式,对应绕 Z 轴 90 度。如果残差很大,先检查点对顺序;如果 det 是 -1,说明反射修正没生效;如果正交性误差大,说明 R 没做投影。

再补一个带噪声的测试,给目标点加 0.01 的高斯噪声,看残差是否在合理范围。这时候残差不会为 0,但应该在噪声量级附近,且正交性依然严格成立——这正是 SVD 方法的优势:即使有噪声,输出的 R 仍然是合法旋转矩阵。

5. 常见报错排查:401、local proxy failed、reading choices

实际接入和编译过程中,报错集中在几类。我按真实遇到的顺序列一下。

401 Unauthorized:API Key 无效或没带上。检查请求头是不是Authorization: Bearer sk-xxx,Key 有没有复制完整,有没有多余换行。TaoToken 的 Key 在控制台生成后只显示一次,丢了就重新生成。如果用的是环境变量,确认echo $TAOTOKEN_API_KEY有值。

local proxy failed / connection refused:本地网络或代理配置问题。先确认base_url写的是https://taotoken.net/api,没有多余路径。如果你本地有 HTTP 代理环境变量,检查http_proxy、https_proxy是否指向了不可用的地址,必要时临时unset掉再试。这类报错和模型本身无关,是链路问题。

reading choices 报错 / 返回体解析失败:通常是响应不是预期的 JSON 结构。可能原因有:模型 ID 填错导致返回了错误页;请求体里stream参数和客户端解析方式不匹配;或者返回被中间层截断。先用 curl 直接打一次,看原始返回:

curl -s https://taotoken.net/api/v1/chat/completions \ -H "Authorization: Bearer sk-你的密钥" \ -H "Content-Type: application/json" \ -d '{"model":"你的模型ID","messages":[{"role":"user","content":"hi"}]}'

如果 curl 正常而客户端报错,问题在客户端配置。如果 curl 也报错,看返回的 error message。

OAuth / 认证失败:Claude Code 这类工具如果用 OAuth 流程,需要确认走的是 API Key 模式而不是账号登录模式。配置里 Base URL、Key、Model ID 三件套要同时正确,只填两个会失败。

Eigen 编译报错:JacobiSVD找不到,通常是没 include<Eigen/SVD>;AngleAxisd找不到,include<Eigen/Geometry>;模板报错一长串,先看第一行错误,往往是类型不匹配,比如Vector3f和Vector3d混用。统一用double能避免大部分精度和类型问题。

SVD 结果 R 不对:九成是协方差方向反了。把W += ps * pt.transpose()改成W += pt * ps.transpose(),R 会变成转置。另外确认点对是一一对应的,顺序错乱会直接导致错误结果。

6. 把 SVD 标定接进你的工程

代码跑通之后,落地还有几件事。第一,点对数量建议至少 4 组以上,3 组在无噪声时能解,但有噪声时自由度不够,残差会偏大。第二,如果点云来自不同传感器,先做时间同步和粗配准,SVD 只负责精配准。第三,旋转矩阵存下来之后,用四元数做插值和平滑,比直接对矩阵元素插值稳定得多。

如果你想让模型帮你审查这段标定代码、解释某个奇异值为什么接近 0、或者把 RT 矩阵转成 URDF 里的关节外参,可以把代码贴进模型对话里问。入口在https://taotoken.net/api对应的对话能力,配合前面配好的 Key 就能用。长期做编码和 Agent 任务的,可以看 Coding Plan 那条线,把模型调用固定下来,省得每次重新配。

最后留一个实用技巧:验证 RT 矩阵时,除了残差和正交性,再算一下R * R^T和R^T * R是否都接近单位阵,以及把变换应用到一个新点上,看结果是否符合预期。这三步做完,基本能排除 99% 的方向和符号错误。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询