当卫星定位失效时:用 Levenberg-Marquardt 对抗蓝牙 RSSI 混乱

卫星定位进楼就失效,作者用 BLE 信标加 Levenberg-Marquardt 三边定位在 iOS 上把误差压到半米内。

中文
复制
题图:BeaconIL 室内定位示意图,三个 BLE 信标的覆盖圈交叉出定位点,左下是 EMA 滤波曲线,右下是 Levenberg-Marquardt 的损失函数公式

卫星定位能把你带到楼前。进了楼,它就把你丢下了。室外能把你的位置三角定位到几米以内的信号,穿不透一层屋顶。混凝土和钢筋会把信号挡死。在仓库、医院、商场、机场航站楼里,人类发射的所有卫星星座都看不见你。这就是室内定位问题,它确实很难。不是“有趣的工程谜题”那种难,而是“物理定律在跟你作对”那种难。我做了一个叫 BeaconIL 的 iOS 应用,试图用 Bluetooth Low Energy 信标和一套从数值计算移植过来的非线性优化算法解决它。以下是我用数学对抗无线电物理的过程中学到的东西。

先说点背景,讲讲这个项目为什么存在。到 2020 年,我在桌面开发和微控制器嵌入式工作上已经干了十二年,从桌面端的 Qt 和 C++ 一路做到裸机固件。我所在公司的研发部门做基于微控制器的产品。没有两个任务是一样的。地铁有害气体监测的固件。工业基础设施监测。还有一个智能宠物项圈,后来扩展到全国范围上市,这段经历我已经写过了。接着就是与本篇相关的那个任务:面向工厂量产的智能手表,功能清单里有室内定位,因为卫星定位在屋顶下会失效。这里还有安全层面的考量。工厂车间里的机器不会原谅半米的误差,所以位置在近距离内必须可信,而不只是平均意义上准确。

手表是主要交付物。为了给它一个可供测距的对象,我们也自己做了 iBeacon:硬件由一位同事设计,固件是我的部分。不过这个任务的核心是数学:把带噪声的无线电读数变成位置估计,跑在微控制器上,最终用纯 C 实现。但要排查无线电的各种坑,手里拿一部手机最方便。iOS 给了我一套能用的 BLE 协议栈、CoreLocation 的测距回调,还有一块能把估计位置画出来的屏幕。这些都不需要调试,拿来就能用。在微控制器上,这些层都得我自己搭起来,数学才有得测。所以算法先落到 iOS 上,成了 BeaconIL。算法在真实硬件上验证之后,我把它移植成 Go,做成一个干净的独立库。那份 Go 代码是给微控制器上最终的 C 重写当参考的,这一次要避开我已经踩过的 bug。所以在 BeaconIL 起步时,这些数学都还不存在。终点是裸机微控制器,但手机让我能只专注在数学上。当然,它也带来了自己的操作系统抽象层和自己的限制。这个实验回答的问题比它被问到的更多。顺带一提,在 iOS 上这么深入的工作定下了我的方向。这个项目之后,我选择移动端作为要长期投入的职业,我在上面发布的那些应用得到了关注,我的经理后来还让我再做一个企业级的。

主要目标不是做一个打磨完善的产品,而是验证数学是否成立:EMA 滤波、路径损耗距离模型和 Levenberg-Marquardt 三边定位,能否在真实的 iOS 硬件和真实的 BLE 信标上给出稳定可用的位置估计。这个目标达到了。在一个 4×6 米、布置了三个已校准信标的房间里,屏幕上的标记与我实际站立的位置始终相差约半米以内。上架 App Store、通过两轮审核,以及后来开源,都是次要的。如果今天用我后来积累的移动端经验重做一遍,架构会不一样:更干净、更符合 iOS 的习惯,不那么像一个嵌入式工程师的第一个移动项目。但架构粗糙不等于代码不可靠。那几个早期应用在实际使用中经受住了考验,真正要紧的边界情况都处理了。数学——这才是重点——是成立的。

这个应用会扫描附近的 iBeacon 兼容 BLE 信标,测量每个信标的信号强度,换算成距离估计,然后运行三边定位算法算出位置,并把结果实时渲染在平面图上。它经历了两轮 App Store 审核并成功上架。代码在 https://github.com/Vitaliy69/BeaconIL

图 1 — BeaconIL 主界面。上方:实时信标列表,显示 major/minor 标识符、以 dBm 为单位的 RSSI 以及估算距离。下方:SpriteKit 可视化,信标(蓝色标记)与计算出的设备位置(红色标记)叠加在楼层平面图上。

图 1 — BeaconIL 主界面。上方:实时信标列表,显示 major/minor 标识符、以 dBm 为单位的 RSSI 以及估算距离。下方:SpriteKit 可视化,信标(蓝色标记)与计算出的设备位置(红色标记)叠加在楼层平面图上。

无线电的麻烦

低功耗蓝牙信标是电池供电的小型发射器,按固定间隔广播广告包,通常每秒 10 次。每个包包含一个 UUID、一个 major 值和一个 minor 值,用于标识信标本身及其所属分组。iPhone 通过 CoreLocation 接收这些包。CoreLocation 会把它们攒起来,大约每秒回调一次 didRange,回调里是一个数组,包含所有可见信标及其当前 RSSI 值。RSSI 的单位是分贝毫瓦(dBm)。

RSSI 是负数。越接近零,信号越强。紧挨着手机的信标可能读到 -40 dBm;走到十米开外,可能就只有 -75 dBm。理论上,这给了你估算距离的办法:信号越弱,距离越远。

理论上。

