Android 测绘数值基础:测量单位系统与七四参数、高程拟合

单位换算与坐标换算看似无关,其实是同一条主线:内部存标准值,出口才换算,且必须收口、可验证。

前言

做测量、GIS 类应用,你一定遇到过这类需求:用户要在"米 / 英尺 / 英寸"、"度 / 弧度 / 度分秒"、"平方米 / 亩"之间切换,切换一次,全 App 所有显示距离、角度、面积的地方都得跟着变。再往下做,你会碰到第二层换算:手上有一批已知控制点,想把 WGS84 的经纬度换到某个本地坐标系,就得先求出一套"转换参数"------七参数、四参数、或者一个高程拟合面。

这两件事乍看是两个方向:前者是同一个物理量换单位 ,后者是同一个空间位置换坐标系。但摊开看,结构一模一样------都是"内部保存统一基准值,只在出口处换算,换算逻辑收口成一处"。

这篇文章我把这两层换算放在一起讲:前半段讲单位系统怎么用"全局注册表 + 格式化收口"做到切换一处、全 App 生效;后半段讲七参数 / 四参数 / 高程拟合在算什么、有哪些标准公式层面的坑。

一、痛点:换算散落各处

最常见的坏味道是:每个用距离的页面都写"如果当前是英尺就乘 3.28",角度页面写"如果显示度分秒就转一下"。于是:

  • 换算法则散落 N 个页面,改一处漏一处;
  • 精度的取舍各写各的,有的保留 2 位、有的保留 4 位;
  • 用户切换单位时,要遍历所有页面去改它们的内部状态。

这个设计要解决的核心矛盾是:数据在内部始终用统一的标准单位(距离用米、角度用度、面积用平方米),显示时才按当前单位换算。 换算和格式化收口在单位系统里,页面不碰。

坐标这一层同理:如果每个用坐标的地方都自己写一遍七参数公式、自己决定旋转角单位、尺度要不要 +1,参数一更新就不知道哪个页面用了旧公式。所以坐标转换也要收口成纯函数模块,调用方只给控制点和源坐标。

二、单位注册表:一组"量纲 + 当前单位"

先定义量纲(长度、角度、面积),每个量纲有自己的"基准单位"和一组可切换的"显示单位"。全局一个注册表持有每个量纲当前选中的单位:

kotlin 复制代码
enum class Dimension { LENGTH, ANGLE, AREA }

// 一个量纲下的单位定义
data class Unit(
    val id: String,
    val label: String,
    val toBase: (Double) -> Double,  // 本单位 -> 基准单位
    val fromBase: (Double) -> Double // 基准单位 -> 本单位
)

class UnitRegistry {
    // 每个量纲当前激活的单位
    private val current = mutableMapOf<Dimension, Unit>()

    fun switch(dimension: Dimension, unit: Unit) {
        current[dimension] = unit
    }

    fun current(dimension: Dimension): Unit =
        current[dimension] ?: defaultFor(dimension)
}

这样"当前用哪个单位"是一个全局可变状态,任何页面想读当前单位、想切换单位,都走同一个入口。

三、换算与格式化:数据进,字符串出

页面拿到内部的标准值(米/度/平方米),要做两件事:换算成当前单位、再格式化成字符串。这两步都封装成"从量纲 + 值 到 显示字符串"的单一入口:

kotlin 复制代码
fun format(dimension: Dimension, value: Double): String {
    val unit = registry.current(dimension)
    val converted = unit.fromBase(value)     // 标准单位 -> 当前单位
    val precision = precisionFor(dimension)   // 每个量纲的精度策略
    return "${trim(converted, precision)} ${unit.label}"
}

页面从此不再关心"现在是米还是英尺、要不要保留 3 位小数",只需调用 format(Dimension.LENGTH, internalMeters)。换单位时,页面只需重新触发一次重绘,取值函数自动读到新单位,显示立刻统一变化。

3.1 长度与面积:真实换算因子

注册表的威力取决于换算因子是否准确。这些因子在国际上都是确定值,可以直接写死:

kotlin 复制代码
object LengthUnits {
    val METER       = Unit("m",   "米",   toBase = { it },          fromBase = { it })
    val FOOT        = Unit("ft",  "英尺", toBase = { it * 0.3048 }, fromBase = { it / 0.3048 })
    val INCH        = Unit("in",  "英寸", toBase = { it * 0.0254 }, fromBase = { it / 0.0254 })
    val NAUTICAL_MILE = Unit("nmi", "海里", toBase = { it * 1852.0 }, fromBase = { it / 1852.0 })
}

object AreaUnits {
    val SQUARE_METER = Unit("m2", "平方米", toBase = { it },                 fromBase = { it })
    val HECTARE      = Unit("ha", "公顷",   toBase = { it * 10_000.0 },      fromBase = { it / 10_000.0 })
    val MU           = Unit("mu", "亩",     toBase = { it * (2000.0 / 3.0) }, fromBase = { it / (2000.0 / 3.0) })
}

