简介:这是一份面向摄影测量学、计算机视觉课程学习者及C#开发者的单向空间后方交会实现工程,重点解决由单幅图像与地面控制点反求相机外方位元素、完成坐标转换与精度评定的问题。压缩包共31个文件,以cs源码、sln解决方案、csproj项目配置为主,辅以txt控制点与图像数据、exe可执行程序及少量缓存和资源文件,整体约63KB,结构紧凑,便于在Visual Studio中直接打开调试。已有245人学习下载。工程围绕针孔相机模型的内外参矩阵展开,包含控制点读取、图像坐标归一化、逆投影解算、最小二乘优化与误差输出等环节,并通过矩阵运算、算法封装、图像数据类与窗体交互模块组织代码,适合用来对照理解后方交会的数学推导、复现实验流程并排查数值计算中的常见问题。
1. 单像空间后方交会:只给 3 个控制点,凭什么能算出相机在哪
航测外业回来,作业员手上通常只有两样东西:一张拍了地面的照片,和几个实测过三维坐标的地面控制点。照片上能量出这些点的像点坐标,地面上知道它们的真实位置,问题就落在——拍摄那一瞬间,相机的六个外方位元素(Xs、Ys、Zs、φ、ω、κ)是多少。这套 C# 单像空间后方交会工程,就是把摄影测量学里那套共线条件方程做成一个能读数据、能迭代、能给出精度指标的控制台程序,专门解决这件事。
它适合两类人:正在啃摄影测量课、被偏导和迭代折腾得头大的学生,以及需要把后方交会塞进自己空三链路、又不想背一个庞大 SDK 的开发者。整个工程不依赖商业库,一个 .NET 控制台项目加几百行代码,六个未知数、法方程、精度评定全部摆在明面上,没有任何隐藏环节,参数可以改到能逐项核对。
2. 工程结构拆解:Core、Models、IO 三层怎么分工
一个只有几百行的算法工程,最容易写成一大坨 Main 函数,中间夹着文件读取、矩阵运算和三角函数,改一处全崩。这套 C# 单像空间后方交会项目按职责切成四块:Models放数据契约,Core放数学与解算,IO负责读写,Program只做编排。这不是为了显得工程化,而是因为后方交会天生有两套坐标系、三种角度单位、两个迭代开关,混在一起必然翻车。
2.1 共线条件方程决定了工程边界
整个工程的所有代码,本质上都在服务一个公式。地面点 (X, Y, Z) 经过旋转和平移变到像空间,再按焦距投影到像平面,得到像点坐标:
x - x0 = -f * (a1·ΔX + b1·ΔY + c1·ΔZ) / (a3·ΔX + b3·ΔY + c3·ΔZ) y - y0 = -f * (a2·ΔX + b2·ΔY + c2·ΔZ) / (a3·ΔX + b3·ΔY + c3·ΔZ)其中 ΔX = X - Xs,以此类推;a1…c3 是旋转矩阵的九个元素,由 φ、ω、κ 三个角决定。这个方程是非线性的——未知数既在分子上又在分母里,还藏在三角函数内部,所以没法直接解,只能线性化后迭代。
知道了这一点,工程边界就清楚了:输入必须包含内方位元素(f、x0、y0)、至少三个控制点的地面坐标和像点坐标;输出是六个外方位元素和一套精度指标;中间过程是“给初值 → 组误差方程 → 解法方程 → 更新 → 判收敛”。凡是超出这条链路的东西,比如粗差探测、多片联合平差、区域网平差,都不该塞进这个工程的主体,最多做成外挂模块。
我一般会先在纸上把这条链路写一遍,再决定类怎么切。链路只有五步,类就不该超过六个。
2.2 目录结构:四层各管一件事
工程用 .NET 6 控制台模板起,不引第三方包,目录长这样:
SpaceResection/ ├── SpaceResection.csproj ├── Program.cs ├── Models/ │ ├── ControlPoint.cs // 控制点:地面坐标 + 像点坐标 │ ├── InteriorOrientation.cs // 内方位元素:f, x0, y0 │ └── ExteriorOrientation.cs // 外方位元素 + 精度 ├── Core/ │ ├── Mat.cs // 转置、乘法、高斯-约当求逆 │ ├── Rotation.cs // φ-ω-κ → 3x3 旋转矩阵 │ ├── Collinearity.cs // 共线条件方程正算 │ └── SpaceResectionSolver.cs // 迭代解算主逻辑 ├── IO/ │ ├── ControlPointReader.cs // 读 CSV │ └── ResultWriter.cs // 输出结果与日志 └── Data/ └── control_points.csvModels里的类都是纯数据,不带任何计算,这样单元测试可以直接 new 出来喂给 Solver。Core里的四个文件职责互斥:Mat只认识double[,],Rotation只认识三个角,Collinearity只做正算,SpaceResectionSolver才持有迭代状态。IO是唯一碰文件系统的地方,换数据源只改这一层。
提示:
Core里不要出现Console.WriteLine。第一次写的时候我在求解器里打了十几行调试输出,后来想把它挂到 WinForm 上,满屏日志根本没法收场。
数据模型很简单,两个 record 加一个结果类:
// Models/ControlPoint.cs public sealed class ControlPoint { public string Id { get; init; } = ""; public double X { get; init; } // 地面 X,单位 m public double Y { get; init; } // 地面 Y,单位 m public double Z { get; init; } // 地面 Z,单位 m public double x { get; init; } // 像点 x,单位 mm(已含主点偏移前的原始观测) public double y { get; init; } // 像点 y,单位 mm } // Models/InteriorOrientation.cs public sealed class InteriorOrientation { public double F { get; init; } // 焦距,mm public double X0 { get; init; } // 主点 x0,mm public double Y0 { get; init; } // 主点 y0,mm }这里刻意把地面坐标定成米、像点坐标定成毫米,和国内教材的习惯一致。代价是后面误差方程的系数会带一个 1/1000 的量纲因子,法方程条件数会变差,这一点在第 5 章会专门说。另一种做法是全部换算成米,f也写成 0.152 这种形式,数值上更稳,但对读教材的人不友好。我选了前者,因为对照公式时不容易错。
2.3 数据契约:控制点文件长什么样
输入用最笨的 CSV,一行一个点,第一行是表头:
Id,X,Y,Z,x,y P1,5354.000,3521.000,120.500,12.345,-8.210 P2,5821.400,3488.900,118.200,41.882,-6.734 P3,5602.700,3910.300,122.800,26.117,9.855 P4,5140.600,3845.100,119.400,-3.264,8.402读取用File.ReadLines加Split(','),跳过表头,double.Parse时统一挂CultureInfo.InvariantCulture。这一条很关键:某些区域设置下小数点会被解析成逗号,12.345直接抛异常,我第一次在别人机器上跑就栽在这。
// IO/ControlPointReader.cs public static List<ControlPoint> Read(string path) { var list = new List<ControlPoint>(); foreach (var line in File.ReadLines(path).Skip(1)) // 跳过表头 { if (string.IsNullOrWhiteSpace(line)) continue; var f = line.Split(','); if (f.Length < 6) continue; list.Add(new ControlPoint { Id = f[0].Trim(), X = double.Parse(f[1], CultureInfo.InvariantCulture), Y = double.Parse(f[2], CultureInfo.InvariantCulture), Z = double.Parse(f[3], CultureInfo.InvariantCulture), x = double.Parse(f[4], CultureInfo.InvariantCulture), y = double.Parse(f[5], CultureInfo.InvariantCulture), }); } if (list.Count < 3) throw new InvalidDataException($"控制点只有 {list.Count} 个,后方交会至少需要 3 个"); return list; }参数说明:path是 CSV 路径;跳过表头用Skip(1);InvariantCulture保证小数点解析不受系统区域影响;末尾的数量校验是硬性的——三个点是六个方程解六个未知数的临界值,少于三个方程数不够,法方程直接奇异。
校验放在读取层而不是求解层,是为了让错误尽早暴露。等到进了迭代才发现点数不够,堆栈会指到矩阵求逆里,排查成本翻倍。
3. 算法核心:旋转矩阵到法方程的完整推导与落地
中间这一层是整个项目的心脏。摄影测量教材上这一章通常写满十几页偏导,落到代码里其实就三个函数:算旋转矩阵、算近似值、组误差方程。难点不在写,而在符号约定——φ-ω-κ 转角系统有好几种主轴定义,国内教材用 Y 轴为主轴的那一套,跟计算机图形学里的欧拉角顺序完全不同,抄错了整套结果就是错的。
3.1 旋转矩阵:九个元素与 φ-ω-κ 转角系统
按国内摄影测量通用的定义,旋转矩阵 R 的九个元素是:
| 元素 | 表达式 | 元素 | 表达式 |
|---|---|---|---|
| a1 | cosφcosκ − sinφsinωsinκ | b1 | cosωsinκ |
| a2 | −cosφsinκ − sinφsinωcosκ | b2 | cosωcosκ |
| a3 | −sinφcosω | b3 | −sinω |
| c1 | sinφcosκ + cosφsinωsinκ | c2 | −sinφsinκ + cosφsinωcosκ |
| c3 | cosφcosω |
这三列分别对应地面坐标轴 X、Y、Z 的方向余弦,写进代码就是一个 3×3 数组:
// Core/Rotation.cs public static class Rotation { // φ-ω-κ 转角系统(Y 轴为主轴),返回 3x3 旋转矩阵 // R[0,*] = a1 b1 c1 ; R[1,*] = a2 b2 c2 ; R[2,*] = a3 b3 c3 public static double[,] Build(double phi, double omega, double kappa) { double cp = Math.Cos(phi), sp = Math.Sin(phi); double co = Math.Cos(omega), so = Math.Sin(omega); double ck = Math.Cos(kappa), sk = Math.Sin(kappa); return new double[3, 3] { { cp * ck - sp * so * sk, -cp * sk - sp * so * ck, -sp * co }, { co * sk, co * ck, -so }, { sp * ck + cp * so * sk, -sp * sk + cp * so * ck, cp * co } }; } }参数说明:phi、omega、kappa全部是弧度。这一点必须写死在注释里,因为调用方十有八九会从度分秒转过来,忘了转就是第 5 章第一个坑。
三行分别对应 a、b、c 三组方向余弦。注意 R 是单位正交矩阵,R^T · R = I,这个性质可以直接拿来当单元测试:随便给三个角,构建 R,算R^T·R,如果不是单位阵,说明公式抄错了。我写这套代码时就是靠这个断言在五分钟内发现了 b3 的符号写反。
配套的正算是共线条件方程,后面组误差方程、算近似值都靠它:
// Core/Collinearity.cs public static class Collinearity { // 由外方位元素正算像点坐标,输出单位 mm public static void Project(double[] eo, ControlPoint p, InteriorOrientation io, out double xc, out double yc) { var R = Rotation.Build(eo[3], eo[4], eo[5]); double dX = p.X - eo[0], dY = p.Y - eo[1], dZ = p.Z - eo[2]; double Xb = R[0, 0] * dX + R[0, 1] * dY + R[0, 2] * dZ; double Yb = R[1, 0] * dX + R[1, 1] * dY + R[1, 2] * dZ; double Zb = R[2, 0] * dX + R[2, 1] * dY + R[2, 2] * dZ; if (Math.Abs(Zb) < 1e-9) throw new DivideByZeroException($"点 {p.Id} 的像空间 Z 接近 0,检查 Zs 初值"); xc = io.X0 - io.F * Xb / Zb; yc = io.Y0 - io.F * Yb / Zb; } }参数说明:eo是长度 6 的数组,顺序固定为[Xs, Ys, Zs, φ, ω, κ],用数组而不是类是为了迭代时直接下标更新;Zb是像空间 Z 分量,必须为正,为负说明点在相机背后,为 0 会直接除零。这个判断看起来多余,实际在迭代前两轮初值很差的时候经常触发,有了它你能立刻知道是初值问题还是数据问题。
3.2 近似值:为什么初值给不好会直接发散
后方交会是典型的非线性最小二乘,迭代收敛完全依赖初值。教材上写的经验做法是:Xs、Ys 取控制点地面坐标的平均值,φ = ω = κ = 0,Zs = 平均高程 + 航高。前三项好办,航高怎么办?很多示例代码直接硬编码一个 1000 米,换个数据集就跑不通。
工程里的做法是用比例尺估计。控制点之间的地面距离和对应像点距离之比,就是比例尺分母,取所有点对的中位数抗粗差:
// Core/SpaceResectionSolver.cs(片段) private static double EstimateScale(IReadOnlyList<ControlPoint> pts) { // 返回 m/mm:地面米 / 像点毫米,乘以焦距(mm) 直接得到航高(m) var ratios = new List<double>(); for (int i = 0; i < pts.Count; i++) for (int j = i + 1; j < pts.Count; j++) { double dG = Math.Sqrt(Sq(pts[i].X - pts[j].X) + Sq(pts[i].Y - pts[j].Y)); double dP = Math.Sqrt(Sq(pts[i].x - pts[j].x) + Sq(pts[i].y - pts[j].y)); if (dP > 1.0) ratios.Add(dG / dP); // 像点太近的两点舍弃,比例不稳定 } if (ratios.Count == 0) throw new InvalidDataException("像点重合度过高,无法估计比例尺"); ratios.Sort(); return ratios[ratios.Count / 2]; // 中位数 } private static double[] InitialGuess(IReadOnlyList<ControlPoint> pts, InteriorOrientation io) { double scale = EstimateScale(pts); // m/mm double xs = pts.Average(p => p.X); double ys = pts.Average(p => p.Y); double zs = pts.Average(p => p.Z) + io.F * scale; // 航高 = f(mm) * 比例尺(m/mm) return new[] { xs, ys, zs, 0.0, 0.0, 0.0 }; // 角元素初值全 0 }参数说明:scale的量纲是 m/mm,所以io.F * scale直接是米,量纲自洽,不用再除以 1000。dP > 1.0这个阈值(1 毫米)是经验值,两个像点靠得太近时比例尺估计的噪声会放大十倍。ratios.Count / 2取中位数,是因为控制点里偶尔会有一个坐标录错的粗差,均值会被它带偏。
初值里的 φ、ω、κ 全给 0,对应的是“相机垂直向下、像平面水平”的假设。对于常规航空摄影(倾角小于 3 度)这个假设足够好,迭代三五轮就收敛;对于大倾角影像,比如无人机倾斜摄影的侧视镜头,全 0 初值可能直接发散,得先做一次单应或者从 POS 数据里取近似姿态。
注意:如果你的数据来自带 POS 的无人机,别浪费——把 POS 里的经纬高和姿态角转成外方位元素当初值,收敛速度和稳定性完全是两个量级。这算是血泪经验,我第一版死磕全 0 初值,倾斜镜头那几张图迭代了 40 轮还在震荡。
3.3 误差方程系数:逐项推导与组装
线性化的核心是把共线条件方程对六个未知数求偏导。教材上给出的结果(对每个控制点列出两个方程)系数如下,这里的 x、y 是扣除主点后的像点坐标:
// Core/SpaceResectionSolver.cs(片段:单个控制点的误差方程系数) // x = p.x - x0, y = p.y - y0,单位 mm double a1 = R[0,0], b1 = R[0,1], c1 = R[0,2]; double a2 = R[1,0], b2 = R[1,1], c2 = R[1,2]; double a3 = R[2,0], b3 = R[2,1], c3 = R[2,2]; double ck = Math.Cos(kappa), sk = Math.Sin(kappa); double co = Math.Cos(omega), so = Math.Sin(omega); // 对 Xs, Ys, Zs 的偏导(分母 Zbar 为像空间 Z 分量,单位 m) A[2*i, 0] = (a1 * f + a3 * x) / Zbar; A[2*i, 1] = (b1 * f + b3 * x) / Zbar; A[2*i, 2] = (c1 * f + c3 * x) / Zbar; // 对 φ, ω, κ 的偏导 A[2*i, 3] = y * so - ((x / f) * (x * ck - y * sk) + f * sk) * co; A[2*i, 4] = -f * ck - (x / f) * (x * sk + y * ck); A[2*i, 5] = y; A[2*i+1, 0] = (a2 * f + a3 * y) / Zbar; A[2*i+1, 1] = (b2 * f + b3 * y) / Zbar; A[2*i+1, 2] = (c2 * f + c3 * y) / Zbar; A[2*i+1, 3] = -x * so - ((y / f) * (x * ck - y * sk) - f * ck) * co; A[2*i+1, 4] = -f * sk - (y / f) * (x * sk + y * ck); A[2*i+1, 5] = -x;参数说明:A是 2n×6 的设计矩阵,n 是控制点数,每个点占两行(x 方向和 y 方向各一个观测方程)。Zbar是该点在像空间的 Z 分量,单位米。f、x、y单位毫米,所以前三个系数带 mm/m 的量纲因子,后三个是 mm/rad,这是正常的,不用人为补齐。
角元素那六项看着吓人,其实结构很清楚:对 φ 的偏导含 sinω、cosω 和 κ 的组合,对 ω 的偏导只有 cosκ、sinκ,对 κ 的偏导退化成一个简单的一次式。这个结构不是巧合,它反映了转角系统的层级关系——κ 是绕主轴的旋转,影响最直接;φ 是最外层的转角,耦合最多。
写完解析偏导,务必做一次数值验证。用中心差分把每个偏导算一遍,和解析值对比,相对误差大于 1e-5 就说明符号或者公式有问题:
// 数值验证解析偏导(独立的小工具,不进主流程) static double NumericDeriv(double[] eo, ControlPoint p, InteriorOrientation io, int k) { const double h = 1e-7; // 步长:位置量用 1e-4 更合适,角度用 1e-7 var e1 = (double[])eo.Clone(); e1[k] += h; var e2 = (double[])eo.Clone(); e2[k] -= h; Collinearity.Project(e1, p, io, out double x1, out _); Collinearity.Project(e2, p, io, out double x2, out _); return (x1 - x2) / (2 * h); }参数说明:h的选取要匹配变量量纲,位置量(米)用 1e-4 到 1e-3,角度(弧度)用 1e-7 到 1e-6;太大会引入截断误差,太小会被浮点精度吃掉。这个函数只对 x 分量演示,y 分量同理。我把它做成了一个#if DEBUG下的断言,只在调试构建时跑,正式运行不增加开销。
实际用下来,这个自检拦下过至少三次错误:一次是 a24 的括号位置写错,一次是x忘了减主点,一次是 ω 的符号反了。三次如果靠肉眼看迭代结果,根本发现不了——迭代居然还能收敛,只是残差大得离谱。
3.4 法方程求解与迭代收敛
所有点的误差方程组装完,就是标准的最小二乘:V = A·δ − L,法方程AᵀA·δ = AᵀL,解出改正数 δ 加到当前值上,循环到收敛。常数项L是观测值减计算值:
// Core/SpaceResectionSolver.cs(主循环) public ExteriorOrientation Solve(IReadOnlyList<ControlPoint> pts, InteriorOrientation io) { double[] eo = InitialGuess(pts, io); int n = pts.Count; var A = new double[2 * n, 6]; var L = new double[2 * n, 1]; double[,] invN = null; double lastMax = double.MaxValue; for (int iter = 1; iter <= MaxIter; iter++) { var R = Rotation.Build(eo[3], eo[4], eo[5]); for (int i = 0; i < n; i++) { var p = pts[i]; double dX = p.X - eo[0], dY = p.Y - eo[1], dZ = p.Z - eo[2]; double Xb = R[0,0]*dX + R[0,1]*dY + R[0,2]*dZ; double Yb = R[1,0]*dX + R[1,1]*dY + R[1,2]*dZ; double Zb = R[2,0]*dX + R[2,1]*dY + R[2,2]*dZ; double x = p.x - io.X0, y = p.y - io.Y0; double xc = -io.F * Xb / Zb, yc = -io.F * Yb / Zb; FillRow(A, i, R, io.F, x, y, Zb, eo[4], eo[5]); // 见 3.3 的系数 L[2*i, 0] = x - xc; // 观测 - 计算 L[2*i+1, 0] = y - yc; } var N = Mat.Mul(Mat.Transpose(A), A); var U = Mat.Mul(Mat.Transpose(A), L); invN = Mat.Inverse(N); // 奇异会抛异常,别吞掉 var dx = Mat.Mul(invN, U); for (int k = 0; k < 6; k++) eo[k] += dx[k, 0]; double maxAbs = Enumerable.Range(0, 6).Max(k => Math.Abs(dx[k, 0])); if (maxAbs < Tolerance) { Iterations = iter; break; } if (maxAbs > lastMax * 10) throw new InvalidOperationException($"第 {iter} 轮改正数放大 10 倍,迭代发散"); lastMax = maxAbs; } // ... 精度评定见 4.2 }参数说明:MaxIter默认 20,超过基本可以判定不收敛;Tolerance默认 1e-6,含义是位置改正数(米)和角度改正数(弧度)都小于这个值——混用量纲有点糙,但实用,因为 1e-6 弧度约等于 0.2 角秒,1e-6 米等于 1 微米,都远小于实际精度;lastMax是上一轮的改正数,用来做发散检测,正常迭代改正数应该单调下降。
提示:
Tolerance别设成 1e-10 这种值。浮点精度在法方程求逆那一步就损失掉了,设太严只会白跑满 20 轮,然后你以为是算法不对。
收敛后invN就是(AᵀA)⁻¹,第 4 章算精度要用它,所以它在循环外面声明、在循环里面赋值。这个细节不做的话,精度评定要么重新求一次逆,要么拿不到。
4. 执行链路:从读 CSV 到输出六个外方位元素
到这一章,算法部分已经齐了,剩下的是把它串起来、把精度算出来、把结果打出来。这三件事看着琐碎,实际上决定了这个工具能不能在真实作业里用——一个只输出六个数的程序,你永远不知道结果可不可信。
4.1 主流程:从读文件到输出六参数
Program.cs只做编排,一行计算逻辑都不写:
// Program.cs var dataDir = Path.Combine(AppContext.BaseDirectory, "Data"); var pts = ControlPointReader.Read(Path.Combine(dataDir, "control_points.csv")); var io = new InteriorOrientation { F = 152.000, // 焦距 mm X0 = 0.000, // 主点 x0 mm Y0 = 0.000 // 主点 y0 mm }; var solver = new SpaceResectionSolver { MaxIter = 20, Tolerance = 1e-6 }; var result = solver.Solve(pts, io); ResultWriter.Write(Console.Out, result, pts.Count);参数说明:AppContext.BaseDirectory是程序集所在目录,配合 csproj 里把Data/*.csv设成CopyToOutputDirectory=PreserveNewest,发布后不用改路径;F、X0、Y0从相机标定报告里抄,单位毫米,X0、Y0是主点相对像幅中心的偏移,很多相机标定出来是零点几毫米,如果恰好接近 0 可以填 0,但别假设它就是 0。
运行命令就一条:
dotnet run --project SpaceResection/SpaceResection.csproj -c Release-c Release不是可选的。求解器里那个数值验证在#if DEBUG下跑,Debug 构建每轮迭代会多算 12 次正算,四个点的数据集看不出来,三十个点的数据集能慢一个数量级。
数据量上的经验值:3 个控制点是理论下限,实际作业建议 6 个以上。原因不只是精度,还在于多余观测——4 个点有 8 个观测方程、6 个未知数,多余观测数 2,能给出单位权中误差;3 个点时多余观测为 0,残差恒为 0,精度评定完全没意义。
4.2 精度评定:单位权中误差和协方差矩阵
最小二乘解算自带精度信息,不利用就浪费了。核心是两个量:单位权中误差 σ0 和未知数的协方差矩阵。
// Core/SpaceResectionSolver.cs(片段:收敛后) int r = 2 * n - 6; // 多余观测数 if (r > 0) { double vtv = 0; for (int i = 0; i < 2 * n; i++) { double vi = 0; for (int j = 0; j < 6; j++) vi += A[i, j] * lastDx[j]; vi -= L[i, 0]; // v = A·δ − L vtv += vi * vi; } double sigma0 = Math.Sqrt(vtv / r); // 单位权中误差,单位同像点坐标(mm) result.Sigma0 = sigma0; result.StdDev = new double[6]; for (int k = 0; k < 6; k++) result.StdDev[k] = sigma0 * Math.Sqrt(invN[k, k]); // 中误差 }参数说明:r = 2n - 6是多余观测数,n = 4时r = 2,n = 3时r = 0,除零,所以外面套了if (r > 0);vtv是残差平方和,单位是 mm²;sigma0的单位跟着观测值走,是毫米,物理含义是“一次像点量测的中误差”;invN[k,k]是协方差矩阵对角元,开方再乘sigma0就得到第 k 个未知数的中误差。
输出里位置量的中误差单位是米,角度量的中误差单位是弧度,混在一张表里容易看错。我一般会同时给出换算后的秒值:
| 未知数 | 解算值 | 中误差 | 换算 |
|---|---|---|---|
| Xs | 5123.482 m | 0.021 m | 2.1 cm |
| Ys | 3412.117 m | 0.019 m | 1.9 cm |
| Zs | 1250.336 m | 0.048 m | 4.8 cm |
| φ | 0.012345 rad | 1.8e-5 rad | 3.7″ |
| ω | −0.004321 rad | 1.5e-5 rad | 3.1″ |
| κ | 0.623100 rad | 1.1e-5 rad | 2.3″ |
拿到这张表要会看:Zs 的中误差通常是 Xs、Ys 的两三倍,因为航高方向的几何强度天然弱于平面方向;φ、ω 的中误差通常大于 κ,因为倾角对像点位移的影响是非线性的,弱化了。如果哪个数的中误差大得离谱,先怀疑数据,再怀疑控制点分布。
sigma0本身也有判读标准。像点量测精度一般能做到 0.005 mm 到 0.01 mm,如果算出来是 0.08 mm,说明要么有点位粗差,要么模型不匹配(比如影像有畸变没改正)。这个指标比任何单个参数都更能反映整体质量。
4.3 结果输出与过程日志
输出分两段:一段给人看,一段给下游程序读。给人看的就是上面那种表格,给程序读的用固定宽度的 key=value,好解析也好 diff:
// IO/ResultWriter.cs public static void Write(TextWriter w, ExteriorOrientation r, int n) { w.WriteLine("=== 单像空间后方交会结果 ==="); w.WriteLine($"控制点数 = {n} 迭代次数 = {r.Iterations}"); w.WriteLine($"Xs = {r.Xs,12:F4} m m = {r.StdDev?[0] * 1000,7:F2} mm"); w.WriteLine($"Ys = {r.Ys,12:F4} m m = {r.StdDev?[1] * 1000,7:F2} mm"); w.WriteLine($"Zs = {r.Zs,12:F4} m m = {r.StdDev?[2] * 1000,7:F2} mm"); w.WriteLine($"phi = {r.Phi,10:F7} rad = {r.Phi * 180 / Math.PI,8:F4} deg"); w.WriteLine($"omega = {r.Omega,10:F7} rad = {r.Omega * 180 / Math.PI,8:F4} deg"); w.WriteLine($"kappa <p> <a href="https://download.csdn.net/download/m0_68160088/88607762" style="color:#ec7500;font-size:14px;"> 本文还有配套的精品资源,点击获取 </a> <img alt="menu-r.4af5f7ec.gif" src="https://csdnimg.cn/release/wenkucmsfe/public/img/menu-r.4af5f7ec.gif" style="width:16px;margin-left:4px;vertical-align:text-bottom;cursor:text;"> </p>