实际上,RSSI 是你这辈子能碰到的最嘈杂的信号之一。离开信标的无线电波并不会干干净净地沿直线传到你的手机。它会从墙壁、地板、天花板、金属货架和人体上反射;会被水吸收(而人体大部分是水);还会因多径传播而自我干扰——同一个包经由多条路径、以略微不同的时间到达,这些副本相互叠加或相互抵消。有人从你和信标之间走过,RSSI 可以瞬间掉 10 dBm,人一走开又弹回来。

就算你站得纹丝不动、手机拿得稳稳当当,单个信标的 RSSI 在相邻两次读数之间也会抖动 3 到 8 dBm。由于 RSSI 到距离的换算是对数关系,3 米处 6 dBm 的抖动对应大约 −1.5 到 +3 米的距离波动。这种不对称来自对数曲线本身。你的位置估计会像弹珠一样到处乱撞。

这是你撞上的第一堵墙。在它能做任何定位计算之前,你必须先解决这个问题。

EMA:指数移动平均

解决办法是信号滤波。你不能直接用原始的 RSSI 值,它们抖动得太厉害,必须先做平滑。但平滑也不能太狠,否则系统会变得迟钝,位置跟不上实际移动。

我选了指数移动平均,也就是 EMA。思路很简单:每来一个新的 RSSI 读数,就让平滑值朝它挪一小步,而不是整个替换掉。这一步的大小由一个平滑系数控制,而系数由窗口大小推导出来 N

α = 2 / (N + 1)
ema_new = α × rssi_new + (1 - α) × ema_old

IndoorMath.swift 里的实现为每个 beacon 维护一个 RSSI 历史缓冲区。新读数到达时,先看缓冲区是否已经填满 EMA 窗口。没填满就直接把原始值追加进去,因为数据还不够,谈不上平滑。一旦缓冲区满了,EMA 公式就开始生效:

窗口是滑动的:新值进来,旧值从前面掉出去。默认窗口大小是 10,对应的平滑系数约为 0.18。也就是说,每个新读数对平滑值的贡献大约是 18%,剩下的 82% 来自历史值。

窗口大小可以由用户配置,范围从 5 到 100。窗口越小,系统响应越快(你一移动,位置马上更新),但放进来的噪声也更多。窗口越大,曲线越平滑,但滞后越明显。我在真实房间里拿真实 beacon 反复测试后,把默认值定在了 10。这是个甜点:屏幕上的位置标记移动得干脆利落,而不是乱抖,但也不至于像在糖浆里游泳。

还有一个过期检查。如果某个 beacon 超过 12 秒没有收到信号,就把它整个从缓冲区里踢出去。这是为了处理你走出某个 beacon 覆盖范围的情况。你总不希望一个已经看不见的 beacon 留下的陈旧数据继续污染三边定位。

从 RSSI 到距离:路径损耗模型

拿到平滑后的 RSSI 值后,下一步就是把它换算成距离。所用模型是对数距离路径损耗公式的简化版:

distance = 10 ^ ((txCalibratedPower - rssi) / (10 × n))

其中 txCalibratedPower 是在距信标恰好 1 米处测得的 RSSI。它是参考点,是把抽象的 dBm 数值与物理距离绑定在一起的锚。n 是路径损耗指数:自由空间中信号功率按距离的平方衰减,n = 2 对应 Friis 传输方程里的 20·log₁₀(d) 项。这个自由空间的 2 就是 app 最初发布时采用的值。真实建筑很少符合它,本文后面的校准方法讲的正是这件事。频率被折算进了 1 米参考值里,而不是指数里。

实现只有一个函数:

代码里的那个除数 10.0 * pathLossExponent,就是上面公式中的 10 × n。指数在这次提交中变成了参数,默认值对两个现有调用点都保留了 2021 年的原始行为。当你把下面的校准方法应用到真实楼层时,n 就是你要按区域拟合的那个数。

如果 txCalibratedPower 是 -59 dBm(Estimote 或 Kontakt.io 信标的典型值),而你平滑后的 RSSI 读数是 -70 dBm,那么在自由空间默认值下,距离约为 10^(11/20) ≈ 3.5 米。同一个读数改用现场拟合出的指数 2.8,估计值就缩到 10^(11/28) ≈ 2.5 米。同样的无线电,同样的算术,一米的差距完全由指数带来。够简单了。

txCalibratedPower 并不是一个普适常数。每个信标都略有不同。制造公差、天线朝向、电量、外壳材质:这些都会让 1 米参考值偏移几个 dBm。如果一个信标的真实值是 -65,你却按 -59 来算,距离估计就会系统性地偏差 10^(6/20) ≈ 2 倍。你会把 2 米外的信标当成 4 米外。整个系统就废了。

这就是校准存在的理由。

1 米参考值

BeaconIL 内置了校准模式。把 iPhone 放在距信标恰好 1 米、高度相同的位置,按下 “Start calibration”。app 会采集 RSSI 测量值(默认 30 个)并取平均,得到该信标专属的 txCalibratedPower

校准过程中会实时显示 RSSI 值,方便你观察它何时稳定下来。采样数量足够后,平均值会作为该 beacon 的 onMeterRSSI 存入 Core Data。校准数据量可以配置:从 10 到 150 个样本任选。样本越多,平均值越准,但校准过程也越长。30 是个实用的默认值:CoreLocation 大约每秒回调一次,所以大约需要 30 秒,得到的参考值已经足够稳定。完整的流程——包括应用、通知等等——会在后文出现。

