
简介VideoIMUCapture-Android是一款面向SLAM与SfM研究者的Android数据采集工具可同步捕获视频、IMU及相机元数据并对光学防抖OIS导致的逐帧相机参数漂移问题提供警告与OIS数据支持适合需要构建高质量3D重建数据集的开发者、科研人员学习使用。资源共121个文件以Java源码、XML资源、Python脚本和Gradle构建配置为主涵盖Camera2 API调用、protobuf数据格式定义、Dockerfile及Shell辅助脚本压缩包仅3.37MB工程结构紧凑便于按模块阅读或二次开发。目前已有618人学习下载。借助这套工程读者可快速掌握同步时钟下H.264视频与约100Hz IMU数据的采集框架理解OIS与DVS的差异并基于现成代码扩展自己的数据记录流程为后续SLAM算法测试或视觉惯性里程计实验提供可直接复用的基础实现。1. 录SLAM数据为什么必须关OISVideoIMUCapture-Android 解决的不是“能录视频”用 ORB-SLAM3 跑手机上录好的视频轨迹却直接飞掉这是我在测试视觉惯性里程计时最常见的翻车现场。后来才意识到问题多半不在算法而在采集端手机默认开了 OIS 和 DVS画面稳了内参却变了。VideoIMUCapture-Android 就是为这个场景准备的——一次抓齐视频、IMU 数据、相机参数并把设备是否支持、当前是否开启 OIS 一并告诉下游。它要解决的不是“能录像”而是“录出来的数据能被 SLAM 与 Structure from MotionSfM直接使用”。适合两类人跑 VIO/SLAM 算法做数据集验证的以及用 SfM 重建需要干净视频源的。2. OIS 与 DVS 的差别两种防抖如何毁掉 SLAM 与 SfM 的内参和时间戳要理解这个采集工具为什么把 OIS 和 DVS 单独拎出来讲得先把这两种防抖的本质说清楚。光学图像稳定OIS是物理层面的录制时镜头模组或感光元件会沿 XY 方向平移用来抵消手部抖动。数字视频稳定DVS是算法层面的相机先把整个传感器画面读出来再做边缘裁剪和帧间对齐最终输出一帧“看起来没抖”的图像。两者最直接的区别是作用对象不同但它们的副作用对 SLAM/SfM 是一样的——毁内参、乱时间戳。2.1 OIS 是镜头漂移DVS 是画面裁剪两者都让内参“时变”先看 OIS。当镜头模组为补偿抖动而平移时光线到达传感器的路径改变了等效焦距和主点坐标都在微变也就是内参矩阵不再是常量。SLAM 里三角化、PnP、光束法平差全都建立在“内参恒定”这个前提上一旦内参随每一帧波动特征点的几何关系就被系统性污染。更要命的是Android 公共 Camera2 API 通常只能读出 OIS 的开关模式拿不到镜头平移的位移量。这意味着你无法在后处理里补偿它只能关掉它。再看 DVS。数字稳定典型的实现是“先多读几行再裁剪平移窗口”。裁剪窗口每帧都可能不一样等效焦距就跟着变帧间对齐还会引入时间缓冲让视频帧的真实曝光时间被平移了几个帧周期。对 SfM 来说裁剪只是损失一点 FOV但特征点匹配本身还能做对基于惯性数据紧耦合的 VIO 来说视频帧的时间戳一旦和 IMU 时间戳不对齐视觉和惯性的约束关系就错位了轨迹发散是必然的。两者对 SLAM/SfM 的影响差异可以放在一起看对比项OIS 光学防抖DVS 数字稳定作用机制镜头/传感器物理平移画面裁剪与帧间对齐对内参影响等效焦距和主点微变裁剪窗口变化导致 FOV 变化对时间戳影响影响较小主要是曝光时刻改变引入帧缓冲时间戳明显偏移是否可补偿公共 API 一般拿不到位移量可通过关闭算法避免SLAM 采集合适性必须关闭必须关闭有一类设备支持“OIS 数据输出”这也是标题里特别强调的点。所谓“可以提供 OIS 数据”指的是系统能在 CaptureResult 里返回当前帧 OIS 是否生效、生效模式是哪种。这个信息对算法有实际价值即便你忘了关 OIS下游至少能通过这个字段把可疑的帧过滤掉。但请注意大多数手机不会把镜头位移的原始数值开放给应用层所以别指望拿它做精确补偿。2.2 用 Camera2 检测设备是否支持 OIS一份可控的 Kotlin 代码理解了这两种防抖的原理落地第一步就是在采集前检测设备能力。VideoIMUCapture-Android 这类工具一般会在启动时把相机能力读出来写入 sensor_info.json方便后续排查。检测 OIS 和 DVS 支持情况的代码不难用 Camera2 的两个固定字段即可fun checkStabilizationSupport(cameraId: String): PairBoolean, Boolean { val characteristics cameraManager .getCameraCharacteristics(cameraId) // OIS 支持列表LENS_INFO_AVAILABLE_OPTICAL_STABILIZATION val oisModes characteristics.get( CameraCharacteristics.LENS_INFO_AVAILABLE_OPTICAL_STABILIZATION ) ?: intArrayOf(CameraMetadata.LENS_OPTICAL_STABILIZATION_MODE_OFF) // DVS 支持列表CONTROL_AVAILABLE_VIDEO_STABILIZATION_MODES val dvsModes characteristics.get( CameraCharacteristics.CONTROL_AVAILABLE_VIDEO_STABILIZATION_MODES ) ?: intArrayOf(CameraMetadata.CONTROL_VIDEO_STABILIZATION_MODE_OFF) val oisSupported oisModes.any { it ! CameraMetadata.LENS_OPTICAL_STABILIZATION_MODE_OFF } val dvsSupported dvsModes.any { it ! CameraMetadata.CONTROL_VIDEO_STABILIZATION_MODE_OFF } return oisSupported to dvsSupported }这段代码先通过LENS_INFO_AVAILABLE_OPTICAL_STABILIZATION拿到设备支持的 OIS 模式数组。数组里出现OFF之外的模式比如ON、AUTO说明硬件支持光学防抖。同理CONTROL_AVAILABLE_VIDEO_STABILIZATION_MODES用于确认是否支持数字稳定。代码里?: intArrayOf(...)的兜底写法很关键部分低端设备或定制 ROM 不实现这两个字段返回 null按“不支持”处理而不是崩溃。要注意的是这个检测结果只代表“硬件/系统能力”不代表录制时 OIS 真的开启。是否生效要看每一帧 CaptureResult 里的LENS_OPTICAL_STABILIZATION_MODE实际值。我一般会在每一帧的回调里把这个值拉出来写入日志道理后文会讲OIS 不是设置了就一定生效个别设备的 HAL 层会偷偷改回 AUTO。本节这个函数只是“设备画像”真正起约束作用的是后面采集时的 CaptureRequest 设置。3. 从零搭建采集管线VideoIMUCapture-Android 的最小可用配置这一章解决“怎么跑起来”的问题。VideoIMUCapture-Android 通常是一个 Android Studio 工程Gradle 构建核心模块一般分成 camera相机预览与控制、sensorIMU 采样、recorder编码落盘、calib参数导出几块。多数情况下你不需要改核心逻辑只需要配置好权限、选对目标设备就能开始录第一段数据。但配置过程中有几个位置很容易踩坑下面按顺序展开。3.1 工程结构与权限AndroidManifest 里必须声明什么采集应用要碰的外设很直接相机、陀螺仪、加速度计有时还有麦克风MediaRecorder 在部分版本上即使不录音频也可能要求声明音频权限。先看一份最小可用的 AndroidManifest 配置uses-feature android:nameandroid.hardware.camera.any android:requiredfalse / uses-feature android:nameandroid.hardware.sensor.gyroscope android:requiredfalse / uses-permission android:nameandroid.permission.CAMERA / uses-permission android:nameandroid.permission.WRITE_EXTERNAL_STORAGE android:maxSdkVersion29 / uses-permission android:nameandroid.permission.RECORD_AUDIO /三点说明。第一相机和陀螺仪都声明为requiredfalse这样应用可以安装到没有陀螺仪的低端设备上运行时再做能力检测并降级处理。第二WRITE_EXTERNAL_STORAGE加maxSdkVersion29是给 Android 10 及以下版本用的Android 11 之后强制分区存储应用直接写自己的外部目录不需要这个权限。第三录音权限看着多余但 MediaRecorder 在视频源设置为 SURFACE 时部分 OEM 驱动仍会检查RECORD_AUDIO不声明会直接启动失败所以宁可保留。权限的动态申请建议在进入录制页面前一次性完成不要在录制中途弹窗。原因很简单弹窗会打断录制倒计时而且 Camera2 的打开流程和权限回调交织在一起代码会变得很难维护。录制过程中一旦出现权限被撤回部分国产 ROM 有“后台权限回收”MediaRecorder 会以异常结束数据文件全部作废。3.2 录制 H.264 视频与高频 IMU两套时钟域的统一方案采集管线的核心工作有两件用 Camera2 MediaRecorder 录 H.264 视频用 SensorManager 以最高频率收加速度计和陀螺仪数据。先看 MediaRecorder 的配置片段val recorder MediaRecorder() recorder.setVideoSource(MediaRecorder.VideoSource.SURFACE) recorder.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4) recorder.setVideoEncoder(MediaRecorder.VideoEncoder.H264) recorder.setVideoEncodingBitRate(20 * 1000 * 1000) recorder.setVideoFrameRate(30) recorder.setVideoSize(1280, 720) recorder.setOrientationHint(0)参数上我一般这样定编码选 H.264 MP4这是所有 SLAM 工具链和后处理脚本最容易解析的组合码率给 20 Mbps 对 720p 30fps 足够不要盲目拉到 50 Mbps会让中端手机写入卡顿、掉帧更严重分辨率选 1280x720 而不是 4K因为视觉里程计和 SfM 用的特征点分辨率并不需要多高720p 在室内纹理下已经能提取充足特征还省存储空间。真正需要花心思的是 IMU 的时钟域问题。SensorManager 的SensorEvent.timestamp单位是纳秒但它的时钟起点因设备而异有芯片直接给 boot time也有驱动换算过。直接把event.timestamp写进 CSV会遇到“同一秒内 IMU 时间戳和视频帧时间戳对不上”的怪问题。我一般不用它作为 CSV 主时间戳而是在回调到达时取SystemClock.elapsedRealtimeNanos()val listener object : SensorEventListener { override fun onSensorChanged(event: SensorEvent) { // event.timestamp 只用于同一传感器队列内的排序 val unifiedTimestampNs SystemClock.elapsedRealtimeNanos() imuWriter.writeLine( event.sensor.type, unifiedTimestampNs, event.values[0], event.values[1], event.values[2] ) } override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) {} } sensorManager.registerListener(listener, accelerometer, SensorManager.SENSOR_DELAY_FASTEST) sensorManager.registerListener(listener, gyroscope, SensorManager.SENSOR_DELAY_FASTEST)这段代码里的关键是“统一用elapsedRealtimeNanos()落盘”。elapsedRealtimeNanos()基于系统单调时钟包含休眠时间原生支持跨传感器比较。event.timestamp则保留用于调试——如果它和你写入的elapsedRealtimeNanos()之间存在固定偏移且漂移稳定说明驱动时钟没毛病如果偏移乱跳那是设备驱动的问题换机比换代码快。IMU 采样率上SENSOR_DELAY_FASTEST是能申请到的最高频率实际收到的频率通常在 200 Hz 到 500 Hz 之间取决于设备型号。对 VIO 算法来说 200 Hz 的陀螺仪已经够用不需要更高。还要提醒一句注册监听时不要漏了onAccuracyChanged回调里至少要记录精度等级精度突然变为 0不可信时对应的 IMU 片段最好在数据集说明里标注出来。3.3 落盘文件组织video、imu.csv、camera_params 各放哪采集结束不能只有一个视频文件。SLAM 算法需要的是视频、IMU、相机参数、设备信息多路对齐的数据包。我习惯按会话建目录每次录制生成一个时间戳命名的文件夹session_20250101_153000/ video.mp4 imu.csv frames.csv camera_params.json sensor_info.json每个文件的职责要明确。video.mp4是 H.264 编码的视频流imu.csv保存加速度计和陀螺仪的逐条数据frames.csv是每一帧视频的捕获时间戳camera_params.json放相机内参和畸变参数sensor_info.json记录设备能力检测结果和录制时实际生效的 OIS/DVS 状态。这套组织结构的好处是后续跑标定或数据集转换时所有信息都在一个目录里不用翻聊天记录找参数。camera_params.json的内容通常长这样{ camera_id: 0, resolution: [1280, 720], fps: 30, camera_matrix: [[fx, 0, cx], [0, fy, cy], [0, 0, 1]], dist_coeffs: [k1, k2, p1, p2, k3], ois_mode: OFF, dvs_mode: OFF }注意ois_mode和dvs_mode记录的是录制过程中实际生效的模式不是设备支持列表。这两个字段是后处理程序判断数据可信度的依据如果发现某一段视频的ois_mode变成了ON那这段的几何约束必须打上标记。这里也回应了标题里的那句“如果设备支持则可以提供 OIS 数据”——设备支持 OIS 时你至少能把这种“每帧是否被防抖污染”的状态信息给到下游。4. 从视频到 SLAM 数据集帧时间戳与相机标定的补齐方法录完原始视频到真正能喂给 VINS-Mono 或 ORB-SLAM3中间还差两步补逐帧时间戳、标定相机内参。这一步也是视频采集工具和普通录像 App 拉开差距的地方。MediaRecorder 写出来的 MP4 文件本身不含你想要的“每一帧的真实曝光时间”相机标定也不能只靠 JSON 里那几个占位符得实际算出来。4.1 MediaRecorder 不给你逐帧时间戳从 CaptureResult 里捞MediaRecorder 封装得太彻底应用层拿不到“这一帧是什么时候曝光的”这种信息。常见做法是在录制的同时通过 Camera2 的CaptureCallback把每一帧的时间戳捞出来单独写一个frames.csv。代码核心是重写onCaptureCompletedprivate val captureCallback object : CameraCaptureSession.CaptureCallback() { override fun onCaptureCompleted( session: CameraCaptureSession, request: CaptureRequest, result: TotalCaptureResult ) { val sensorTimestampNs result.get( CaptureResult.SENSOR_TIMESTAMP ) ?: return // 同时记录单调时钟用于把 sensor 时间戳统一到同一时基 val wallClockNs SystemClock.elapsedRealtimeNanos() framesWriter.writeLine($sensorTimestampNs,$wallClockNs) } }这里有两个时间戳它们不是一回事。CaptureResult.SENSOR_TIMESTAMP是传感器曝光开始时刻来自相机 HAL 的时间基准SystemClock.elapsedRealtimeNanos()是应用层收到回调的时刻。后者的作用是提供一条“换算桥”离线处理时用两种时间戳的对应关系拟合出偏移量再统一到单调时钟。不要指望SENSOR_TIMESTAMP直接能用它和 IMU 数据的时基在不同设备上有不同表现。落盘之后校验帧率是否稳定再谈对齐。frames.csv行数应该接近“录制秒数 × 帧率”时间戳差值的中位数应该等于帧间隔。如果发现大量时间戳差值超过两倍帧间隔说明录制过程中存在掉帧最好重录而不是指望算法去修复。掉帧对视觉 SLAM 是严重的输入质量问题轻则轨迹抖动重则尺度漂移。4.2 棋盘格标定脚本用 OpenCV 解出内参与畸变时间戳对齐只是第一步还得知道相机内参。VideoIMUCapture-Android 输出的camera_params.json里的内参矩阵和畸变系数一般是录制一段棋盘格视频后离线算出来的。先抽帧mkdir -p frames ffmpeg -i video.mp4 -vf fps8 frames/%04d.jpg抽帧频率建议在 5-8 fps太密了相邻帧的棋盘格角度几乎一样对求解没有增量信息。完成后用下面的 OpenCV 脚本做标定import cv2 import json import glob chessboard (9, 6) criteria (cv2.TERM_CRITERIA_EPS cv2.TERM_CRITERIA_MAX_ITER, 30, 0.001) pattern_pts np.zeros((np.prod(chessboard), 3), np.float32) pattern_pts[:, :2] np.indices(chessboard).T.reshape(-1, 2) obj_pts, img_pts [], [] frames sorted(glob.glob(frames/*.jpg)) for fname in frames: img cv2.imread(fname) gray cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) found, corners cv2.findChessboardCorners( gray, chessboard, None ) if found: obj_pts.append(pattern_pts) corners cv2.cornerSubPix( gray, corners, (11, 11), (-1, -1), criteria ) img_pts.append(corners) ret, K, dist, rvecs, tvecs cv2.calibrateCamera( obj_pts, img_pts, gray.shape[::-1], None, None ) print(json.dumps({ camera_matrix: K.tolist(), dist_coeffs: dist.tolist() }, indent2))脚本做的事情是遍历所有抽出的帧用findChessboardCorners找棋盘格内角点找到就用cornerSubPix做亚像素精化最后喂给calibrateCamera解出内参矩阵 K 和畸变系数。这里我没写全import numpy as np你跑脚本时补上即可。几个参数需要按你的棋盘调chessboard (9, 6)是内角点数不是棋盘的格子总数买棋盘时商家标的是格数内角点要各减 1。如果一帧都没检测到先检查棋盘是不是反光材质或者抽帧分辨率够不够。成功检测的帧数至少要 20 张并且要覆盖画面的中心、边缘和俯仰角度边缘区域的角点对畸变系数收敛尤其重要。4.3 IMU 与相机的对齐检查录制自检三要素相机内参有了最后还要确认 IMU 和视频之间是否真的能对上。SLAM 后端一般有一个 imu-camera 外参标定过程但外参标定之前你得先保证两路数据的对齐是可解的。我自己的习惯是录制时安排三个固定动作回放时快速判断数据质量而不是录完就急着跑算法。这三个动作分别是静止 3 秒、绕竖直轴缓慢转一圈、左右快速摆头。用一个小脚本检查import pandas as pd imu pd.read_csv(imu.csv) frames pd.read_csv(frames.csv, headerNone, names[sensor_ts, wall_ts]) static imu.iloc[:1000] # 开始静止段 print(gyro mean:, static[[gyro_x, gyro_y, gyro_z]].mean()) frame_dt_ns frames[sensor_ts].diff().median() print(median frame interval ms:, frame_dt_ns / 1_000_000) print(imu first ts:, imu[ts_ns].iloc[0]) print(frame first ts:, frames[sensor_ts].iloc[0])第一个输出检查陀螺仪零偏静止时陀螺均值应该在 0.02 rad/s 以内超出这个量级说明设备自身零偏大或静止段混入了手抖。第二个输出检查帧间隔720p 30fps 的视频帧间隔中位数应该是 33.33 ms波动超过 20% 就要怀疑掉帧。第三个输出只是粗略看两路起始时间差差得太多会影响标定收敛。这套自检十分钟内能做完能筛掉大半录制返工的情况。5. 数据不对齐先排查这 5 个采集坑采集工具用起来不难但数据问题几乎都出在不起眼的地方。下面五条是我在不同设备上踩过的坑每条都按“现象、原因、解决”整理你录制前对照着检查一遍能省下好几轮无效实验。5.1 画面越稳轨迹越飞OIS 未关闭现象录出来的视频在手机上看非常稳定但放进 ORB-SLAM3 跑轨迹在 10 秒内就偏到画面外。原因手机相机默认开启了 OIS镜头在每一帧都在做微小平移等价于内参矩阵不断变化而 SLAM 后端完全不知道这种变化只能把误差算进位姿。解决创建CaptureRequest时显式设置LENS_OPTICAL_STABILIZATION_MODE_OFF同时把CONTROL_VIDEO_STABILIZATION_MODE_OFF也设上。不要只关一个个别厂商的驱动会“好心”地把另一个模式也改掉。录制开始前再从CaptureResult里读一次两个模式值确认不是设置后未生效。5.2 IMU 条数与帧数对不上时钟域混用现象IMU 数据看起来是连续采的视频也正常录完但拿时间戳比较时发现两端的时间基准是错开的整条数据对不齐。原因IMU 的event.timestamp与 Camera2 的SENSOR_TIMESTAMP不一定同一时基直接把两个值做差会得到无意义的结果。解决所有落盘时间戳统一用SystemClock.elapsedRealtimeNanos()传感器原始时间戳另存备用。如果已经录错了可以在离线阶段用两路时间戳做线性拟合求出偏移但遇到驱动时钟跳变的设备就得重录。5.3 画面偏色或过曝自动白平衡和自动曝光在后台改参数现象录制前期画面正常中段开始偏绿或过曝而且色彩突变的时间点与手机转动的时刻重合。原因没有锁 AWB 和 AE相机的自动算法根据场景动态调整了增益和白平衡导致相邻帧之间的色彩和亮度响应不一致。解决在CaptureRequest.Builder里把CONTROL_AE_MODE设为CONTROL_AE_MODE_OFFCONTROL_AWB_MODE设为CONTROL_AWB_MODE_OFF然后手动固定SENSOR_EXPOSURE_TIME和SENSOR_SENSITIVITY。注意锁参数要在采集前做不要在录制中途改否则会出现一帧跳变。5.4 陀螺仪数据全为 0传感器监听被系统静默回收现象录制结束时看imu.csv前几分钟有数据后面全是一串 0 或没有新行。原因部分手机上传感器监听器没有持有唤醒锁系统在唤醒与休眠切换的边界直接把监听挂起或者进程被低内存回收Listener 成了空壳。解决录制期间拿PARTIAL_WAKE_LOCK保持 CPU 唤醒并且在 Service 而不是 Activity 里持有传感器监听。录制停止时再释放锁不要用应用进入后台就被清理的组件持有 IMU 数据链路。5.5 MediaRecorder 掉帧码率设太高与 IO 阻塞现象视频文件的帧时间戳间隔分布不均匀中间出现明显断层但界面预览看不出问题。原因MediaRecorder 的编码器跟不上写入速度或者手机存储写入遇到瓶颈内部缓冲区溢出后直接丢帧。解决先把码率降到 12-20 Mbps分辨率控制在 720p。另外确保输出目录在设备内置存储而不是慢速 SD 卡录制过程中不要同时做大量文件拷贝。如果怀疑编码器问题换一台芯片更新的设备对比测试同一场景能快速定位卡点。6. 进阶一次三分钟自检让 SLAM 轨迹不再飞录制的最后一环是把这套采集链路变成你实验流程里的标准动作。我现在每次正式采集前都会做一个三分钟的自检流程跑完再去碰算法事半功倍。自检流程分三段录制第 1 段手机平放在桌面上静止 3 秒第 2 段手持手机对着纹理丰富的墙面缓慢平移加转动第 3 段快速左右甩动手机 10 次。录完立刻检查三组指标检查段检查指标正常范围静止段陀螺仪均值各轴小于 0.02 rad/s平移段帧时间戳中位数30fps 对应 33.3 ms波动在 10% 内甩动段帧时间戳连续性无超过两倍帧间隔的断层这三组指标分别对应 IMU 零偏、录制帧率稳定性、以及快速运动下编码器是否丢帧。零偏大说明设备本身质量不行数据录出来也会带着系统性误差帧间隔不稳说明编码器或存储跟不上甩动段断层说明在高速场景下采集链路存在瓶颈。任何一项不过关都不建议直接进入算法实验。除了数据自检还有一个技巧很实用在正式场景录制前先对着一个打印的棋盘格录 20 秒。这段视频不参与 SLAM 实验只用来反复确认内参标定的稳定性。如果两次标定的焦距偏差超过 0.5%说明相机设备状态有变化比如镜头模块松动或温漂这时候要排查设备而不是改算法参数。我在此前项目里就吃过标定漂移的亏后来把这一步固定成采集前置动作再没出现过“算法调了三天最后发现是标定不对”的情况。这条链路走顺之后VideoIMUCapture-Android 的意义才会真正体现出来它不只是一个录像工具而是一套把视频、IMU、相机参数和 OIS 状态对齐的数据采集方案。愿你少踩几个采集坑录出来的数据一次就能跑通。希望帮到你。本文还有配套的精品资源点击获取