因子来源:英尺 0.3048、英寸 0.0254 (国际码磅制精确定义)、海里 1852 米 、亩 = 2000/3 平方米 (市亩定义,写成算式而非 666.67 以免截断误差)。内部永远存平方米,只在显示"亩"时才换算。

3.2 角度:度、弧度与度分秒

角度单位(度 / 度分秒 / 弧度)特殊一些,基准用"度"。度与弧度是确定关系,常量可以直接定义:

kotlin 复制代码
const val DEG_TO_RAD = Math.PI / 180.0   // 1° = π/180 弧度
const val RAD_TO_DEG = 180.0 / Math.PI   // 1 弧度 = 180/π 度 ≈ 57.2958°

// 度 -> 度分秒 的转换
fun toDMS(degrees: Double): String {
    val d = degrees.toInt()
    val m = ((degrees - d) * 60).toInt()
    val s = (degrees - d - m / 60.0) * 3600.0
    return "${d}°${m}′${String.format("%.1f", s)}″"
}

度分秒进制是 1° = 60′ = 3600″ 。上面这段在正数、不需要进位时是对的,但有两个边界:(-31.5).toInt() 得 -31(向零取整),分秒会变正,出现 -31°30′0.0″ 这种自相矛盾的写法;秒若被舍入成 60.0,应进位成"下一分"、分到 60 再进位成"下一度",否则会出现 31°59′60.0″。补上这两点的稳健版本:

kotlin 复制代码
fun toDmsRobust(degrees: Double): String {
    val sign = if (degrees < 0) "-" else ""
    val abs = kotlin.math.abs(degrees)
    var d = abs.toInt()
    var m = ((abs - d) * 60).toInt()
    var s = ((abs - d) * 60 - m) * 60
    s = Math.round(s * 10) / 10.0        // 秒先舍入到 0.1 位
    if (s >= 60.0) { s -= 60.0; m += 1 } // 逐级处理进位
    if (m >= 60)   { m -= 60;  d += 1 }
    return "$sign${d}°${m}′${String.format("%.1f", s)}″"
}

sin/cos 入参是弧度、用户常看到度:换算只在"显示 / 输入"边界做一次,中间层一律传基准单位(度),否则结果会差几个数量级------这和七参数"旋转角量纲"是同类坑。

四、精度与显示策略:别让换算结果吓到用户

不同量纲的显示精度策略不同,应该按量纲配置而非各处硬编码:

  • 距离:默认保留 3 位小数,单位切换时小数位保持不变,避免切换后数字剧烈跳动。
  • 角度:默认保留 4 位小数;切到度分秒时,秒保留 1 位。
  • 面积:根据量级自适应,几平方米和几十万平方米用不同精度。

把精度也收口进单位系统,配合格式化一起输出,就能保证全 App 任何一个角落的数值,格式都是统一的。小数位由量纲而非当前单位决定,避免切换后数字跳变让人误以为数据变了。

五、转换参数到底在算什么

前面是"同一物理量换单位",现在进入第二层:同一个物理位置换坐标系。转换参数的意义是:建立"源坐标系"和"目标坐标系"之间的一套数学关系,让任意一个源坐标都能换到目标坐标系去。控制点就是"同一物理位置在两个坐标系下的已知坐标对",靠它们反解出参数。

一个控制点通常带这几类信息:

  • 源坐标:经纬度(B/L)+ 椭球高(H),通常是 WGS84 下的测量值;
  • 目标坐标:已知的北坐标(N)、东坐标(E)、高程(H),是该点在本地坐标系里的准确值;
  • 参与标志:这个点要不要参与平面求参(useH)、要不要参与高程求参(useV);
  • 残差:求参后回代算出的偏差(水平残差 Hrms / 垂直残差 Vrms)。

这一步看似只是收集数据,其实已经决定了工程化的一半:数据的取舍和标志位的设计 (useH / useV 决定某点是否参与平面 / 高程求参)。

动七参数之前先要知道坐标系基准。WGS-84 与 CGCS2000 都是地心坐标系 、椭球长半轴相同:WGS-84 的 a = 6378137.0 m、1/f = 298.257223563,CGCS2000 的 a = 6378137.0 m、1/f = 298.257222101。两者扁率倒数只差末几位,差异主要来自实现参考框架不同而非椭球本身。

六、七参数:三维空间的刚体变换 + 尺度

七参数描述的是两个空间直角坐标系之间的关系,含 3 个平移(ΔX/ΔY/ΔZ)、3 个旋转(Rx/Ry/Rz)、1 个尺度 K。它的计算入口长这样:

kotlin 复制代码
// 传入所有控制点,让求解器反解出一套参数
val param7 = solver.solveSeven(sourcePoints, targetPoints)