图 2 — 单个 beacon 的设置与校准界面。用户在平面图上设置 X/Y 坐标、1 米处的 RSSI 值和名称。校准按钮会启动一次实时采样。采样过程中 RSSI 字段实时更新。

图 2 — 单个 beacon 的设置与校准界面。用户在平面图上设置 X/Y 坐标、1 米处的 RSSI 值和名称。校准按钮会启动一次实时采样。采样过程中 RSSI 字段实时更新。

应用会提醒你不要在校准期间把它切到后台,因为应用不在前台时 iOS 会暂停 BLE 测距。那样读到的数据要么过期要么缺失,校准结果就是垃圾。

还有一点需要如实说明。距离公式里的指数 2 是自由空间的值,而真实建筑并不是自由空间。实测的室内指数,从开阔大厅的约 1.6 到混凝土遮挡下的 4 甚至更高都有。校准把曲线锚定在 1 米处,所以近距离的估计仍然可靠。指数固定为 2 时,曲线远端会系统性偏移。在 4 米乘 6 米的房间里,这种偏移可以接受;放在仓库里就不行了。

把校准扩展到真实楼层是另一套流程,而房间与工厂之间的差距正是它的起点。一个实用的做法:

先走一遍现场,再动手安装任何东西。记下金属货架、冷库通道、移动的机械、人流的走向。这些决定了你的指数分区:已发表的测量结果显示,开阔空间接近 2,办公室为 2.7 到 3.5,杂乱的工业厂房则是 4 或更高。你在 4 米乘 6 米实验室里测出的数字并不能直接搬过去。

在信标实际部署的位置做 1 米校准,而不是在图方便的位置。然后沿一条基线采集参考测量值:站在已知距离处——2、4、8 米——记录 RSSI。每个区域两个点就够拟合出一个局部指数:在对数距离图上对测得的点对做直线拟合,斜率就是你的 n。把这个 n 填回该区域信标的 calculateRealDistance,它正是上面代码清单里的 pathLossExponent 参数。混凝土墙后面的区域该有自己的指数,不该和开阔场地共用一个。

按计划重新校准。货架会挪,库存会周转,布局会变。已发布的部署指南建议高精度应用每季度重新校准一次。而当环境实在不听话时,干脆丢掉物理模型。替代方案是指纹法:在已知参考点记录 RSSI 模式,再把实时读数与这张图匹配。它用实测现实换掉模型,是大而不规则空间的标准答案。

图 3 —— 校准方法一张图看完。左:四组模拟基线读数(1、2、4、8 米;txPower -59 dBm,真实指数 2.8,阴影噪声 2 dB),以及对数距离图上的最小二乘直线;其斜率为指数乘以负十。右:指数取错要付出什么代价。自由空间默认值 2 会让估计值随距离增大而偏离对角线(8 米被读成约 23 米),而拟合出的指数能跟上真实距离。这里拟合值落在 3 附近,真实值是 2.8:四个带噪点略微高估,这正是该方法要求按区域分别拟合、而不是用一个全局数字的原因。

图 3 —— 校准方法一张图看完。左:四组模拟基线读数(1、2、4、8 米;txPower -59 dBm,真实指数 2.8,阴影噪声 2 dB),以及对数距离图上的最小二乘直线;其斜率为指数乘以负十。右:指数取错要付出什么代价。自由空间默认值 2 会让估计值随距离增大而偏离对角线(8 米被读成约 23 米),而拟合出的指数能跟上真实距离。这里拟合值落在 3 附近,真实值是 2.8:四个带噪点略微高估,这正是该方法要求按区域分别拟合、而不是用一个全局数字的原因。

为什么线性三边定位行不通

前面讲的是部署规模的校准。现在回到一个房间里、一部手机上:你手上有到至少三个 beacon 的距离,也知道每个 beacon 在平面图上的位置。接下来就该定位了。

教科书上的做法是三边定位:以每个 beacon 为圆心、以测得的距离为半径画圆,三个圆的交点就是你的位置。干净、优雅,而在现实世界里完全不能用。

问题在于,距离估计从来都不精确。它们是从带噪的无线电信号里算出来的近似值——信号被过滤过,但没有被净化。所以实际上,三个圆几乎不会交于一点。它们要么重叠成一片乱七八糟的区域,要么根本不重叠。两个圆可能有两个交点,第三个圆可能一个都不沾。不存在干净的几何解。

你或许可以试着对交点取平均,或者取重叠区域的质心。这些土办法效果很差。它们忽略了一点:有些距离估计比另一些更可靠。信号强、距离近的 beacon 给出的估计,要好过处在噪声底、距离远的那个。

正确的做法是别再把它当成几何问题,而是当成优化问题:在距离测量不完美的情况下,找出最优的位置。

Levenberg-Marquardt:非线性最小二乘

与其去找一个完美的交点,不如定义一个代价函数,用来衡量任意一个候选位置有多差,然后把它最小化。代价函数是残差平方和,写成距离平方之差的形式:

f(x) = Σ ( ‖x - pᵢ‖² - dᵢ² )²

其中 x 是候选位置,pᵢ 是 beacon i 的已知位置,dᵢ 是到该 beacon 的测量距离。对每个 beacon,计算候选位置到该 beacon 的欧氏距离平方,减去测量距离的平方,把差值平方,再对所有 beacon 求和。使这个和最小的位置就是你的最优估计。这种写法(距离平方之差,而非距离之差)在存在精确解时与直接形式有相同的最小值点;在带噪数据上两者可能略有差异。作为交换,平方形式给出的 Jacobian 更简单。偏导数对坐标是线性的(2x - 2pᵢ),因此每次 LMA 迭代的计算都更便宜。

