简介:这份资源是光束法区域网平差的VC++实现代码,面向摄影测量、三维重建方向的学习者与开发者,用于解决多视图几何中相机外方位元素与场景点坐标的联合优化问题。压缩包共32个文件,约55KB,以h头文件与cpp源文件为主体,辅以txt实验数据与结果文件、ico图标及dsp/dsw工程配置,构成一套可直接编译运行的MFC工程。代码围绕最小二乘原理展开,重点解算三个旋转角与三个平移量构成的外方位元素,并给出重投影误差逐步收敛的实验数据与弧度、角度两种结果输出,便于对照验证算法正确性。目前已有1320人学习下载,适合具备C++与线性代数基础、希望深入理解光束法平差计算流程的读者,可据此掌握矩阵运算、迭代优化与摄影测量解算的完整实现思路。
1. 光束法区域网平差 VC 代码:从一堆散点坐标到高精度外方位元素的落地路径
如果你手头有一批摄影测量控制点和像点坐标,想解出每张影像的外方位元素,同时把加密点坐标一并平差出来,那光束法区域网平差基本是绕不开的一步。它把共线方程当观测方程,把像点坐标当观测值,把外方位元素和地面点坐标一起当未知数,整体做最小二乘。听起来是教科书内容,但真到写代码那一步,法方程怎么组、稀疏矩阵怎么存、初值怎么给、迭代什么时候停,每一步都能卡住人。这份 VC 代码就是冲着这个场景来的:用 C++ 在 Visual Studio 环境下实现光束法区域网平差的完整流程,适合做摄影测量课程设计、测绘类算法验证,或者需要把平差内核嵌进自己工具链的开发者。它不依赖大型商业库,核心矩阵运算自己实现,方便你逐行看懂每个环节在干什么。
2. 光束法平差的数学模型与 VC 工程结构:先搞懂未知数怎么排
2.1 共线方程线性化与未知数排列
光束法的观测方程就是共线方程。对每个像点,有两个观测值 x、y,对应一组未知数:该点所在影像的 6 个外方位元素(Xs、Ys、Zs、φ、ω、κ),以及该地面点的 3 个坐标(X、Y、Z)。共线方程本身是非线性的,必须先线性化,得到误差方程:
V = A·δX - L
其中 A 是设计矩阵,δX 是未知数改正数向量,L 是观测值残差向量。线性化的关键是偏导数,对线元素求偏导相对直接,对角元素求偏导要经过旋转矩阵展开,这一步符号容易出错,代码里通常把旋转矩阵 R 的九个元素单独算出来再逐项求导。
未知数排列方式直接决定法方程的结构。常见做法是先排所有影像的外方位元素,再排所有地面点坐标。这样法方程系数矩阵 N = AᵀPA 会呈现分块结构,左上角是影像块,右下角是点块,两者之间的关联块对应“某点出现在某影像上”。这个结构是后面用改化法方程提速的基础。
提示:未知数编号一旦定下来,整个代码里所有索引都要跟它一致,中途改排列方式是翻车高发区。
2.2 VC 工程的文件划分与核心类
拿到这份 VC 代码,先别急着编译,把工程结构看一遍。典型划分是:
Matrix.h / Matrix.cpp:矩阵类,支持加减乘、转置、求逆、矩阵乘向量。光束法里大量用到这些小矩阵运算。Photo.h / Photo.cpp:影像类,存内方位元素、外方位元素、控制点列表、像点列表。Point.h / Point.cpp:地面点类,存坐标、是否控制点、出现在哪些影像上。BundleAdjust.h / BundleAdjust.cpp:平差主类,负责组误差方程、组法方程、解算、迭代。main.cpp:读数据、建对象、调平差、输出结果。
矩阵类是整个工程的地基。如果矩阵求逆写得有问题,后面所有结果都不可信。我一般会先单独测矩阵类:构造一个已知逆的矩阵,调Inverse()看结果对不对,再往下走。
// Matrix.cpp 中矩阵求逆的核心逻辑(高斯-约当法简化示意) bool Matrix::Inverse(Matrix& out) const { int n = rows; Matrix aug(n, 2 * n); // 左半部分放原矩阵,右半部分放单位阵 for (int i = 0; i < n; i++) { for (int j = 0; j < n; j++) { aug(i, j) = (*this)(i, j); aug(i, j + n) = (i == j) ? 1.0 : 0.0; } } // 逐列消元,主元太小说明矩阵接近奇异 for (int k = 0; k < n; k++) { double pivot = aug(k, k); if (fabs(pivot) < 1e-12) return false; // 奇异矩阵,直接失败 for (int j = 0; j < 2 * n; j++) aug(k, j) /= pivot; for (int i = 0; i < n; i++) { if (i == k) continue; double factor = aug(i, k); for (int j = 0; j < 2 * n; j++) aug(i, j) -= factor * aug(k, j); } } // 取出右半部分作为逆矩阵 out.Resize(n, n); for (int i = 0; i < n; i++) for (int j = 0; j < n; j++) out(i, j) = aug(i, j + n); return true; }这段代码里1e-12是主元阈值,太小说明矩阵病态,直接返回失败比硬算出一个垃圾结果要好。实际平差中法方程矩阵条件数可能很大,所以这个判断很有必要。Resize是矩阵类自己的尺寸调整方法,保证输出矩阵维度正确。
2.3 数据准备:控制点与像点坐标的格式约定
代码跑不跑得起来,一半取决于数据格式对不对。这份 VC 代码通常约定输入为文本文件,分两类:控制点文件和像点文件。控制点文件每行是点号、X、Y、Z;像点文件每行是影像号、点号、x、y。控制点同时有地面坐标和像点坐标,加密点只有像点坐标。
# 控制点文件 ctrl.txt 示例 # 点号 X(m) Y(m) Z(m) G01 512345.678 3456789.012 45.678 G02 512400.123 3456800.456 46.012 # 像点文件 img.txt 示例 # 影像号 点号 x(mm) y(mm) 1 G01 -12.345 45.678 1 G02 23.456 -10.123 2 G01 -11.987 44.321读数据时要注意:像点坐标单位是毫米还是像素,内方位元素焦距和主点坐标单位要统一。如果焦距是毫米、像点坐标是像素,那必须先换算,否则平差结果会离谱到你以为算法写错了。
3. 从误差方程到法方程:VC 代码里的组阵与解算步骤
3.1 逐点组误差方程与设计矩阵填充
平差主循环里,第一步是遍历所有像点,为每个像点生成两行误差方程。每行的设计矩阵系数对应该像点涉及的未知数:6 个外方位元素 + 3 个地面点坐标。系数就是共线方程对各未知数的偏导。
// BundleAdjust.cpp 中为单个像点组误差方程的核心片段 void BundleAdjust::AddImagePoint(Photo& photo, GroundPoint& pt, double x, double y) { // 计算旋转矩阵 R double R[3][3]; ComputeRotation(photo.phi, photo.omega, photo.kappa, R); // 计算像点对应的近似地面坐标投影 double X = pt.X, Y = pt.Y, Z = pt.Z; double Xs = photo.Xs, Ys = photo.Ys, Zs = photo.Zs; double dX = X - Xs, dY = Y - Ys, dZ = Z - Zs; double Xbar = R[0][0]*dX + R[0][1]*dY + R[0][2]*dZ; double Ybar = R[1][0]*dX + R[1][1]*dY + R[1][2]*dZ; double Zbar = R[2][0]*dX + R[2][1]*dY + R[2][2]*dZ; // 共线方程计算近似像坐标 double xApprox = -photo.f * Xbar / Zbar; double yApprox = -photo.f * Ybar / Zbar; // 常数项 L = 观测值 - 近似值 double Lx = x - xApprox; double Ly = y - yApprox; // 填充设计矩阵系数(偏导数),此处省略具体偏导展开 // 每个像点两行,每行对应若干未知数列 FillDesignMatrix(photo, pt, R, Xbar, Ybar, Zbar, xApprox, yApprox); }ComputeRotation按 φ-ω-κ 转角系统生成旋转矩阵,顺序不能乱。xApprox、yApprox是用当前近似值算出来的像坐标,和实际观测值相减得到常数项。FillDesignMatrix里填的就是偏导数,这部分符号最容易错,建议每写一个偏导就用数值微分验证一下。
3.2 法方程组建与改化法方程提速
所有像点的误差方程组完后,法方程矩阵 N = AᵀPA 和右端项 W = AᵀPL 就出来了。如果直接对 N 求逆,未知数一多内存和时间都吃不消。光束法的标准做法是用改化法方程:先消去地面点未知数,只解外方位元素,再回代求地面点。
// 改化法方程解算流程示意 // N11: 外方位元素块 N12: 关联块 N22: 地面点块 // 消去地面点:N11' = N11 - N12 * N22^-1 * N12^T // W1' = W1 - N12 * N22^-1 * W2 // 解 N11' * deltaExt = W1' 得到外方位元素改正数 // 回代:deltaPt = N22^-1 * (W2 - N12^T * deltaExt)N22是块对角矩阵,每个地面点对应一个 3×3 小块,求逆非常快。N11的维度是影像数×6,通常远小于总未知数。这样解算规模从“影像数×6 + 点数×3”降到“影像数×6”,对区域网平差这种点数远多于影像数的场景,提速非常明显。
注意:改化法方程要求每个地面点至少出现在两张影像上,否则 N22 对应块奇异,回代会失败。数据里如果有单影像点,要么删掉,要么当无地面坐标的像点单独处理。
3.3 迭代收敛判断与结果输出
平差是迭代过程:每次解出改正数,加到近似值上,重新组方程,直到改正数足够小。收敛判断一般看外方位元素改正数的最大值,或者看单位权中误差的变化。
// 迭代主循环 for (int iter = 0; iter < maxIter; iter++) { BuildNormalEquation(); // 组法方程 SolveReducedNormalEquation(); // 解改化法方程 UpdateUnknowns(); // 更新未知数近似值 double maxCorr = GetMaxCorrection(); double sigma0 = ComputeSigma0(); // 输出每次迭代信息,方便观察收敛过程 printf("Iter %d: maxCorr=%.6f sigma0=%.6f\n", iter, maxCorr, sigma0); if (maxCorr < 1e-6) break; // 改正数足够小,收敛 }maxIter一般设 10 到 20,1e-6是改正数阈值,单位要和未知数单位一致。外方位元素线元素单位是米,角元素是弧度,如果阈值统一用 1e-6,角元素收敛会偏慢,可以分开判断。sigma0是单位权中误差,反映观测值精度,正常应该在像点坐标量测精度附近,如果大得离谱,说明粗差没处理或者初值太差。
4. 避坑与排查:光束法 VC 代码跑不通时先查这几处
4.1 现象:迭代不收敛,改正数越算越大
原因通常是初值太差或者偏导数符号错了。外方位元素初值如果离真值太远,共线方程线性化误差大,迭代会发散。偏导数符号错一个,法方程方向就反了,改正数自然往错误方向跑。
解决:先用单张影像空间后方交会算外方位元素初值,再送入区域网平差。偏导数逐项用数值微分对一遍,确认符号和量级都对。
4.2 现象:法方程矩阵奇异,求逆失败
原因可能是控制点不足、地面点只出现在一张影像上、或者未知数里有自由网方向。区域网如果没有足够控制点约束,法方程本身就秩亏。
解决:检查控制点数量,至少需要 3 个以上分布良好的控制点。单影像点要么删掉要么特殊处理。必要时加基准约束,比如固定某个点的坐标。
4.3 现象:平差结果 sigma0 很大,残差分布异常
原因通常是数据里有粗差,或者单位不统一。像点坐标量测粗差、控制点坐标录入错误、焦距和像点坐标单位不一致,都会让 sigma0 偏大。
解决:先做粗差探测,看残差最大的几个点是不是明显离群。统一所有长度单位到毫米或米,焦距、主点、像点坐标、控制点坐标全部对齐。
4.4 现象:程序编译通过但运行崩溃
原因可能是数组越界或者矩阵维度不匹配。VC 代码里矩阵索引从 0 开始,如果按 1 开始写循环就会越界。设计矩阵列数要和未知数总数一致,少一列多一列都会在矩阵乘法时崩。
解决:在矩阵类里加维度断言,Matrix::operator()里检查行列范围。组设计矩阵时打印未知数总数和实际填充列数,对不上就停下来查。
4.5 现象:结果和商业软件差很多
原因可能是迭代没收敛就退出,或者改化法方程回代写错。回代公式里N12^T的转置方向容易搞反,导致地面点坐标更新错误。
解决:把迭代次数调大,观察 sigma0 是否稳定。回代部分单独用一个小例子验证:固定外方位元素,只解地面点,看结果对不对。
5. 进阶用法:用模拟数据验证平差内核与精度评估
代码能跑通只是第一步,怎么确认它算得对,才是真正花时间的地方。我一般会先用模拟数据做闭环验证:给定一组真值外方位元素和地面点坐标,用共线方程正算生成像点坐标,再加一点随机噪声,然后送入平差程序,看解出来的外方位元素和地面点坐标能不能回到真值附近。
// 模拟数据生成:由真值正算像点坐标 void GenerateSimulatedData(vector<Photo>& photos, vector<GroundPoint>& points) { for (auto& pt : points) { for (auto& ph : photos) { if (!IsVisible(ph, pt)) continue; // 不在视场内的点跳过 double R[3][3]; ComputeRotation(ph.phi, ph.omega, ph.kappa, R); double dX = pt.X - ph.Xs; double dY = pt.Y - ph.Ys; double dZ = pt.Z - ph.Zs; double Xbar = R[0][0]*dX + R[0][1]*dY + R[0][2]*dZ; double Ybar = R[1][0]*dX + R[1][1]*dY + R[1][2]*dZ; double Zbar = R[2][0]*dX + R[2][1]*dY + R[2][2]*dZ; double x = -ph.f * Xbar / Zbar + ph.x0; double y = -ph.f * Ybar / Zbar + ph.y0; // 加高斯噪声,模拟量测误差 x += GaussianNoise(0.0, 0.005); // 5 微米量级 y += GaussianNoise(0.0, 0.005); ph.imagePoints.push_back({pt.id, x, y}); } } }IsVisible判断点是否在影像视场内,简单做法是用像坐标范围判断。GaussianNoise用 Box-Muller 变换生成正态分布随机数,标准差按实际量测精度给,像点坐标量测精度一般在 3 到 10 微米。生成数据后,把真值存一份,平差结果和真值逐项对比。
精度评估看几个指标:外方位元素线元素误差、角元素误差、地面点坐标误差、单位权中误差。如果模拟数据下误差和噪声水平匹配,说明平差内核正确。然后用真实数据跑,看 sigma0 和残差分布是否合理。
| 评估项 | 模拟数据预期 | 真实数据参考 |
|---|---|---|
| 线元素误差 | 与噪声同量级 | 取决于控制点精度 |
| 角元素误差 | 与噪声/焦距相关 | 通常优于 1e-5 rad |
| 地面点误差 | 与交会角相关 | 交会角好时接近像点精度 |
| sigma0 | 接近噪声标准差 | 反映整体观测质量 |
从那以后我每次拿到新的平差代码,都强制先用模拟数据走一遍闭环,确认内核没问题再上真实数据。这一步多花半小时,能省掉后面几天的排查。希望帮到你。
本文还有配套的精品资源,点击获取