简介:面向同时定位与建图(SLAM)和运动恢复结构(SfM)研究的Android端数据采集工具VideoIMUCapture,为研究人员提供视频、惯性测量单元(IMU)与相机数据同步捕获的完整方案。应用以约30Hz录制相机帧、约100Hz同步IMU数据,并将帧元数据与IMU数据统一写入protobuf3文件,视频则编码为H.264/MP4,保证后续重建的时序一致性。针对手机光学防抖导致每帧相机参数不一致的核心难题,应用会明确警告光学图像稳定(OIS)启用状态,便于研究人员筛选可信数据。资源共121个文件,以29个Java源码为主体,实现Camera2取流、传感器监听与同步逻辑;辅以26个XML配置与布局、26个PNG界面截图,便于理解界面与工程结构;另有9个Python辅助脚本以及Gradle、Dockerfile和Git相关配置,整体压缩包仅3.37MB,轻量易用。已有618人学习下载。包内包含Camera2接口调用、IMU传感器读取、时间戳同步及OIS状态检测的完整实现,适合Android开发者与视觉SLAM研究人员参考工程结构和采样流程。
1. 为什么 SLAM 研究需要一台“会做记录的 Android 手机”
做视觉惯性 SLAM 或 Structure from Motion 的人,多数都经历过同一个痛点:拿着手机去录一段数据,回来一跑算法,发现轨迹飞了。排查到最后,要么是 IMU 时间戳和图像对不上,要么是手机自带的数字视频稳定(DVS)悄悄把图像裁切又补偿了一遍,内参全变。这个时候你需要的是一个纯粹的采集工具,不美化画面、不做稳像、不猜帧率,只做一件事:把相机曝光时刻、IMU 采样时刻、OIS 镜头位移数据全部带着统一时钟基准落盘。VideoIMUCapture-Android 这个方向解决的就是这类问题,它是一类 Android 采集应用,面向 Motion 研究中的 SLAM 和 Structure 重建场景,重点区分光学图像稳定(OIS)与数字视频稳定(DVS),并在设备支持的前提下把 OIS 数据一并采集出来。适合的人群很明确:做视觉惯性里程计的研究者、搞三维重建的算法工程师,以及需要可控数据集的移动端 SLAM 开发者。
2. OIS 和 DVS 的差别为什么直接影响重建精度
2.1 两种稳像的物理层面差异
光学图像稳定(OIS)是硬件层面的补偿。镜头组或者感光元件会跟随陀螺仪信号做反向位移,目的是让光线在曝光时间内尽量稳定地落在传感器上。这个机制对拍照很有用,但对 SLAM 来说是个“干扰源”:图像确实更清晰了,但像素对应的投影中心被镜头位移改变了。如果算法用的是标定好的固定内参,这些被 OIS 修正过的帧会引入系统性误差。
数字视频稳定(DVS)走的是另一条路。它不做硬件补偿,而是在 ISP 或后处理阶段对整体画面做裁切和重投影,把抖动“剪掉”。DVS 开启后,输出的视频帧已经不是传感器原始视野,而且这个裁剪区域是时变的——每一帧的仿射变换矩阵都可能不同。对 Structure from Motion 来说,这等于每一帧的内参都不一样,特征点三角化的几何一致性会被直接破坏。
2.2 录制时如何处理这两类数据
常见做法是录制时强制关闭 DVS,尽量关闭或绕过 OIS,同时单独读取 OIS 传感器数据。在 Android 上,OIS 数据的可访问性取决于厂商实现。部分设备的 IMU 传感器列表里会出现TYPE_DEVICE_ORIENTATION、TYPE_GYROSCOPE之外的特殊传感器,或者通过CaptureRequest控制 OIS 模式。有的设备可以在CaptureResult里拿到镜头位移对应的厂商扩展字段。录制时应当枚举传感器列表,确认哪些传感器是稳定的,哪些是虚拟叠加的。
我在做这类采集工具时,会先做一步“传感器摸底”:在设备上打印所有可用传感器,区分硬件传感器与虚拟传感器。这一步能避免很多后期数据对齐的麻烦。虚拟传感器(比如游戏旋转向量)通常已经做过融合和滤波,不适合用于 OIS 补偿建模;硬件陀螺仪和加速度计的原值才是我们需要的。
2.3 OIS 数据能做什么用
OIS 数据在重建流程里的价值有两层。第一层是诊断:如果某段录制中 OIS 位移幅度比较大,说明手持抖动剧烈,这段数据的视觉特征质量可能不稳定。第二层是补偿:在标定中把 OIS 镜头位移建模为内参微调,可以把投影误差进一步压低。部分研究组把这套数据用在实拍数据集里做真值对比,方法是把 OIS 位移和陀螺仪积分做一致性校验,筛掉异常片段。
val sensorManager = getSystemService(SENSOR_SERVICE) as SensorManager val sensorList = sensorManager.getSensorList(Sensor.TYPE_ALL) for (sensor in sensorList) { val isHardware = sensor.isWakeUpSensor || sensor.fifoReservedEventCount > 0 Log.d("SensorProbe", "name=${sensor.name} type=${sensor.type} vendor=${sensor.vendor} hardware=$isHardware") }这段代码枚举了设备上全部传感器,并粗略区分硬件传感器与虚拟传感器。fifoReservedEventCount大于 0 通常意味着该传感器有硬件 FIFO 支持,数据更接近原始采样。isWakeUpSensor表示传感器在系统休眠时仍能唤醒处理,这类传感器适合做数据采集。打印 vendor 字段可以帮助确认 OIS 相关传感器是否由镜头模组厂商提供——这一点在后续读取镜头位移数据时很关键。
3. 在 Android 上实现视频、IMU、OIS 三路同步采集
3.1 相机侧:用 Camera2 拿到“帧级时间戳”
视频采集必须用 Camera2 API,而不是旧的 Camera1。Camera1 的预览回调时间戳不可靠,帧率控制也比较粗。Camera2 的CaptureResult里带有SENSOR_TIMESTAMP,这个时间戳基于CLOCK_MONOTONIC,可以直接和SystemClock.elapsedRealtimeNanos()对齐。这是整条同步链路的地基。
录制时把CaptureRequest.CONTROL_VIDEO_STABILIZATION_MODE设为OFF,同时把LENS_OPTICAL_STABILIZATION_MODE设为OFF。这样至少能拿到未被软件裁切、未被硬件补偿的原始帧。但注意,部分设备上关闭 OIS 后,CaptureResult里就不再输出镜头位移相关字段了。如果你想同时采集原始帧和 OIS 位移数据,可能需要让 OIS 保持开启,但记录每一帧的位移量。
val session = CameraCaptureSession( listOf(surface, imageReader.surface), object : CameraCaptureSession.StateCallback() { override fun onConfigured(session: CameraCaptureSession) { val requestBuilder = session.device.createCaptureRequest(TEMPLATE_RECORD) requestBuilder.set(CaptureRequest.CONTROL_VIDEO_STABILIZATION_MODE, CaptureRequest.CONTROL_VIDEO_STABILIZATION_MODE_OFF) requestBuilder.set(CaptureRequest.LENS_OPTICAL_STABILIZATION_MODE, CaptureRequest.LENS_OPTICAL_STABILIZATION_MODE_OFF) requestBuilder.set(CaptureRequest.CONTROL_AE_TARGET_FPS_RANGE, Range(30, 30)) session.setRepeatingRequest(requestBuilder.build(), callback, null) } }, backgroundHandler)这里选择了TEMPLATE_RECORD,它针对视频录制做了自动曝光和自动白平衡的调优。CONTROL_VIDEO_STABILIZATION_MODE和LENS_OPTICAL_STABILIZATION_MODE同时关闭,是为了保证输出的是传感器原始视野,避免 DVS 裁切与 OIS 补偿带来的像素位移。固定帧率到 30fps 是为了让 IMU 插值和对齐有一个稳定的时间网格。需要更高时间分辨率时,可以把帧率提升到 60fps,但要注意曝光时间缩短带来的噪声和暗光下的运动模糊。
3.2 传感器侧:IMU 原始数据落盘
IMU 原始数据的采集比相机简单,但坑更多。SensorManager注册监听器时,SENSOR_DELAY_FASTEST可以拿到尽可能高的采样率,但实际采样率由设备决定。你需要读Sensor.getMinDelay()来确认上限。另一个容易忽略的点:SensorEvent.timestamp是设备启动后的单调时钟,单位是纳秒,但它不一定和elapsedRealtimeNanos有相同的零点——好在两者都是单调递增的,我们只需要计算偏移量。
val gyro = sensorManager.getDefaultSensor(Sensor.TYPE_GYROSCOPE) sensorManager.registerListener(object : SensorEventListener { override fun onSensorChanged(event: SensorEvent) { val elapsedRealtime = SystemClock.elapsedRealtimeNanos() val sensorTime = event.timestamp val offset = elapsedRealtime - sensorTime // 写入文件:time_ns, offset_ns, wx, wy, wz Log.d("IMU", "$sensorTime $offset ${event.values[0]} ${event.values[1]} ${event.values[2]}") } override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) {} }, gyro, SENSOR_DELAY_FASTEST)这里同时记录了event.timestamp和elapsedRealtimeNanos,两者的差值offset是传感器时间域到系统单调时钟域的桥。很多采集工具只记录event.timestamp,离线处理时一旦重启时钟基准就乱了。把 offset 直接落盘,后续对齐图像时就不需要再猜。注意:SENSOR_DELAY_FASTEST是建议值,不是保证值,有些设备在 200Hz 的陀螺仪上实际只能到 180Hz,所以录制完成后的数据校验环节必须检查真实采样间隔分布。
3.3 编码与封装:MediaCodec + MediaMuxer
画面编码走 MediaCodec,封装走 MediaMuxer。MediaCodec 需要配一个输入 surface 作为相机输出的目标,这样可以避免频繁的 YUV 拷贝。编码器配置 H.264 或 HEVC,码率根据分辨率和场景复杂度调整。对于 SLAM 数据采集,过高的码率会带来发热降频,码率过低则运动场景下块效应严重,特征提取稳定性变差。1080p 30fps 下,我一般把码率定在 8~12 Mbps,基本平衡。
val format = MediaFormat.createVideoFormat(MediaFormat.MIMETYPE_VIDEO_AVC, width, height) format.setInteger(MediaFormat.KEY_COLOR_FORMAT, MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface) format.setInteger(MediaFormat.KEY_BIT_RATE, 10_000_000) format.setInteger(MediaFormat.KEY_FRAME_RATE, 30) format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 1) format.setInteger(MediaFormat.KEY_BITRATE_MODE, MediaCodecInfo.EncoderCapabilities.BITRATE_MODE_VBR) encoder.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)KEY_I_FRAME_INTERVAL设置为 1 秒,即每 30 帧一个关键帧。过长的 I 帧间隔会导致跳帧后画面恢复慢,损坏的参考帧会影响后续所有 P 帧。BITRATE_MODE_VBR适合运动强度不均的场景,静止时省码率,剧烈运动时自动提高码率。设置固定码率 CBR 的好处是文件体积可预测,但在快速旋转时画质损失明显。对 SLAM 来说,画质均匀性比文件体积重要,所以 VBR 是更稳妥的选择。
4. 时戳同步的统一语义与数据文件组织
4.1 三种时钟域的统一对齐
整个采集过程涉及三个时间域:图像感应的SENSOR_TIMESTAMP、IMU 的event.timestamp、以及录音或外部触发信号的AUDIO_TIMESTAMP。这三者的共同基准是CLOCK_MONOTONIC,但它们各自的零点不同。统一对齐的办法是:在录制开始时记下一个epochOffset = SystemClock.elapsedRealtimeNanos() - SystemClock.currentThreadTimeMillis()之类的锚点,同时记录每种数据源的第一个时间戳,然后离线处理时把各自的时间戳减去各自的初始值,再做线性对齐。
常见做法是录制时单独维护一个元数据文件,记录录制开始时刻SystemClock.elapsedRealtimeNanos()、相机 SENSOR_TIMESTAMP 最小值、IMU 首帧 timestamp、以及 OIS 传感器首帧 timestamp。离线处理时把每个数据都投影到elapsedRealtimeNanos时间轴,再做最近邻配对或线性插值。很多现成的数据集工具包要求的就是这种带锚点的数据组织方式,因此在设计录制格式时就应该按这个标准来,不要等数据出了问题再回补。
4.2 文件目录设计与命名规范
一个录制会话应该生成一个独立目录,目录内包含视频文件、IMU 数据文件、OIS 数据文件和一个元数据 JSON。文件命名里带上会话编号和日期时间,避免多次录制后找不到哪段对应哪个场景。IMU 数据文件用 CSV 格式,每行包含时间戳、时间偏移、三轴角速度、三轴加速度。OIS 数据文件单独存放,因为它的采样率通常低于 IMU,不需要混合进 IMU 流里。
recordings/ 20240514_session_01/ video.mp4 imu_raw.csv ois_raw.csv metadata.json camera_calibration.jsonmetadata.json里存放设备型号、Camera2 特性、传感器列表、各数据源的首帧时间和时间偏移。这些信息在你换一台手机重新标定时非常有用。camera_calibration.json是事后离线标定的结果,采集工具本身不做标定,但目录里留好位置,方便后处理流程统一读取。这里我建议把标定文件也按会话归档,因为同一台设备在不同温度下,内参漂移幅度有时会超过误差容忍度。
4.3 元数据里记录什么
{ "device": "Pixel_series", "session_start_elapsedRealtimeNanos": 123456789, "video": { "fps": 30, "width": 1920, "height": 1080, "sensor_timestamp_min": 98765, "sensor_timestamp_max": 123456789 }, "imu": { "sample_count": 6000, "first_timestamp": 100000, "last_timestamp": 123400000, "sampling_rate_hz": 200 }, "ois": { "available": true, "sample_count": 3000, "first_timestamp": 101000, "last_timestamp": 123300000 } }这份 JSON 的核心作用是把三种数据的相对起始时间都明确下来。video.sensor_timestamp_min是第一个图像帧的曝光开始时刻,imu.first_timestamp是第一条 IMU 采样时刻。两者差值就是视觉和惯性之间需要补偿的固定偏移。这个偏移在每次录制中都不同,因为数据流开始时间不同,而算法需要的是一个固定的“传感器延迟”,指从曝光到 IMU 采样之间的物理延迟,这部分通常由硬件决定,但另一种固定偏差,是两类数据流启动时刻的初始化差异,应该在这里被记录并扣除。
5. 避坑指南:VideoIMUCapture-Android 的 5 个高频翻车点
5.1 关闭了 DVS 之后,预览和录制画面变“抖”
现象:关闭 DVS 后,录制的视频在手机屏幕上看明显比系统相机抖,甚至怀疑采集工具有 bug。 原因:系统相机默认叠加了 DVS,视觉上“稳”是后处理的结果;关闭后传感器原始帧直接输出,抖动自然暴露。这恰恰说明采集的是未经裁切的原始数据。 解决:录制时通过三脚架或稳定器固定设备,或接受抖动并在后期用算法做运动补偿。不要重新开启 DVS——一旦开启,图像的时变裁切矩阵就无法复原。
5.2 IMU 时间戳出现周期性的间隔跳动
现象:CSV 文件里 IMU 时间戳相邻差值是 5ms 和 6ms 交替,甚至偶尔出现 15ms 的跳跃。 原因:SENSOR_DELAY_FASTEST是建议值;系统可能为了省电把采样率降到某个节拍,或者传感器 FIFO 批量上报导致时间戳不均匀。 解决:在 onSensorChanged 里做均匀性统计,录制时实时计算最近 100 个时间戳的标准差,超过阈值就在界面上提示“IMU 采样不稳定,请重启录制”。更进一步,用硬件 FIFO 模式(如果设备支持)配合flush()可以改善批量上报的问题。
5.3 OIS 传感器在部分设备上完全读不到
现象:传感器列表里没有明显与镜头位移相关的传感器,CaptureResult里也没有厂商扩展字段。 原因:OIS 数据是厂商私有实现,Android 没有统一的 API 来读取镜头位移。中低端设备通常直接不暴露这部分数据。 解决:在枚举传感器时把 vendor 名称和 sensor name 完整记录到 metadata。对于不支持的设备,降级方案是采集 IMU 陀螺仪数据,用陀螺仪积分估算设备姿态,间接推测 OIS 的工作强度。这个方案精度有限,但至少能给数据质量一个参考。
5.4 长时间录制约 15 分钟后视频与 IMU 的对齐误差变大
现象:前 5 分钟数据对齐效果良好,15 分钟后误差增长到几十毫秒级别。 原因:设备发热导致传感器时钟频率发生微小漂移。CLOCK_MONOTONIC受 NTP 影响小,但受温度影响的晶振漂移依然存在。 解决:把录制分段,每段控制在 10 分钟以内,段与段之间留 30 秒冷却时间。同时,每段录制单独记录时钟锚点,不要共用同一份 offset。离线对齐时可以尝试用 IMU 信号与视频帧的互相关做二次校准。
5.5 MediaMuxer 偶发崩溃,导致整个录制文件无法打开
现象:录制过程中应用闪退,生成的 .mp4 文件在播放器里无法打开。 原因:MediaMuxer 在stop()之前如果被异常终止,文件没有写入 moov atom,播放器无法索引。 解决:录制开始前先把元数据文件落盘,然后每写一个关键帧,把当前写入进度记录到内存。崩溃后恢复时,可以根据进度决定是丢弃整个文件还是尝试用 ffmpeg 修复。更稳妥的做法是把视频和 IMU 分开录制。视频损坏不影响 IMU 数据的可用性,离线处理时用部分视觉帧配合 IMU 也能做一次低精度重建。
6. 用数据校验脚本把采集质量变成可见指标
录制工具完成之后,真正决定数据集是否可用的是校验环节。我在采集完成后的第一件事不是跑 SLAM,而是跑一套数据校验脚本。脚本输出三个指标:IMU 采样均匀度、视觉帧时间戳单调性和 OIS 数据覆盖率。IMU 采样均匀度用相邻时间戳差值的变异系数来衡量,变异系数低于 0.05 视为合格;视觉帧时间戳应当严格递增,如果出现倒挂说明摄像头出帧乱序;OIS 数据覆盖率按传感器可用时间与录制总时长的比值计算,低于 90% 时,该设备不适合做需要 OIS 建模的研究。
一个好的额外加分项是录制时叠加一个 LED 闪光信号,把它录进视频画面,同时记录驱动 LED 的时刻。这样校验时可以通过播放器逐帧查看闪光出现的帧号,与驱动时刻对比,得到一个绝对时间戳误差,作为跨设备采集的基准参考。这个做法成本极低,但能挽回一批“看起来没问题”实则时间轴错位的数据。
Device: Pixel_series IMU uniformity (std/mean): 0.031 PASS Video timestamp monotonic: true PASS OIS coverage: 96.2% PASS Estimated sync error vs LED flash: 3.2 ms PASS我自己不止一次遇到过这种情况:录制时一切正常,回放数据也流畅,但跑了半个月的算法才发现初始时间偏移算错了。从那以后,我把校验脚本加到了采集工具的主界面上,录制一结束就自动弹结果。一个采集工具如果只负责录数据,不负责验证数据,那么它产出的可能只是一堆看似“能跑”但实际上对不齐的废数据。希望这套经验能帮你在做 Android 端采集工具时少走几步弯路,祝顺利。
本文还有配套的精品资源,点击获取