这是一个非线性最小二乘问题,标准解法是 Levenberg-Marquardt 算法。LMA 融合了梯度下降和高斯-牛顿法,会根据当前离解还有多远来调整自身行为。离最小值较远时,它的表现像梯度下降:慢但可靠,沿着最陡下降方向小步前进。接近最小值时,它切换到高斯-牛顿法:收敛快,步长更大、更精确。这一切换由一个阻尼参数 λ 控制,每次迭代都会根据这一步是否降低了代价来调整它。

完整实现见 LMAMath.swift:771 行 Swift,没有任何外部依赖。它是 Apache Commons Math Levenberg-Marquardt 优化器的移植版,原版用 Java 写成。同一个算法也存在于一个开源 Go 库 https://github.com/Vitaliy69/lmamath 中。它的首次提交日期是 2021 年 6 月 28 日,同一天 Swift 核心收到了加固更新,增加了额外检查和对 3D 空间的支持。同一套数学的两个独立实现,从此互相验证。

写这篇文章时,我在共享测试向量上把这两个移植版互相对照。在存在精确解的输入上,两者返回的坐标精确到小数点后十二位完全一致。在有噪声的输入上,结果相差不到一微米,剩下的一点点是浮点舍入,不是逻辑上的差异。

本文的封面图也是用同样的方式构建的。图上三个信标位于固定坐标。那些圆是测得的距离,由真实的 RSSI 值通过路径损耗公式推导而来。那颗红星不是装饰。它是求解器对那个确切场景给出的答案,在绘制封面时算出来的。我用来生成场景的真实位置就在几厘米之外,因为有噪声的圆把估计值拉离了几何理想值。这种偏移就是本文的全部故事,浓缩在一张图里。把这三个圆输入任意一个移植版,你落到的点几乎分毫不差。

两个移植版本都配有只需一条命令即可运行的测试套件。Swift 包用测试可执行文件构建求解器;Go 模块则有 go test。两者共用同一组测试向量:精确输入、带噪输入、更高维度的锚点,以及必须被拒绝的畸形输入。在拒绝这一侧,两个实现各自挣来自己的防护。Swift 在 2021 年 6 月的提交里加上了维度检查。Go 直到几年后才补上,起因是它自己的测试套件在混合维度的锚点上 panic——正是 Swift 版本两年前修掉的那个 bug。修复在 panic 出现的同一天进了仓库。一个移植版本,只有在以同样的方式失败之后,才算完成。

Go 库的一次运行就能把整个故事压缩进一个场景。喂给它四个锚点和四个带噪距离,它从质心出发,感知代价函数往哪个方向倾斜,然后迈出小心阻尼过的步子,直到估计值不再移动。下面这个场景就是那次运行,从求解器自己的循环里追踪出来的。每个半透明球体是一个带噪距离。红色路径是估计值在下坡行走。星号是它最终停下的位置,离真值大约半米。右侧面板从内部展示同一次运行:代价塌缩,阻尼上升过一次,用来刹住一个坏步子。当阻尼降到零,求解器走的就是纯 Gauss-Newton 步。

图 4 —— Go 移植版本求解一个三维案例,从求解器自己的循环中追踪得到。四个锚点位于不同高度,这正是 2021 年 6 月的提交解锁的维度;距离带噪(±0.35 m);收敛路径从质心初始猜测走到估计值:距真值 0.45 m,7 次迭代。右图:每次迭代的代价与阻尼因子 λ,λ 上升过一次以阻尼一个坏步子,随后降到零,因为求解器在最小值附近切换到 Gauss-Newton 步。噪声源正是本文反复遇到的那些:一个站在 B2 路径上的身体,一个同频段的 Wi-Fi 接入点。(为便于阅读,Z 轴拉伸 1.25 倍。)

图 4——Go 移植版求解一个三维案例,轨迹直接取自求解器自身的循环。四个锚点位于不同高度,这正是 2021 年 6 月那次提交解锁的维度;距离带有噪声(±0.35 m);收敛路径从质心初始猜测出发到达估计值:与真值相差 0.45 m,迭代 7 次。右图:每次迭代的代价与阻尼因子 λ。λ 上升过一次,用来压制一个糟糕的步长,随后随着求解器在最小值附近切换到 Gauss-Newton 步长而降为零。噪声来源正是本文反复遇到的那几个:一个站在 B2 路径上的身体,一个位于同一频段的 Wi-Fi 接入点。(为便于阅读,Z 轴拉伸 1.25 倍。)

Swift 入口点是 solve(positions:distances:)

那段注释的符号写反了。target[i] 恒为零。最优点处的实际方程是 (x0-xi)^2 + (y0-yi)^2 - ri^2 = target[i],与上面的代价函数一致,但注释写成了 +xi+ri^2。这无害。注释不会执行。下面的 value()jacobian() 函数算的是对的。就当它是一个留在已发布代码里的小瑕疵吧。

有几个设计决策值得说明。

初始点。 优化器需要一个起始猜测。我用质心,即所有信标位置的平均值。这是一个合理的起点,因为设备位于信标覆盖区域内的某处,而质心正在该区域中央。更好的初始猜测意味着收敛所需的迭代次数更少。

平方反比加权。 每个信标对代价函数的贡献都乘以权重 1/d²。这意味着更近的信标(其距离估计更可靠)对解的影响更大。距离 1 米的信标权重是距离 5 米的 25 倍。这就是平方反比定律,它对应着一个物理事实:信号强度随距离的平方衰减,因此离信号源更近的测量本质上更可信。

