你大概见过这种场景:举起手机,相机对准一栋楼,屏幕上浮标提示那栋楼的名字和距离;开车时把手机固定在支架上,顶部箭头随着每一次转弯同步摆动。这类交互都有一个共同的底层能力——camera方向的追踪,也就是在Android系统里实时估算相机镜头的指向,然后把这个方向稳定地暴露给上层应用。这篇文章是我做过几个方向类项目之后的整理,把传感器计算、视觉追踪、融合实现、上线前踩过的坑都放在一起。如果你准备做行为识别、AR导航、巡检场景的相机方向相关功能,又或者只是想把手机当成一个高精度电子罗盘来玩,都可以从里面挑你需要的那部分。
1. 先把“追踪”拆明白:三个坐标系和三个角度
1.1 三个坐标系决定了一切
方向追踪难不难?很多人第一反应是“不就是拿磁力计测个角度吗”。真做起来你会发现,难点根本不在角度计算,而在坐标系。手机里至少有三个坐标系在同时运行:
- 世界坐标系,一般取北、东、地这样的正交基准,用来定义“绝对方向”。
- 设备坐标系,由手机硬件定义。X轴沿屏幕横向向右,Y轴沿屏幕纵向向上,Z轴垂直屏幕向外。
- 图像坐标系,它是二维的,以画面左上角为原点向右向下延伸,相机光轴方向需要结合内参矩阵才能反推出来。
当你从相机预览帧拿到一张画面时,每个像素其实都携带了一条方向线索:像素在图像里的位置,加上相机内参,可以得到一条从光心射出的3D射线。camera方向的追踪,最终要回答的问题就是:这条射线在世界坐标系里指向哪里。所有视觉方向的算法,本质上都在做“像素坐标→相机坐标→世界坐标”这条链路的换算。
这里有一个经常被忽视的细节:设备坐标系和相机坐标系通常不是重合的。后置摄像头的光轴一般沿设备Z轴的负方向,但不同厂商的排布会有差异。如果你直接把传感器算出来的姿态当相机姿态用,很可能出现画面里的“水平”和算法里的“水平”差了几度。
1.2 方位角、俯仰角、横滚角究竟在描述什么
方向追踪的输出,通常可以拆成三个互相独立的角度:
- 方位角(azimuth),相当于罗盘上的角度,0度是正北,90度是正东,它描述的是设备绕Z轴的旋转。
- 俯仰角(pitch),镜头在垂直方向是抬头还是低头,围绕设备X轴。
- 横滚角(roll),设备绕Y轴的侧倾。拍照片把屏幕转歪的那一下,就是roll在变。
大部分业务场景只需要方位角和俯仰角。比如用相机指向某个建筑,你要知道镜头朝向在水平面上属于哪个扇区,仰角范围是否覆盖目标高度。如果只关心水平方向,可以退化成“一维追踪”;但如果要做完整的AR叠加或姿态识别,三个角一个都不能少。
用欧拉角表达姿态很直观,但它有一个著名的缺陷:万向锁。俯仰角接近正负90度时,方位角和横滚角的区分会变得模糊,数值会剧烈抖动。所以做融合的时候,我更喜欢用四元数做中间计算,只在需要展示或做业务判断时转回欧拉角。
1.3 追踪方向和追踪位置是不一样的问题
我第一次做导航类需求时犯过糊涂,以为方向算准了,目标位置就确定了。实际上,方向只是给出了一条射线,它没有距离。camera方向的追踪输出的是旋转状态,也就是三个欧拉角或者一个旋转矩阵;位置追踪则是在空间里算手机的平移量。
这两个是本质不同的自由度和状态。方向属于旋转群,位置属于三维空间。很多产品需求会把它们混为一谈,比如“我要知道相机对着哪”和“我要把虚拟物体固定在那个墙角”,看起来差不多,实际工程量差出了好几倍。开发者的第一件事是拆需求:目标是不带距离的朝向,还是带距离的6自由度位姿?拆对了,项目边界就清楚了一半。
2. 传感器路线:加速度计、磁力计、陀螺仪拼出一台电子罗盘
2.1 三颗传感器各自在姿态估计里承担什么
不考虑视觉的情况下,方向追踪最成熟的方案是传感器。这一类方案的本质,是把姿态问题换成矢量对齐问题。
加速度计一直“感知”着重力方向,它测到的重力矢量在世界坐标系里几乎是固定的,于是可以根据它在设备坐标系里的投影,推算出俯仰角和横滚角。磁力计测的是地球磁场矢量,把磁场投影到水平面上,就能得到设备相对磁北的方位角。陀螺仪不直接测方向,它测的是三个轴上的旋转角速度,积分之后得到的是相对上一时刻的姿态变化。
三个数据源各有各的毛病。加速度计静态时很准,但手机一抖动,它会把运动的线加速度当成重力的一部分,输出立刻污染。磁力计很容易被环境里的铁磁性材料干扰,在电梯、地下车库、钢筋楼里会乱蹦。陀螺仪没有绝对基准,纯积分的角度会在几分钟内漂出几十度。
所以工程上几乎不会用单一传感器去追方向,而是做融合。你要有的核心概念是:传感器融合不是把数值取平均,而是用各自的优势互相矫正。
2.2 用传感器融合计算方位角的完整思路
最经典的融合组合是“加速度计+磁力计”做电子罗盘。在Android的SensorManager里,这套逻辑被封装成了两个函数:
private float[] lastGravity; private float[] lastMagnetic; private float[] rotationMatrix = new float[9]; private float[] orientationAngles = new float[3]; @Override public void onSensorChanged(SensorEvent event) { if (event.sensor.getType() == Sensor.TYPE_ACCELEROMETER) { lastGravity = event.values.clone(); } else if (event.sensor.getType() == Sensor.TYPE_MAGNETIC_FIELD) { lastMagnetic = event.values.clone(); } if (lastGravity != null && lastMagnetic != null) { SensorManager.getRotationMatrix(rotationMatrix, null, lastGravity, lastMagnetic); SensorManager.getOrientation(rotationMatrix, orientationAngles); float azimuth = (float) Math.toDegrees(orientationAngles[0]); float pitch = (float) Math.toDegrees(orientationAngles[1]); float roll = (float) Math.toDegrees(orientationAngles[2]); // azimuth=0 表示北,正值表示向东偏转 } }getRotationMatrix做的事,是把重力矢量和磁场矢量融合成一个3x3旋转矩阵,描述设备坐标系相对世界坐标系的姿态。getOrientation再把矩阵分解成三个角度。原理说白了就是:重力方向把平面“扶正”,磁场在扶正之后的水平面上的投影角,就是方位角。
这里有一个许多人忽略的细节:磁力计是有零偏的。手机跌落过、磁吸支架吸过、部分后盖带磁,都会让方位角产生整体偏移。我在一个演示项目里,就遇到过静态精度调到很好,但换一台手机立刻偏15度的情况。解决办法是加校准环节,让用户拿着手机空中画“8”字,再根据磁场模长的标准差决定当前数据能不能信。
2.3 陀螺仪加入之后,为什么必须做滤波
只靠加速度计和磁力计还有个致命问题:它们响应慢,噪声大,拿来看手抖一下都会剧跳。陀螺仪响应快,可以平滑过渡,但漂移是硬伤。所以工程上普遍把三路数据融合起来。
最轻量的是互补滤波,更精细的是卡尔曼滤波家族。互补滤波的基本思路是:陀螺仪负责高频姿态预测,加速度计和磁力计负责低频姿态修正,两者按系数叠加。系数大,跟踪快但噪声大;系数小,噪声小但跟随慢。
我在实际调参时会单独给方位角、俯仰角、横滚角配不同的系数,因为磁场干扰和重力噪声在三个轴上的表现差异很大。比如横滚角主要受重力影响,加速度计噪声相对可控;方位角受磁场环境影响,修正系数就要保守一些。
注意:别急着自己写融合。先用系统自带的旋转矢量传感器
TYPE_ROTATION_VECTOR跑通流程,它能直接输出四元数,帮你跳过最难的初始标定。但要在一些定制系统上测试,它的更新策略不稳定,存在丢更新或长时间不更新的情况,必须加超时保护。
3. 视觉路线:让相机自己从画面里反推朝向
3.1 视觉方向追踪回答的是哪一类问题
视觉方案切到另一套语言:相机拍到的图像到底发生了什么变化?如果相机在原地转动,画面里的物体会产生规律的运动;如果相机平移,画面里会出现视差。视觉方向追踪要回答的问题是:给定两帧或多帧图像,相机的旋转矩阵R是多少。
这个问题在数学上有一套很标准的表达:配对的特征点满足对极几何约束,本质矩阵可以分解成旋转和平移;如果只关心旋转,问题还可以进一步退化成单应变换。也就是把两帧图像看成同一平面在不同旋转角下的投影,先解出单应矩阵,再从单应矩阵分解出旋转。
纯视觉方案的最大优点是:它不依赖磁场,不会被钢筋和吸铁石带偏。但它的代价是计算复杂、对场景有要求。毕竟相机不是传感器,它只能看到拍摄到的内容。
3.2 从两帧图像推算旋转变化的直觉步骤
要从两帧图像推算相机旋转,绕不开以下流程:
- 提取特征点。常用角点检测、ORB、AKAZE这类轻量描述子,找画面中不受光照和尺度变化影响太大的关键点。
- 估计光流或做特征匹配。上一帧的特征点要去下一帧里找到对应位置,输出一组匹配点对。
- 用RANSAC剔除误匹配。运动物体、反光、纹理重复会产生大量错误匹配,必须用随机抽样一致性方法反复筛掉野值。
- 根据剩下的点对求解单应矩阵或本质矩阵,再从矩阵里分解出旋转矩阵。
第4步有个容易踩的坑:不同视觉库的分解结果约定不一样,有的返回“旋转在前”,有的返回“旋转在后”,有的还带尺度约定。如果直接拿库的默认输出去算角度,可能刚换一个库版本,整个方向就错了90度。
另一个必须注意的问题是图像坐标系和相机坐标系的方向约定。Android的相机预览在不同分辨率、不同屏幕旋转下,图像坐标系可能翻转。你得先把它统一到相机像素坐标系,再做特征匹配。我见过不少项目把图像坐标直接喂给视觉算法,结果方向总差半城。
3.3 标记点方案为什么在工程里更流行
完全靠自然场景做视觉方向追踪不是不行,而是不稳定。背景纹理稀疏会失败,剧烈运动时图像模糊会失败,重复纹理匹配歧义会失败。所以工程里更常见的做法,是在场景里放一个已知几何尺寸的标记物,比如ArUco标记、AprilTag这类方形标签。
标记点的好处,是它提前给了你大量先验信息:角点顺序、边长、世界坐标里的精确位置。用标记点估计姿态只需要一步——检测四边形,再用PnP求解6自由度位姿。
但标记点方案只适合受控环境。如果场景不能贴标、没有固定参照物,就必须退回到自然特征追踪。这也解释了为什么移动端的大型方向应用很少只靠视觉:视觉适合做“增量修正”,传感器负责提供“绝对基准”,两者必须配合。
4. 融合路线:传感器快而漂,视觉慢而稳
4.1 两类数据互补的底层逻辑
我踩过最大的坑,就是试图用单一数据源做相机方向追踪。传感器和视觉这两种方案,刚好在最关键的指标上互补:
- 传感器的输出频率高,通常能到100Hz以上,响应实时。但它没有长期稳定性,磁场一乱就崩。
- 视觉的输出频率受帧率限制,通常只有30FPS,并且对运动模糊和纹理敏感,一旦丢失需要重新初始化。可只要两帧之间有足够重叠,视觉算出的相对旋转又非常稳定,漂移远小于陀螺仪纯积分。
融合方向说起来只有一句话:用传感器把方向从上一时刻推到新姿态,再用视觉结果把这个粗略值拉回真值。两者之间的权重,取决于各自在当下的置信度。这是“松耦合”融合最朴素的形式,但已经能解决大部分工程问题。
4.2 一个不需要矩阵库的互补滤波简化模型
为了让方向和位置能快速落地,我给出一个简化融合模型。方向用四元数 q 表示,陀螺仪积分给出相对变化 Δq_g,视觉观测给出 q_obs,那么计算过程就是:
预测:q_pred = q_prev * Δq_g 更新:q_new = slerp(q_pred, q_obs, alpha)这里的 alpha 是0到1之间的权重。视觉置信度高,alpha 取大一点;视觉置信度低,alpha 趋近于0。实现时用四元数的球面插值,代码只有几十行。真正麻烦的是如何定义“置信度”。
我通常用三套信号做判断:视觉匹配的内点率、磁力计模长与当地期望值的偏差、陀螺仪角速率是否超过阈值。任何一路信号告警,对应的 alpha 就往下降。这个逻辑不复杂,但如果你第一次写,建议先把每一维置信度单独打印出来,观察它和真实误差的相关性,再调权重。盲调参数基本等于碰运气。
如果你暂时不想碰四元数,也可以把三个欧拉角分别做互补滤波,但要注意角度环绕和万向锁问题。方位角从350度转到10度时,不能直接做算术平均,必须先处理跨零。
4.3 再往后走:视觉惯性里程计能追到什么程度
如果项目要求既追方向又追位置,就会进入视觉惯性里程计的领域。它把图像特征和IMU测量值放进同一个优化框架里,同时估计旋转、平移、重力方向等状态量。
这里我不展开公式,只给一条选型建议:如果摄像头和IMU在你的设备上同步得不错,优先选滑动窗口紧耦合方案,精度上限高;如果硬件同步做得不好,松耦合对更友好。不管选哪种,第一步都躲不过“IMU与相机坐标系外参标定”。
我见过好几个团队直接调现成工具去标定外参,结果跑出来方向偏差好几度,最后发现是标定板没贴稳。外参标定看起来是零头工作,实际上决定了整个融合系统的天花板。外参错一毫米,融合出来的姿态可能错一个数量级。
5. 实测复盘:从demo到可用的距离比想象中长
5.1 一次室外实测暴露的磁场干扰问题
有一次我在一个商圈广场做实测,传感器方案在实验室里明明是准的,到了室外站了没两分钟就开始跳。我按习惯先排除算法,把磁力计原始数据打到日志里,看到模长在一小片区域从45μT飙到80μT。这就实锤了不是算法bug,而是环境里存在铁磁性干扰源。钢筋、店铺招牌、地下车库结构、磁吸支架,都会让磁场局部变形。
解决方案有三条路:一是在算法里评估磁场模长和倾角的合理性,超范围直接降低磁力计权重;二是只用磁力计做缓慢校准,不让瞬时值主导输出;三是干脆切换方向来源,以陀螺仪递推为主、磁力计做长周期修偏。我最终采用第三种,实测效果最稳。
提示:检测磁场干扰的土办法很简单,静态放手机看方位角偏差。如果你发现固定位置数据稳定、换个方向绕一圈回来数值变了十几度,基本就是周边磁场被扭曲了,先找干扰源,再调算法。
5.2 相机和传感器坐标系“轴不对齐”的隐蔽坑
屏幕方向一改,相机图像坐标系可能跟着翻转,但传感器坐标系不会动。两者之间有一个固定的旋转关系,也就是外参。很多方向偏差根本不是融合算错了,而是旋转关系配错了。
常规做法是把摄像头主方向在设备坐标系里表示出来。比如后置摄像头光轴一般沿设备Z轴负方向,前置沿Z轴正方向,但不同厂商的硬件也可能不规整。开发时一定要在跑通竖屏、横屏、前置、后置各个条件之后,再固定这个变换矩阵,否则你看到的画面和算法输出的角度就是“各说各话”。
我早期做一个AR演示时,预览竖屏正常,切到横屏方向偏了90度,查了很久,最后发现是把图像方向写死在竖屏坐标系里。后来统一用“相机坐标系到世界坐标系”的整条变换链,任何UI旋转都不会再影响追踪结果。
5.3 时间戳、帧率和数据回灌:对齐比融合更重要
方向融合不是把两路数据粗暴混在一起。传感器事件有自己的时间戳,图像帧也有自己的时间戳,务必要用时间戳对齐,而不是依赖回调到达顺序。
有个常被忽略的细节:视觉追踪在低帧率环境里,两帧间隔可能达到100ms以上,而陀螺仪已经递推了好几轮。如果用一个过时的姿态去和过时的视觉结果融合,整个系统会表现出“卡一下再冲出去”的体验。我处理这个问题的方式,是给每个视觉观测附上采样时间,再在陀螺仪递推轨迹里找同一时刻的姿态来对齐。
数据回灌指的是把上一时刻的融合结果重新喂给下一帧,这是正常的递推逻辑。但回灌前一定要做边界检查:欧拉角会碰到万向锁,旋转矩阵会因浮点误差失去正交性,四元数可能因为反复归一化丢精度。这些问题不会立刻崩溃,但会在长跑场景里慢慢击穿你的系统。
5.4 三个评价指标:静态精度、动态漂移、恢复速度
上线之前,不要只盯着“准不准”。方向追踪系统,我建议用三个指标衡量:
- 静态精度。手机固定在桌上时,输出角度围绕真值的波动范围。室内桌面场景下,传感器方案一般能做到±2度以内。
- 动态漂移。手持设备快速转若干圈后回到初始位置,输出与初始姿态的偏差。数值越大,说明系统对误差的抑制能力越差。
- 恢复速度。故意遮挡镜头或引入强磁干扰后,融合结果恢复正常范围所需时间。理想情况是2秒以内。
拿这三个指标建立基线,之后每次调参都能知道到底有没有进步。我犯过只盯着静态精度反复调权重,结果把动态漂移搞大的错。方向追踪这种系统,单点优秀没有意义,体系均衡才是关键。
6. 最小实现骨架:一个能跑通的方向追踪Demo
6.1 工程结构把三个模块拆到职责清晰
为了让前面的原理能直接落地,我给你一套Android工程的最小骨架。整体拆成三个模块:
- 传感器采集模块:负责注册SensorEventListener,把加速度、磁场、陀螺仪数据带上时间戳写入环形缓冲区。
- 视觉处理模块:在子线程里处理相机预览帧,提取特征点、匹配、解算相对旋转,输出带时间戳的视觉观测。
- 融合输出模块:消费两个上游数据队列,按四元数预测加更新流程,输出最终方向角。
三个模块之间用队列通信,不要在做相机回调的线程里加锁,更不要把耗时计算放到UI线程。首次打开相机预览时,我先让设备平放两秒做初始化,让传感器模块估计出初始重力方向,再开始视觉追踪。
6.2 方向计算、平滑处理与视觉旋转的核心代码
传感器方向计算的核心代码可以简写如下:
float[] deviceRotation = new float[9]; float[] cameraTransform = new float[9]; float[] resultMatrix = new float[9]; float[] outAngles = new float[3]; // deviceRotation 来自 SensorManager.getRotationMatrix(...) // cameraTransform 是相机坐标系到设备坐标系的旋转关系,由硬件标定得到 multiplyMatrix(cameraTransform, deviceRotation, resultMatrix); SensorManager.getOrientation(resultMatrix, outAngles); float azimuth = (float) Math.toDegrees(outAngles[0]); float pitch = (float) Math.toDegrees(outAngles[1]); float roll = (float) Math.toDegrees(outAngles[2]);视觉部分的核心伪代码是这样的,重点在“视觉只输出相对旋转,不直接输出绝对朝向”:
# 视觉线程伪代码 kp_prev, des_prev = detect_and_compute(frame_prev) kp_curr, des_curr = detect_and_compute(frame_curr) matches = match_descriptors(des_prev, des_curr) H, mask = find_homography(kp_prev, kp_curr, matches) R_rel = decompose_rotation_from_homography(H) q_vis = quaternion_from_rotation(R_rel)实际工程里,还需要把每次算出来的相对旋转按时间链式累乘,才能得到当前相对初始帧的方向。注意每次累乘要乘以一个置信度衰减因子,否则一次误匹配就会被不断扩大,最终污染绝对姿态。
6.3 不用专业仪器也能验证效果的方法
做完demo怎么验证有没有用?我的土办法是找一块正方形地砖区域,把手机握在手里,让手机跟随地砖边缘缓慢平移、按固定角度旋转。每转到一个位置,就比对画面中心对应的地砖纹理方向,再记录系统输出的方位角变化。这个方法不需要外部设备,适合快速自测。
如果条件允许,我会再找两个固定地标,先用传感器方案在静止时读一组基准方向,再进入动态扫描,把动态输出与基准方向做差,看漂移曲线。等这套流程跑顺,你对系统误差的感知,会比单纯盯着画面要清楚得多。
最后留一个立刻能用的小技巧:做方向追踪应用时,别把输出数据直接喂给UI。先在后台维护一个带时间戳的方向队列,UI层只消费经过平滑处理的低频更新。这样画面上的指针会稳定很多,以后想接入更多传感器或换融合策略,也不用回头大改。