// 旋转角:原始值以"秒"为单位,转成弧度/度才能用于旋转矩阵
val rx = param7.rx / 3600.0
val ry = param7.ry / 3600.0
val rz = param7.rz / 3600.0

// 尺度:库返回的是百万分之偏差,还原成真实比例因子
val scale = param7.k / 1_000_000.0 + 1.0

这里藏着两个容易错的工程点,也正是"专业坑":

  1. 旋转角的量纲 。底层求出的旋转角常以"秒"为单位,必须 /3600 转成度,或再转弧度,才能塞进旋转矩阵。漏了这一步,角度差几十个数量级。
  2. 尺度的语义 。返回的 K 往往是"百万分之几"的偏差(比如 -116.4),真实比例因子是 K / 1e6 + 1。很多实现把这层换算埋在结果类里,调用方直接拿 K + 1,就错了。

七参数的数学模型是布尔沙模型(Bursa-Wolf),在小角度近似下,空间直角坐标的转换可写成:

复制代码
[X']   [ΔX]        [ 1    Rz  -Ry ] [X]
[Y'] = [ΔY] + (1+m)[ -Rz   1   Rx ] [Y]
[Z']   [ΔZ]        [ Ry  -Rx   1  ] [Z]

其中 Rx / Ry / Rz 是旋转角(弧度),m 是尺度偏差(无量纲),ΔX / ΔY / ΔZ 是平移量。不同软件对旋转角正负号约定可能相反,要以所用算法 / 参照软件为准,不能照抄公式上生产。7 个未知量至少 3 个控制点才能求解,实际工程用更多点做最小二乘。

七、四参数:平面上的平移 + 旋转 + 尺度

四参数是在平面坐标系里工作的:2 个平移(北 N、东 E)、1 个旋转角 R、1 个尺度 K,通常还带一个"基准原点"(OrgN/OrgE),表示这套参数是在哪个已知点上"锚定"的:

kotlin 复制代码
// 求四参数:输出 北平移、东平移、旋转角、尺度、基准原点
val param4 = solver.solveFour(controlPoints)

val result = Param4Result(
    dn = param4.dn,
    de = param4.de,
    rotation = param4.rotation,
    scale = param4.scale,
    originN = param4.orgN,
    originE = param4.orgE,
)

四参数的数学模型是平面相似变换,以基准原点 (N₀, E₀) 为中轴:

复制代码
N₂ = ΔN + k·cosθ·(N₁ - N₀) - k·sinθ·(E₁ - E₀)
E₂ = ΔE + k·sinθ·(N₁ - N₀) + k·cosθ·(E₁ - E₀)

工程实现里常把 k·cosθ、k·sinθ 记作待定系数 a、b,解出后反算 k = √(a² + b²)、θ = atan2(b, a)。它适合"小范围平面测区":把地球面近似成平面,用平移 + 旋转 + 尺度覆盖大部分误差。它和七参数不是二选一,工程里经常先求一套七参数做整体框架,再在平面层叠一套四参数精调 ,两个结果并存。边界在于:平面近似只在小范围内成立,测区越大、残差随离控制点越远而越大。

八、高程拟合:把椭球高换回正常高

平面坐标换完了,还有个高程问题:GPS 测出来的是椭球高 ,可工程用的通常是正常高(相对似大地水准面)。两者差一个起伏,不能简单相减,得用一个"拟合面"来修正。

三者的标准关系是:大地高(椭球高)h、正常高 H、高程异常 ζ 满足 H = h − ζ 。高程异常随位置变化、没有全局常数,高程拟合就是用控制点上已知的 ζ 拟合出一个覆盖测区的 ζ 曲面。

拟合面常用多项式表示,系数是 A0~A5,加一个基准原点:

kotlin 复制代码
// 平面拟合:高程修正值 = A0 + A1 * (N - OrgN) + A2 * (E - OrgE)
fun heightCorrect(n: Double, e: Double): Double {
    return a0 + a1 * (n - orgN) + a2 * (e - orgE)
}

高程拟合的方法选择本身就是个工程决策,常见的几种:

  • 加权平均:按距离加权,适合控制点分布不规则、只求一个整体修正量;
  • 平面拟合:用 A0 + A1·dN + A2·dE,适合小范围、地形平坦;
  • 曲面拟合:升到更高阶(A0~A5),能表达更复杂的起伏,但要更多控制点才稳;
  • 自动判断:让求解器根据控制点数量/分布自己挑一种;
  • 垂直平差:当成一个整体基准平移 + 南北/东西向坡度来处理。

选错了,拟合面在小范围内看着没问题,出了控制点范围就飞了。这是高程拟合最典型的"专业陷阱"。

8.1 拟合模型的形式

  • 加权平均(反距离加权) :ζ̂ = Σ(wᵢ·ζᵢ) / Σwᵢ,权重常取 wᵢ = 1/dᵢ 或 1/dᵢ²,只给一个"综合修正量",不表达趋势。
  • 平面拟合 :待定 3 个系数,ζ̂ = A0 + A1·dN + A2·dE。
  • 二次曲面拟合 :待定 6 个系数,ζ̂ = A0 + A1·dN + A2·dE + A3·dN² + A4·dE² + A5·dN·dE。

系数越多越能表达复杂起伏,但对控制点数量和质量要求越高------6 个系数至少 6 个高程控制点才能解,实际还要更多点才稳。控制点少又集中时盲目上二次曲面,能解出系数也是"过拟合",出了控制点范围就失真。这就是"别盲目用高阶"的含义。

求这些系数就是一个最小二乘问题:多点联立正规方程 N·x = r(N 为 3x3 对称阵,元素由 dN、dE、ζ 的乘积和累加得到),解出 [A0, A1, A2];坐标量级很大时用带部分主元的高斯消元比行列式法更稳。

九、三类模型怎么选

模型 适用坐标系 参数个数 数学下限控制点 典型场景
七参数 空间直角坐标 3 平移 + 3 旋转 + 1 尺度 3 个 大范围整体框架转换
四参数 平面直角坐标 2 平移 + 1 旋转 + 1 尺度 2 个 小范围平面测区精调
高程拟合 高程(ζ 面) 按阶数 3 / 6 个 3 / 6 个 椭球高 → 正常高

十、升华:把换算做成"可测试"的模块

不管是单位换算还是坐标求参,最难的不是写对公式,而是证明它算得对。工程化的做法有三板斧:

  1. 回代残差验证。用算出的参数把控制点的源坐标换回目标坐标,看偏差(Hrms/Vrms)是否在限差内。残差本身就是一道验收关卡,超过阈值就该怀疑控制点里混进了坏点。
  2. 把已知结果钉进单元测试。维护一组真实坐标对和一份"参照软件算出的权威结果",每次改动后用断言(比如精度 0.0001)校验参数是否仍一致。改库、重构都不会悄悄跑偏。
  3. 参数模型与计算实现分离。结果类(七参/四参/拟合系数)只管承载,求解逻辑收敛在底层,上层只负责"收集控制点 → 配置方法 → 拿结果 → 验证残差"。这样业务、界面、甚至存储(保存/加载参数文件)都依赖同一份干净的模型。

单位系统同理:fromBase(toBase(x)) 是天然的可单测点,format 输出是纯函数结果,都能钉进断言;两层换算都当成纯函数模块即可。

结论

  • 测绘 App 有两层换算:单位换算 (同一物理量换显示单位)和坐标换算(同一位置换坐标系),本质都是"内部存标准值、出口才换算",这套思路同样适用于汇率、温度、重量等多单位场景。
  • 单位系统用全局注册表 持有每个量纲的当前单位,页面统一从一处读,切换一处生效全 App。
  • 换算 + 格式化收口成 "量纲 + 标准值 → 显示字符串" 的单一入口,精度策略也按量纲配置。
  • 角度换算注意 1° = 60′ = 3600″、1 rad = 180/π ≈ 57.2958° ;度分秒要处理负号与秒进位,否则出现 -31°30′ 或 59′60″ 这类错误。
  • 长度/面积因子是定值:英尺 0.3048、英寸 0.0254、海里 1852、亩 2000/3 平方米,内部一律存标准单位。
  • 转换参数分三类:七参数 (空间三维:3 平移 + 3 旋转 + 1 尺度,布尔沙模型)、四参数 (平面相似变换:2 平移 + 1 旋转 + 1 尺度 + 原点)、高程拟合(多项式拟合面修正椭球高)。
  • 两个高频坑:旋转角要 /3600 从"秒"转"度/弧度";尺度要 K/1e6 + 1 还原真实比例因子;正常高 = 椭球高 − 高程异常。
  • 工程化的关键是可测试闭环 :控制点 → 求参 → 回代残差验收 → 单元测试断言精度;拟合别盲目上高阶,算法封装成纯函数模块、结果模型与求解逻辑分离。

单位切换和坐标转换你是一处收口还是散落各处?七参数与四参数在项目里又是怎么配合的?欢迎在评论区聊聊你踩过的坑。

关键词标签 :#Android #测量单位 #七参数 #四参数 #高程拟合 #坐标转换 #WGS84 #Kotlin

Android 测绘数值基础:测量单位系统与七四参数、高程拟合

单位换算与坐标换算看似无关,其实是同一条主线:内部存标准值,出口才换算,且必须收口、可验证。

前言

做测量、GIS 类应用,你一定遇到过这类需求:用户要在"米 / 英尺 / 英寸"、"度 / 弧度 / 度分秒"、"平方米 / 亩"之间切换,切换一次,全 App 所有显示距离、角度、面积的地方都得跟着变。再往下做,你会碰到第二层换算:手上有一批已知控制点,想把 WGS84 的经纬度换到某个本地坐标系,就得先求出一套"转换参数"------七参数、四参数、或者一个高程拟合面。

这两件事乍看是两个方向:前者是同一个物理量换单位 ,后者是同一个空间位置换坐标系。但摊开看,结构一模一样------都是"内部保存统一基准值,只在出口处换算,换算逻辑收口成一处"。

这篇文章我把这两层换算放在一起讲:前半段讲单位系统怎么用"全局注册表 + 格式化收口"做到切换一处、全 App 生效;后半段讲七参数 / 四参数 / 高程拟合在算什么、有哪些标准公式层面的坑。

一、痛点:换算散落各处

最常见的坏味道是:每个用距离的页面都写"如果当前是英尺就乘 3.28",角度页面写"如果显示度分秒就转一下"。于是:

  • 换算法则散落 N 个页面,改一处漏一处;
  • 精度的取舍各写各的,有的保留 2 位、有的保留 4 位;
  • 用户切换单位时,要遍历所有页面去改它们的内部状态。

这个设计要解决的核心矛盾是:数据在内部始终用统一的标准单位(距离用米、角度用度、面积用平方米),显示时才按当前单位换算。 换算和格式化收口在单位系统里,页面不碰。

坐标这一层同理:如果每个用坐标的地方都自己写一遍七参数公式、自己决定旋转角单位、尺度要不要 +1,参数一更新就不知道哪个页面用了旧公式。所以坐标转换也要收口成纯函数模块,调用方只给控制点和源坐标。

二、单位注册表:一组"量纲 + 当前单位"

先定义量纲(长度、角度、面积),每个量纲有自己的"基准单位"和一组可切换的"显示单位"。全局一个注册表持有每个量纲当前选中的单位:

kotlin 复制代码
enum class Dimension { LENGTH, ANGLE, AREA }

// 一个量纲下的单位定义
data class Unit(
    val id: String,
    val label: String,
    val toBase: (Double) -> Double,  // 本单位 -> 基准单位
    val fromBase: (Double) -> Double // 基准单位 -> 本单位
)

class UnitRegistry {
    // 每个量纲当前激活的单位
    private val current = mutableMapOf<Dimension, Unit>()

    fun switch(dimension: Dimension, unit: Unit) {
        current[dimension] = unit
    }

    fun current(dimension: Dimension): Unit =
        current[dimension] ?: defaultFor(dimension)
}

这样"当前用哪个单位"是一个全局可变状态,任何页面想读当前单位、想切换单位,都走同一个入口。

三、换算与格式化:数据进,字符串出

页面拿到内部的标准值(米/度/平方米),要做两件事:换算成当前单位、再格式化成字符串。这两步都封装成"从量纲 + 值 到 显示字符串"的单一入口:

kotlin 复制代码
fun format(dimension: Dimension, value: Double): String {
    val unit = registry.current(dimension)
    val converted = unit.fromBase(value)     // 标准单位 -> 当前单位
    val precision = precisionFor(dimension)   // 每个量纲的精度策略
    return "${trim(converted, precision)} ${unit.label}"
}

页面从此不再关心"现在是米还是英尺、要不要保留 3 位小数",只需调用 format(Dimension.LENGTH, internalMeters)。换单位时,页面只需重新触发一次重绘,取值函数自动读到新单位,显示立刻统一变化。

3.1 长度与面积:真实换算因子

注册表的威力取决于换算因子是否准确。这些因子在国际上都是确定值,可以直接写死:

kotlin 复制代码
object LengthUnits {
    val METER       = Unit("m",   "米",   toBase = { it },          fromBase = { it })
    val FOOT        = Unit("ft",  "英尺", toBase = { it * 0.3048 }, fromBase = { it / 0.3048 })
    val INCH        = Unit("in",  "英寸", toBase = { it * 0.0254 }, fromBase = { it / 0.0254 })
    val NAUTICAL_MILE = Unit("nmi", "海里", toBase = { it * 1852.0 }, fromBase = { it / 1852.0 })
}

object AreaUnits {
    val SQUARE_METER = Unit("m2", "平方米", toBase = { it },                 fromBase = { it })
    val HECTARE      = Unit("ha", "公顷",   toBase = { it * 10_000.0 },      fromBase = { it / 10_000.0 })
    val MU           = Unit("mu", "亩",     toBase = { it * (2000.0 / 3.0) }, fromBase = { it / (2000.0 / 3.0) })
}

因子来源:英尺 0.3048、英寸 0.0254 (国际码磅制精确定义)、海里 1852 米 、亩 = 2000/3 平方米 (市亩定义,写成算式而非 666.67 以免截断误差)。内部永远存平方米,只在显示"亩"时才换算。

3.2 角度:度、弧度与度分秒

角度单位(度 / 度分秒 / 弧度)特殊一些,基准用"度"。度与弧度是确定关系,常量可以直接定义:

kotlin 复制代码
const val DEG_TO_RAD = Math.PI / 180.0   // 1° = π/180 弧度
const val RAD_TO_DEG = 180.0 / Math.PI   // 1 弧度 = 180/π 度 ≈ 57.2958°

// 度 -> 度分秒 的转换
fun toDMS(degrees: Double): String {
    val d = degrees.toInt()
    val m = ((degrees - d) * 60).toInt()
    val s = (degrees - d - m / 60.0) * 3600.0
    return "${d}°${m}′${String.format("%.1f", s)}″"
}

度分秒进制是 1° = 60′ = 3600″ 。上面这段在正数、不需要进位时是对的,但有两个边界:(-31.5).toInt() 得 -31(向零取整),分秒会变正,出现 -31°30′0.0″ 这种自相矛盾的写法;秒若被舍入成 60.0,应进位成"下一分"、分到 60 再进位成"下一度",否则会出现 31°59′60.0″。补上这两点的稳健版本:

kotlin 复制代码
fun toDmsRobust(degrees: Double): String {
    val sign = if (degrees < 0) "-" else ""
    val abs = kotlin.math.abs(degrees)
    var d = abs.toInt()
    var m = ((abs - d) * 60).toInt()
    var s = ((abs - d) * 60 - m) * 60
    s = Math.round(s * 10) / 10.0        // 秒先舍入到 0.1 位
    if (s >= 60.0) { s -= 60.0; m += 1 } // 逐级处理进位
    if (m >= 60)   { m -= 60;  d += 1 }
    return "$sign${d}°${m}′${String.format("%.1f", s)}″"
}

sin/cos 入参是弧度、用户常看到度:换算只在"显示 / 输入"边界做一次,中间层一律传基准单位(度),否则结果会差几个数量级------这和七参数"旋转角量纲"是同类坑。

四、精度与显示策略:别让换算结果吓到用户

不同量纲的显示精度策略不同,应该按量纲配置而非各处硬编码:

  • 距离:默认保留 3 位小数,单位切换时小数位保持不变,避免切换后数字剧烈跳动。
  • 角度:默认保留 4 位小数;切到度分秒时,秒保留 1 位。
  • 面积:根据量级自适应,几平方米和几十万平方米用不同精度。

把精度也收口进单位系统,配合格式化一起输出,就能保证全 App 任何一个角落的数值,格式都是统一的。小数位由量纲而非当前单位决定,避免切换后数字跳变让人误以为数据变了。

五、转换参数到底在算什么

前面是"同一物理量换单位",现在进入第二层:同一个物理位置换坐标系。转换参数的意义是:建立"源坐标系"和"目标坐标系"之间的一套数学关系,让任意一个源坐标都能换到目标坐标系去。控制点就是"同一物理位置在两个坐标系下的已知坐标对",靠它们反解出参数。

一个控制点通常带这几类信息:

  • 源坐标:经纬度(B/L)+ 椭球高(H),通常是 WGS84 下的测量值;
  • 目标坐标:已知的北坐标(N)、东坐标(E)、高程(H),是该点在本地坐标系里的准确值;
  • 参与标志:这个点要不要参与平面求参(useH)、要不要参与高程求参(useV);
  • 残差:求参后回代算出的偏差(水平残差 Hrms / 垂直残差 Vrms)。

这一步看似只是收集数据,其实已经决定了工程化的一半:数据的取舍和标志位的设计 (useH / useV 决定某点是否参与平面 / 高程求参)。

动七参数之前先要知道坐标系基准。WGS-84 与 CGCS2000 都是地心坐标系 、椭球长半轴相同:WGS-84 的 a = 6378137.0 m、1/f = 298.257223563,CGCS2000 的 a = 6378137.0 m、1/f = 298.257222101。两者扁率倒数只差末几位,差异主要来自实现参考框架不同而非椭球本身。

六、七参数:三维空间的刚体变换 + 尺度

七参数描述的是两个空间直角坐标系之间的关系,含 3 个平移(ΔX/ΔY/ΔZ)、3 个旋转(Rx/Ry/Rz)、1 个尺度 K。它的计算入口长这样:

kotlin 复制代码
// 传入所有控制点,让求解器反解出一套参数
val param7 = solver.solveSeven(sourcePoints, targetPoints)

// 旋转角:原始值以"秒"为单位,转成弧度/度才能用于旋转矩阵
val rx = param7.rx / 3600.0
val ry = param7.ry / 3600.0
val rz = param7.rz / 3600.0

// 尺度:库返回的是百万分之偏差,还原成真实比例因子
val scale = param7.k / 1_000_000.0 + 1.0

这里藏着两个容易错的工程点,也正是"专业坑":

  1. 旋转角的量纲 。底层求出的旋转角常以"秒"为单位,必须 /3600 转成度,或再转弧度,才能塞进旋转矩阵。漏了这一步,角度差几十个数量级。
  2. 尺度的语义 。返回的 K 往往是"百万分之几"的偏差(比如 -116.4),真实比例因子是 K / 1e6 + 1。很多实现把这层换算埋在结果类里,调用方直接拿 K + 1,就错了。

七参数的数学模型是布尔沙模型(Bursa-Wolf),在小角度近似下,空间直角坐标的转换可写成:

复制代码
[X']   [ΔX]        [ 1    Rz  -Ry ] [X]
[Y'] = [ΔY] + (1+m)[ -Rz   1   Rx ] [Y]
[Z']   [ΔZ]        [ Ry  -Rx   1  ] [Z]

其中 Rx / Ry / Rz 是旋转角(弧度),m 是尺度偏差(无量纲),ΔX / ΔY / ΔZ 是平移量。不同软件对旋转角正负号约定可能相反,要以所用算法 / 参照软件为准,不能照抄公式上生产。7 个未知量至少 3 个控制点才能求解,实际工程用更多点做最小二乘。

七、四参数:平面上的平移 + 旋转 + 尺度

四参数是在平面坐标系里工作的:2 个平移(北 N、东 E)、1 个旋转角 R、1 个尺度 K,通常还带一个"基准原点"(OrgN/OrgE),表示这套参数是在哪个已知点上"锚定"的:

kotlin 复制代码
// 求四参数:输出 北平移、东平移、旋转角、尺度、基准原点
val param4 = solver.solveFour(controlPoints)

val result = Param4Result(
    dn = param4.dn,
    de = param4.de,
    rotation = param4.rotation,
    scale = param4.scale,
    originN = param4.orgN,
    originE = param4.orgE,
)

四参数的数学模型是平面相似变换,以基准原点 (N₀, E₀) 为中轴:

复制代码
N₂ = ΔN + k·cosθ·(N₁ - N₀) - k·sinθ·(E₁ - E₀)
E₂ = ΔE + k·sinθ·(N₁ - N₀) + k·cosθ·(E₁ - E₀)

工程实现里常把 k·cosθ、k·sinθ 记作待定系数 a、b,解出后反算 k = √(a² + b²)、θ = atan2(b, a)。它适合"小范围平面测区":把地球面近似成平面,用平移 + 旋转 + 尺度覆盖大部分误差。它和七参数不是二选一,工程里经常先求一套七参数做整体框架,再在平面层叠一套四参数精调 ,两个结果并存。边界在于:平面近似只在小范围内成立,测区越大、残差随离控制点越远而越大。

八、高程拟合:把椭球高换回正常高

平面坐标换完了,还有个高程问题:GPS 测出来的是椭球高 ,可工程用的通常是正常高(相对似大地水准面)。两者差一个起伏,不能简单相减,得用一个"拟合面"来修正。

三者的标准关系是:大地高(椭球高)h、正常高 H、高程异常 ζ 满足 H = h − ζ 。高程异常随位置变化、没有全局常数,高程拟合就是用控制点上已知的 ζ 拟合出一个覆盖测区的 ζ 曲面。

拟合面常用多项式表示,系数是 A0~A5,加一个基准原点:

kotlin 复制代码
// 平面拟合:高程修正值 = A0 + A1 * (N - OrgN) + A2 * (E - OrgE)
fun heightCorrect(n: Double, e: Double): Double {
    return a0 + a1 * (n - orgN) + a2 * (e - orgE)
}

高程拟合的方法选择本身就是个工程决策,常见的几种:

  • 加权平均:按距离加权,适合控制点分布不规则、只求一个整体修正量;
  • 平面拟合:用 A0 + A1·dN + A2·dE,适合小范围、地形平坦;
  • 曲面拟合:升到更高阶(A0~A5),能表达更复杂的起伏,但要更多控制点才稳;
  • 自动判断:让求解器根据控制点数量/分布自己挑一种;
  • 垂直平差:当成一个整体基准平移 + 南北/东西向坡度来处理。

选错了,拟合面在小范围内看着没问题,出了控制点范围就飞了。这是高程拟合最典型的"专业陷阱"。

8.1 拟合模型的形式

  • 加权平均(反距离加权) :ζ̂ = Σ(wᵢ·ζᵢ) / Σwᵢ,权重常取 wᵢ = 1/dᵢ 或 1/dᵢ²,只给一个"综合修正量",不表达趋势。
  • 平面拟合 :待定 3 个系数,ζ̂ = A0 + A1·dN + A2·dE。
  • 二次曲面拟合 :待定 6 个系数,ζ̂ = A0 + A1·dN + A2·dE + A3·dN² + A4·dE² + A5·dN·dE。

系数越多越能表达复杂起伏,但对控制点数量和质量要求越高------6 个系数至少 6 个高程控制点才能解,实际还要更多点才稳。控制点少又集中时盲目上二次曲面,能解出系数也是"过拟合",出了控制点范围就失真。这就是"别盲目用高阶"的含义。

求这些系数就是一个最小二乘问题:多点联立正规方程 N·x = r(N 为 3x3 对称阵,元素由 dN、dE、ζ 的乘积和累加得到),解出 [A0, A1, A2];坐标量级很大时用带部分主元的高斯消元比行列式法更稳。

九、三类模型怎么选

模型 适用坐标系 参数个数 数学下限控制点 典型场景
七参数 空间直角坐标 3 平移 + 3 旋转 + 1 尺度 3 个 大范围整体框架转换
四参数 平面直角坐标 2 平移 + 1 旋转 + 1 尺度 2 个 小范围平面测区精调
高程拟合 高程(ζ 面) 按阶数 3 / 6 个 3 / 6 个 椭球高 → 正常高

十、升华:把换算做成"可测试"的模块

不管是单位换算还是坐标求参,最难的不是写对公式,而是证明它算得对。工程化的做法有三板斧:

  1. 回代残差验证。用算出的参数把控制点的源坐标换回目标坐标,看偏差(Hrms/Vrms)是否在限差内。残差本身就是一道验收关卡,超过阈值就该怀疑控制点里混进了坏点。
  2. 把已知结果钉进单元测试。维护一组真实坐标对和一份"参照软件算出的权威结果",每次改动后用断言(比如精度 0.0001)校验参数是否仍一致。改库、重构都不会悄悄跑偏。
  3. 参数模型与计算实现分离。结果类(七参/四参/拟合系数)只管承载,求解逻辑收敛在底层,上层只负责"收集控制点 → 配置方法 → 拿结果 → 验证残差"。这样业务、界面、甚至存储(保存/加载参数文件)都依赖同一份干净的模型。

单位系统同理:fromBase(toBase(x)) 是天然的可单测点,format 输出是纯函数结果,都能钉进断言;两层换算都当成纯函数模块即可。

结论

  • 测绘 App 有两层换算:单位换算 (同一物理量换显示单位)和坐标换算(同一位置换坐标系),本质都是"内部存标准值、出口才换算",这套思路同样适用于汇率、温度、重量等多单位场景。
  • 单位系统用全局注册表 持有每个量纲的当前单位,页面统一从一处读,切换一处生效全 App。
  • 换算 + 格式化收口成 "量纲 + 标准值 → 显示字符串" 的单一入口,精度策略也按量纲配置。
  • 角度换算注意 1° = 60′ = 3600″、1 rad = 180/π ≈ 57.2958° ;度分秒要处理负号与秒进位,否则出现 -31°30′ 或 59′60″ 这类错误。
  • 长度/面积因子是定值:英尺 0.3048、英寸 0.0254、海里 1852、亩 2000/3 平方米,内部一律存标准单位。
  • 转换参数分三类:七参数 (空间三维:3 平移 + 3 旋转 + 1 尺度,布尔沙模型)、四参数 (平面相似变换:2 平移 + 1 旋转 + 1 尺度 + 原点)、高程拟合(多项式拟合面修正椭球高)。
  • 两个高频坑:旋转角要 /3600 从"秒"转"度/弧度";尺度要 K/1e6 + 1 还原真实比例因子;正常高 = 椭球高 − 高程异常。
  • 工程化的关键是可测试闭环 :控制点 → 求参 → 回代残差验收 → 单元测试断言精度;拟合别盲目上高阶,算法封装成纯函数模块、结果模型与求解逻辑分离。

单位切换和坐标转换你是一处收口还是散落各处?七参数与四参数在项目里又是怎么配合的?欢迎在评论区聊聊你踩过的坑。

关键词标签 :#Android #测量单位 #七参数 #四参数 #高程拟合 #坐标转换 #WGS84 #Kotlin

相关推荐
郑州光合科技余经理3 小时前
出海外卖平台:支付对接为什么要和下单收款分开
android·java·开发语言·前端·后端·php·ai编程
夜雪一千3 小时前
MySQL 如何提高查询速度:从索引、SQL、表结构到实战调优
android·sql·mysql
终端安全笔记3 小时前
配置锁状态那一栏查的是什么:报告里几项各自指向什么风险
android·前端·安全·ios
恋猫de小郭4 小时前
Kotlin Toolchain 0.13,JetBrains 推荐 KMP 都切换到到全新工具链
android·前端·flutter
安卓机器4 小时前
rom定制系列------小米8 安卓16线刷原生固件 体验新版本
android·小米8安卓16
cyy2984 小时前
小米电视安装tts引擎
android·tv
小锋学长生活大爆炸4 小时前
【工具】在 Android 手机上运行 Docker
android·docker·智能手机
Carson带你学Android4 小时前
OpenAI Dots 登陆手机端:手机上派任务,AI 在云端写代码
android·ios
事圆则缓5 小时前
Android 依赖注入库到底应该怎么选
android