收敛判据。 当代价的相对下降量低于 1e-10,或步长小于参数范数的 1e-10 倍时,优化器停止。这些容差很紧,所以算法不会提前停止。它还有 1000 次迭代和 1000 次函数求值的硬上限作为安全网,不过实际上它会在 5 到 20 次迭代内收敛。

QR 分解。 每次迭代时,用带列主元的 QR 分解对雅可比矩阵(残差对位置参数的偏导数矩阵)做分解。这种做法数值稳定,遇到秩亏的情况也能从容处理。实现里包含完整的 QR 分解例程,用 Householder 反射做变换,用列主元保证数值稳定性,并用 Givens 旋转确定 LM 参数。

这个问题的雅可比矩阵很直接。残差对每个坐标的偏导数就是 2x - 2pᵢ

残差函数计算的是距离平方之差:

把这些拼起来,就得到一个对噪声距离足够稳健的位置估计。算法不要求三个圆交于一点(实际中它们永远不会),而是找出与现有数据拟合最好的位置,并按每个测量值的可信程度加权。当视野里有第四个、第五个信标时,系统会自动把它们都用上。信标越多,约束越多,拟合越好。算法根本不在乎“三个圆”的几何关系,它只是最小化误差。

线程隔离:让 UI 保持流畅

BLE 扫描会持续产生更新。CoreLocation 每秒回调一次 didRange,每次回调都带一个数组,包含所有可见信标及其最新的 RSSI 值。如果能看到 5 个信标,那就是每秒收到 5 个更新过的 CLBeacon 对象。这个负载不算大,但如果在主线程上随意处理,UI 照样会受影响:表格视图会卡顿,SpriteKit 场景会掉帧,整个 app 用起来就像坏了一样。

解决办法是显式管理线程。BeaconScan 处理测距回调(过滤已知信标、计算距离、构建 diffable data source 快照),然后显式切回主线程来应用快照并触发可视化更新:

通知会在 ScanController 中触发 calculateLocation(),进而运行 IndoorMath.updateVisibleBeacons()IndoorMath.getLocation()。这些都是纯计算(EMA 滤波、距离换算和 LMA 求解器),之所以放在主线程,是因为结果马上要被 SpriteKit 场景消费。对 MVP 来说,这是有意做出的取舍。三到五个信标时,求解器几微秒就能收敛,放到后台队列只会增加上下文切换的开销,看不到任何实际收益。我实测过。在一台当前的 Mac 桌面机上,用优化构建的 LMAMath.swift 对两个固定场景(分别有三个和五个信标)循环求解,三个信标平均每次约 5 微秒,五个信标约 10 微秒。手机会慢一些,但仍在微秒量级。按 Geekbench 单核成绩,iPhone 11 大约比这台 Mac 慢 2.5 倍,五个信标的求解也就是几十微秒。结论不变。

快照在任何东西接触 UI 之前就已完整构建,这才是关键所在。DispatchQueue.main.async 随后保证它会在主线程上应用,无论测距回调是从哪个队列送来的。AppDelegate 还持有一个 tableShouldUpdate 标志,ScanController 会在 viewDidDisappear 中把它设为 false,因此当用户没有盯着扫描界面时,扫描控制器会彻底停止处理。没人在看的时候,在三边测量上烧 CPU 毫无意义。

用 SpriteKit 渲染平面图

可视化层我用了 SpriteKit,苹果的 2D 游戏引擎。对一个工具类应用来说,这选择看着有点怪。用 UIKit 会更常规。但 SpriteKit 给你一个带坐标系、z 轴排序和内置动画的场景图,这些在渲染带移动物体的平面图时都用得上。

VisualizationScene 类管理场景。信标画成蓝色方块 sprite,设备位置画成红色标记。平面图可以从照片图库加载,以 50% 不透明度作为背景层放进去:

zPosition 系统保证背景始终在信标和位置标记之后。场景更新时,所有已有的信标节点和位置节点都会被移除,再以新位置重新添加。这不是最高效的做法(你可以在原地更新已有节点),但对少量对象来说足够快,也省去了节点池的复杂度。

场景的坐标系以 (0, 0) 为中心,根据「View Area Size」设置向外延伸。信标坐标相对于这个中心点存储。如果把某个信标的 X 设为 5.0、Y 设为 -3.0,它就会出现在场景中心右侧 5 个单位、下方 3 个单位处。缩放系数把场景单位映射到像素,并随区域大小变化而调整。

有个不错的细节:SKView 上的 preferredFramesPerSecond 被设为 1。这听起来不对。游戏引擎上为什么要 1 fps?但这不是游戏。场景不需要以 60 fps 持续渲染,只需要在有新位置数据时重绘,也就是测距回调到达的时候。把帧率设为 1,意味着 SpriteKit 每秒 tick 一次场景,足以让背景和节点位置保持新鲜,又不会为不必要的渲染耗电。真正的位置更新通过 updateVisualisation() 按需发生,它直接操作节点树。

图 5 — 全局设置界面。UUID 字段决定扫描哪个 iBeacon 区域。View Area Size 控制场景缩放比例。Calibration Data Size 设定校准时平均多少个 RSSI 采样。EMA Filter Size 控制平滑窗口。iCloud 同步可以开关(需重启应用)。

图 5 — 全局设置界面。UUID 字段决定扫描哪个 iBeacon 区域。View Area Size 控制场景缩放比例。Calibration Data Size 设定校准时平均多少个 RSSI 采样。EMA Filter Size 控制平滑窗口。iCloud 同步可以开关(需重启应用)。

持久化:Core Data、Codable 与 CloudKit

Beacon 的配置(坐标、校准后的 RSSI、名称)需要在应用重启后依然存在,并在多台设备间同步。这里的架构分三层。

序列化。BeaconCoordinates 是一个普通的 Codable 结构体:

每个 beacon 的配置序列化成 JSON,以字符串形式存进一个名为 Beacons 的 Core Data 实体。该实体只有一个属性 data,用来保存这个 JSON 字符串。计算属性 dataAccessor 透明地处理编解码:

这个做法不太常规。通常你会把每个字段建模成单独的 Core Data 属性,而不是把 JSON 塞进一个字符串。但这么做有个实际理由:CloudKit。Core Data 通过 CloudKit 同步时,每个属性都会变成 CloudKit schema 里的一个独立字段,而 schema 变更需要迁移。把配置存成单个 JSON 字符串,CloudKit schema 就能保持简单稳定。给 BeaconCoordinates 加一个新字段只是 Codable 层面的改动:不用迁移,也不用更新 CloudKit schema。

本地持久化。BeaconKnown 是 CRUD 层。它在初始化时取出所有 beacon,提供 addBeacondeleteBeacon 方法,并通过 NSManagedObjectContext 保存。context 开启了 automaticallyMergesChangesFromParent,所以 CloudKit 同步带来的变更会直接反映出来,不需要手写合并逻辑。

云端同步。AppDelegate 延迟初始化 persistent container。如果启用了 iCloud 同步(默认开启),它会使用 NSPersistentCloudKitContainer 而不是普通的 NSPersistentContainer

启用 CloudKit 后,Core Data 会自动将变更同步到 iCloud,并在用户的多台设备之间保持同步。在 iPad 上配置好的信标,会出现在 iPhone 上。entitlements 文件声明了 CloudKit 容器标识符和各项服务。关闭同步时,应用会退回到仅本地存储,并启用持久化历史跟踪(在不使用 CloudKit 的情况下,要正确处理变更就必须开启它)。

校准流程全貌

下面完整走一遍校准一个信标时实际发生的事,因为它几乎牵涉到每一个组件。

你在列表中点击一个信标,触发跳转到 BeaconSettingsController。界面上有 X、Y、RSSI 和名称几个字段。你输入信标在平面图上的物理坐标,比如 (5.0, 3.0),再填个名字,比如 "B1"。RSSI 字段默认是 -59,一个常见的出厂值。

你按下 "Start calibration"。弹窗提示你把手机放在距信标 1 米、高度相同的位置。你确认。calibrationInProgress 标志被置为 true,RSSI 字段被禁用,同时发出一个通知:

BeaconScan 收到这个通知,保存正在校准的信标。在下一次测距回调中,它从可见信标里筛选出 major/minor 匹配的那一个,并把它的原始 RSSI 发回去:

BeaconSettingsController 收到每一个 RSSI 值,递增计数器并累加求和。实时 RSSI 会显示出来,让你看到读数不断进来:-61、-58、-63、-60、-59……当计数达到配置的校准数据量(默认 30)时,计算平均值并保存:

calibrationInProgress 上的 didSet 触发,发出停止通知,显示最终值,重新启用该字段,并重置累加器。你按下 Save,BeaconKnown.addBeacon 把校准后的信标持久化到 Core Data。如果 CloudKit 开着,它还会同步到你的其他设备。

此后,当这个信标在扫描中可见时,IndoorMath 会用校准后的 RSSI 作为距离计算中的 txCalibratedPower。1 米参考值不再是一个猜测,而是针对这个具体的物理信标、在这个具体的环境中、用这台具体的手机测出来的值。

App Store 之路

这款 app 如今已不在 App Store 上,但它确实上架过。它以「Beacon Indoor Location」之名发布,App Store ID 为 1561643830,先后推出 1.0 和 1.2 两个版本,均通过了 Apple 审核。1.2 版一路走完 App Store Review,最终状态为「Ready for Distribution」。后来下架,并非 app 本身有问题,而是背后的商业开发者账号被临时冻结。那时 BeaconIL 已经完成了它要做的事:在一个 Apple 批准的、面向真实消费者的 iOS app 里,把从原始 BLE 信号到平面图上一个小点的整条链路跑通,端到端验证了概念。剩下的是一个可以进一步扩展、也能适配美国市场基础设施标准的架构——如果真要走这条路的话。代码还在,以 MIT 许可证开源。

图 6 — BeaconIL 在 App Store 上。该 app 通过了两轮 App Store Review 并发布。目前列表处于非活跃状态,架构则在为更大规模的部署做准备。

图 6 — BeaconIL 在 App Store 上。该 app 通过了两轮 App Store Review 并发布。目前列表处于非活跃状态,架构则在为更大规模的部署做准备。

数学的演进:一个 bug 和一次泛化

git 历史本身就说明了问题,也展示了这套数学是怎么真正成熟起来的。

第一次提交是在 2021 年 4 月 22 日。整个代码库,71 个文件、3,947 行,作为一次「First public commit」全部提交。对一个私下开发、之后才推到远端的个人项目来说,这并不稀奇。稀奇的是接下来发生的事:两个月里什么都没动。然后 6 月 28 日,第二次提交只碰了两个文件:LMAMath.swiftIndoorMath.swift。提交信息把这次改动描述为增加了额外的检查,并支持两个以上的维度。

那次提交修掉了一个隐蔽但严重的问题。在原始代码里,迭代计数器和求值计数器被初始化成了最大值本身(1000 和 1000),而不是 0 和 1:

循环里根本没有对这些上限做任何检查。计数器是有了,却从未真正生效。一旦优化器不收敛(比如信标几何分布极端糟糕,或者距离数据前后矛盾得离谱),while true 循环就会一直转下去,把应用卡死。这些上限看起来像一张安全网,但没有任何东西去执行它们。

六月的更新把两个问题都修了。计数器被正确地初始化为 0 和 1,循环内部也加上了真正的上限检查:

maxIterationsmaxEvaluations 也从实例变量改成了 static let 常量——一次小小的 Swift 重构,让它们成为类型上的编译期常量,而不再是每个实例各自持有的值。

那次提交里的第二处改动是返回类型。原来的 solve 函数返回一个固定的二维元组:

文件头写的是“Solves a formulation of n-D space trilateration problem (2D in this application)”。更新后的版本删掉了括号里的说明,把返回类型改成了泛型数组:

现在求解器返回的坐标数组维度与输入位置的维度一致。IndoorMath.getLocation() 里的调用方仍然只解包前两个元素,因为应用里的楼层平面图是平的,但求解器本身并不局限于两个轴。雅可比矩阵、残差函数和 QR 分解全都按任意维度运算,所以同一份代码不用改动就能处理多层零售店的布局。

那次提交还加了维度校验:如果输入位置的维度不一致,求解器返回空数组,而不是算出一堆垃圾。测量数据也受到同样的对待:距离为零、为负或非有限值,锚点坐标非有限值,都会在一开始就被拒绝。原因出在加权上。平方反比加权会把零距离变成无穷大的权重,而一个 NaN 会悄无声息地穿过 QR 分解,混进返回的位置里。应用本身不会产生这类值,因为它的距离来自路径损耗公式。但求解器是公开 API,它的输入值得和输出一样被严格审视。输入有问题就返回空数组,这是两个实现共同遵守的约定。

故事在 2026 年还在继续,就在这篇文章成稿期间。距离函数在 2021 年上线时,除数里硬编码了指数 2,因为 4 米 × 6 米的测试房间从未让这个选择暴露出问题。编写大场地标定方案时,才暴露出这种硬编码在部署规模下的代价,于是指数变成了你在清单里看到的 pathLossExponent 参数,默认值为 2,这样原来两处调用点的行为与之前完全一致。五年之后,一个提交,把实验室测量和工厂需求之间的闭环合上了。

iBeacon 实际部署:无线电物理告诉了你什么

如果你真的要用 BLE beacon 做室内定位,物理规律会带出一些实用经验。这些经验与具体应用无关,适用于任何用 RSSI 估算距离的系统。

iBeacon 数据包格式

iBeacon 广播包是一个 BLE 广播,带有 Apple 定义的特定载荷结构。包中包含:

  • UUID(16 字节)——一组 beacon 共享的区域标识,例如 07070707-0405-0607-0809-0A0B0C0D0E00。手机会按这个 UUID 过滤 beacon,所以同一次部署中的所有 beacon 通常共用它。
  • Major(2 字节,0–65535)——子组标识,例如“2 层”或“B 栋”。
  • Minor(2 字节,0–65535)——子组内的单个 beacon 标识,例如“2 层的 14 号 beacon”。
  • TX Power(1 字节,有符号)——出厂时测得的 1 米处 RSSI 标定值。这就是距离公式使用的 txCalibratedPower。它只是一个起点。你应该像我上面说的那样在现场重新标定,因为出厂值没有考虑你的具体环境。

beacon 以可配置的间隔广播这个包,通常是 100 毫秒(10 Hz)。间隔越短,数据越多、响应越快,但电池寿命越短。以 100 毫秒间隔运行的纽扣电池 beacon 能撑几个月;间隔改为 1 秒,则能撑一年以上。做定位时,你要选更短的间隔:响应速度比电池寿命更重要,而且在长期部署中 beacon 通常接墙电。

部署几何

三边测量要求在你希望获得位置估计的任意位置,至少能看见三个信标。这话听起来显而易见,但实际做到需要提前规划。

在商业零售场所、办公空间或企业物流仓库部署 iBeacon 时,几何布局决定了定位质量。一个在平面图上看起来合理的部署方案,如果几何关系不对,实际效果可能一塌糊涂。

最糟糕的布局是一条直线。沿走廊摆三个信标,几何条件极差。三边测量问题变得病态,微小的距离误差会沿垂直于这条线的轴放大成巨大的位置误差。求解器照样会收敛,但结果不稳定。

最好的布局是三角形:三个信标放在等边三角形的顶点,预期设备位置大致在重心附近。这样求解器能从多个角度获得均衡的约束。在矩形房间里,把信标放在四个角中的三个效果不错。在走廊里,把它们交错布置在两侧墙面,而不是全放在同一侧。

更大的空间要按重叠三角形来考虑。每一块你想定位的区域,都应该至少能看见三个信标,且大致呈三角形分布。加第四个信标能提供冗余、提升精度,但三个位置得当的信标胜过五个位置糟糕的。

高度很关键

这是室内定位部署中最常被忽略的因素。路径损耗模型假设信标和接收器处于同一高度。当两者高度不同时,实际距离是斜边——比水平距离更长——而 RSSI 反映的正是这个斜边距离,无论你的平面图有没有把它算进去。

如果信标安装在三米高的天花板上,手机握在 1.5 米处,垂直偏移就是 1.5 米。若水平距离为 2 米,真实空间距离是 √(2² + 1.5²) ≈ 2.5 米。RSSI 反映的是 2.5 米,而你的二维三边测量假设的是 2 米。仅仅因为高度不匹配,就引入了 25% 的误差。

对于 BeaconIL 这样的二维定位系统,解决办法是把信标安装在和手机差不多的高度——1.5 到 2 米——固定在墙上而不是天花板上。这未必总是可行,但这是你能做的最大的一项精度改进。如果天花板安装无法避免,你有两个选择。第一,在软件里补偿高度差,用勾股定理从距离估计中减去垂直分量。第二,把高度显式建模:2021 年 6 月的更新让数学部分可以接受任意维度的坐标,因此每个信标可以携带自己的安装高度,垂直偏移就成了模型的一部分,而不再是误差。

环境因素

BLE 使用的 2.4 GHz 频段和 Wi-Fi、微波炉以及大量其他消费电子产品相同。需要留意的干扰源:

  • Wi-Fi 接入点——尤其是 2.4 GHz 频段上的 802.11n/ac/ax。这些信道与 BLE 广播频率重叠。不要把信标安装在距离 Wi-Fi AP 1 米以内的地方。
  • 金属表面——冰箱、服务器机架、电梯井、金属货架。它们会产生强烈的多径反射。放在金属货架后面的信标实际上等于不存在。
  • 人体——人体大约 60% 是水,而水会吸收 2.4 GHz。拥挤环境中的 RSSI 会比空旷环境低 3–5 dBm。这是系统性的,不是随机的,所以它会使距离估计偏高。没有简单的解决办法。EMA 滤波器能平滑过渡,但偏差依然存在。
  • 玻璃和隔断——玻璃对 2.4 GHz 基本透明,但镀膜或着色玻璃(low-E 窗)会让信号衰减 5–10 dBm。干墙隔断每面墙衰减 2–4 dBm。混凝土墙衰减 10–20 dBm,实际上会阻断信号。

实际结论:在系统将要运行的环境中校准,而不是在实验室里。空旷走廊里测得的 1 米参考 RSSI,和堆满金属货架的仓库里测得的不会一样。BeaconIL 的逐信标校准正是为此而存在。

不是每个信标都需要大声喊

最省事的直觉,是把每个信标的发射功率都拉满。距离更远、覆盖更广,完事。但在工厂车间里,这种直觉会适得其反。到处都是金属,机器在运转,人在走动:一个大声的信标放大的是反射和干扰,而不是覆盖范围。在真实制造环境中的测量发现,BLE 信号在那里勉强可用,但衰减明显,而且布点和密度比原始功率更重要。

我们的答案是功率阶梯。

普通信标承担粗定位层。但有些设备故意发射极弱的信号。它们一超出短距离就几乎立刻无法被检测到。在近距离,经过同样的逐信标校准后,它们的距离估计接近理想值。一个只有靠近才能听到的信标,能高置信度地告诉你一件事:你离它很近。

阶梯的最底层是行为类似 NFC 的近接触发射器。大约十厘米内可检测到。当手表靠近受管控的机器到这个距离时,警报会在设备上触发。半米的平均精度对导航来说没问题。但作为冲压机附近的最后一道安全边界,它不行。机械安全工程考虑的是人与危险区之间的最小隔离距离,而一个带有半米误差的估计无法充当这条边界。于是这条边界成了独立的一类设备。

这个教训不限于工厂。高发射功率有时是敌人。它换来距离,付出的代价是多径、干扰和虚假的自信。刻意使用弱信号反而是一种特性:把距离塌缩当作接近信号。

我会怎么改

隔了一段时间回头看这份代码,有些地方我会改。

EMA 的实现把原始 RSSI 值存在一个数组里,手动计算指数平均,每次更新都平移数组。这能跑,但比需要的复杂。更干净的做法是只存当前的 EMA 值和窗口大小,直接套标准 EMA 公式:ema = α × new + (1 - α) × ema。基于数组的做法是早期实现时的选择,我一直没重构。

Beacons 这个 Core Data 实体把配置存成 JSON 字符串,而不是拆成一个个属性。CloudKit 那边的理由我在上面解释过,这是有意为之的取舍。但代价是你没法用 Core Data 的 predicate 按 major 值之类的字段查询,只能把所有 beacon 取出来逐个解码。几十个 beacon 的 app 无所谓,上千个就不行了。

LMA 求解器是从 Apache Commons Math 的实现原样移植过来的,也就一并继承了那部分代码的复杂度。determineLMParameterdetermineLMDirection 两个方法写得很密,实现的是计算 LM 参数的 Moré–Hebden 算法,里面涉及 Givens 旋转、秩判定和一个区间收缩循环。逻辑正确,测试也充分,但读起来和维护起来都不轻松。更现代的做法或许是用更简单的信赖域方法,或者干脆把线性代数交给 Accelerate(iOS 自带的库)来做。

数学才是重点

BeaconIL 有意思的地方不在 UI,也不在 App Store 上架。是数学。室内定位从外面看很简单(“用蓝牙信号判断你在哪儿”),真做进去才发现是个无底洞:用 EMA 滤波对抗无线电噪声,用逐 beacon 标定锚定的路径损耗模型,用 Levenberg-Marquardt 把带噪声的距离变成位置,再用线程隔离把 BLE 回调挡在 UI 之外。每一块单独看都不复杂,难的是让它们在真实硬件上实时协同工作——而硬件的行为往往和教科书上写的不一样。在 4 米乘 6 米、三个 beacon 的测试场地上,整条链路把估计误差控制在半米左右。这才是工程上的挑战,也是这个项目值得做的原因。

代码在 https://github.com/Vitaliy69/BeaconIL。三边定位求解器的独立 Go 移植版在 https://github.com/Vitaliy69/lmamath。两者都是 MIT 许可。

来源: HackerNoon← 返回首页