简介:VideoIMUCapture-Android 是一款面向 SLAM 与 Structure from Motion(SfM)研究者的 Android 数据采集工具,用于同步录制相机帧与 IMU 数据,为三维重建提供原始素材。应用以约 30Hz 采集视频帧、约 100Hz 采集惯性测量数据,并统一到同一时钟;视频以 H.264/MP4 存储,帧元数据与 IMU 数据则写入 protobuf3 文件。针对现代手机普遍存在的光学防抖(OIS)问题,该工具会在录制时给出明确警告,并在设备支持时提供 OIS 数据,便于研究者区分 OIS 与数字视频稳定(DVS)对相机参数的影响。资源包共 121 个文件,约 3.37MB,以 Java 源码、XML 布局、PNG 图标、Python 脚本及 Gradle 构建文件为主,另含 proto 定义与 Docker 相关配置,结构完整、便于二次开发。目前已有 617 人学习下载,适合具备 Android 与计算机视觉基础的研究人员参考使用。
1. 从一台手机到一份SLAM数据集:这个Android采集项目到底在解决什么
做过视觉SLAM或三维重建的开发者大概都经历过这样的场景:算法在仿真环境里跑得挺好,一换到真实手机采集的数据就各种漂移、尺度对不上、时间戳错乱。问题往往不在算法本身,而在数据源——手机相机的自动曝光在变、OIS光学防抖在偷偷补偿位移、IMU和图像帧的时间戳根本不在一个时钟域里。这个项目就是冲着这些痛点去的:它把Android手机变成一个可控的、可复现的传感器数据采集终端,同时抓取视频帧、IMU原始数据和相机内参,并且明确区分OIS和DVS两种防抖状态下的数据差异。适合谁用?做视觉惯性里程计、SfM重建、SLAM算法验证的研究者和工程师,尤其是需要批量采集真实场景数据来喂给算法的那批人。技术栈上走的是Camera2 API + SensorManager + Java原生路线,不依赖第三方视觉库,采集端足够轻。
2. 架构拆解:Camera2、IMU与存储三条线怎么并行跑
2.1 整体模块划分与数据流
这个项目的架构并不复杂,但胜在职责清晰。核心分成三块:相机采集模块、IMU采集模块、数据落盘模块。相机模块基于Camera2 API,负责打开摄像头、配置输出Surface、接收每一帧的Image数据;IMU模块通过SensorManager注册加速度计和陀螺仪监听器,拿到的是原始三轴数据;落盘模块则把图像帧写成文件、把IMU数据按时间戳追加到文本或二进制文件里。
数据流是这样的:Camera2的ImageReader回调触发后,先取出该帧的传感器时间戳(Image.getTimestamp()),同时从IMU缓冲区里取出与该时间戳最接近的若干条IMU记录,一起打包写入。这里有个关键设计——图像帧和IMU数据不是各写各的,而是在写入时做时间对齐,保证后续算法拿到的是已经粗对齐的数据。
常见做法是给每条记录分配一个单调递增的序号,图像帧记录里带上帧号和对应的时间戳范围,IMU记录里带上自己的时间戳和序号。这样即使后续需要重新对齐,也有据可查。
2.2 Camera2会话配置与OIS/DVS状态读取
Camera2的会话配置是整个采集流程里最容易翻车的地方。项目需要同时拿到图像数据和相机特性信息,所以CameraCharacteristics的读取要在打开相机之前完成。下面这段代码展示了如何查询设备是否支持OIS以及当前OIS模式:
// 获取相机特性 CameraCharacteristics characteristics = cameraManager.getCameraCharacteristics(cameraId); // 查询OIS支持情况 int[] oisModes = characteristics.get( CameraCharacteristics.LENS_INFO_AVAILABLE_OPTICAL_STABILIZATION); boolean oisSupported = false; if (oisModes != null) { for (int mode : oisModes) { if (mode == CameraCharacteristics.LENS_OPTICAL_STABILIZATION_MODE_ON) { oisSupported = true; break; } } } // 查询DVS支持情况(数字视频稳定) int[] videoStabModes = characteristics.get( CameraCharacteristics.CONTROL_AVAILABLE_VIDEO_STABILIZATION_MODES); boolean dvsSupported = false; if (videoStabModes != null) { for (int mode : videoStabModes) { if (mode == CameraCharacteristics.CONTROL_VIDEO_STABILIZATION_MODE_ON) { dvsSupported = true; break; } } } // 查询当前OIS状态 Integer currentOisMode = characteristics.get( CameraCharacteristics.LENS_INFO_OPTICAL_STABILIZATION_MODE);这段代码的逻辑很直接:先拿到CameraCharacteristics对象,然后分别查询OIS和DVS的可用模式列表。LENS_INFO_AVAILABLE_OPTICAL_STABILIZATION返回的是该设备支持的OIS模式数组,如果里面包含LENS_OPTICAL_STABILIZATION_MODE_ON,说明硬件支持OIS。DVS的查询类似,用的是CONTROL_AVAILABLE_VIDEO_STABILIZATION_MODES。最后一行读取的是当前生效的OIS模式,这个值会随拍摄状态变化,需要在每帧采集时重新读取。
参数说明:cameraId是相机ID字符串,通常后置摄像头是"0";LENS_INFO_OPTICAL_STABILIZATION_MODE返回的是当前OIS模式,可能为OFF、ON或ON_DYNAMIC。注意OIS是硬件级防抖,DVS是软件级防抖,两者可以同时开启,但开启后图像帧的像素坐标会发生变化,做SLAM时需要知道当前处于哪种状态。
2.3 IMU数据采集与时间戳对齐
IMU采集用的是SensorManager,注册TYPE_ACCELEROMETER和TYPE_GYROSCOPE两个传感器。采样率通过registerListener的第三个参数控制,通常设成SENSOR_DELAY_GAME或直接指定微秒数。关键点在于:IMU回调的时间戳是纳秒级的System.nanoTime(),而Camera2的图像时间戳是SensorEvent.timestamp同源的,两者可以对齐。
// 注册IMU监听器 SensorManager sensorManager = (SensorManager) getSystemService(Context.SENSOR_SERVICE); Sensor accelerometer = sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER); Sensor gyroscope = sensorManager.getDefaultSensor(Sensor.TYPE_GYROSCOPE); // 采样率设为100Hz(10000微秒) sensorManager.registerListener(imuListener, accelerometer, 10000); sensorManager.registerListener(imuListener, gyroscope, 10000); // IMU回调中缓存数据 private final SensorEventListener imuListener = new SensorEventListener() { @Override public void onSensorChanged(SensorEvent event) { // event.timestamp是纳秒级时间戳 ImuRecord record = new ImuRecord( event.timestamp, event.sensor.getType(), event.values[0], event.values[1], event.values[2] ); // 写入环形缓冲区,供图像帧对齐时取用 imuBuffer.add(record); } @Override public void onAccuracyChanged(Sensor sensor, int accuracy) { } };逻辑说明:registerListener的第三个参数是采样周期,单位微秒,10000对应100Hz。onSensorChanged回调里拿到的event.timestamp是纳秒级,和Camera2的Image.getTimestamp()在同一个时钟源上,这是后续做时间对齐的基础。imuBuffer建议用ConcurrentLinkedQueue或加锁的ArrayDeque,因为IMU回调在传感器线程,图像回调在另一个线程,两者并发访问缓冲区。
参数说明:event.values对于加速度计是三个轴的加速度值(含重力),对于陀螺仪是三个轴的角速度。event.sensor.getType()用来区分是哪个传感器的数据。缓冲区大小建议至少覆盖两帧图像之间的时间跨度,按30fps算就是33毫秒,100Hz采样率下大约3到4条记录。
2.4 数据落盘格式与目录组织
采集到的数据最终要写成文件,目录结构建议按会话组织:
/sdcard/VideoIMUCapture/session_20250101_120000/ ├── images/ │ ├── frame_000001.jpg │ ├── frame_000002.jpg │ └── ... ├── imu_data.csv ├── camera_info.json └── session_meta.jsonimu_data.csv每行格式为:timestamp_ns,sensor_type,x,y,z。camera_info.json存内参矩阵、畸变系数、分辨率。session_meta.json存会话开始时间、设备型号、OIS/DVS状态、帧率等元信息。图像帧用JPEG格式存,质量设成95以上,避免压缩伪影影响特征点提取。
写文件时注意用BufferedWriter批量写,不要每来一条IMU数据就flush一次,否则IO会成为瓶颈。图像帧的写入建议放到单独线程,用ExecutorService管理,避免阻塞相机回调。
3. 跑起来:从零编译到第一次采集的完整步骤
3.1 环境准备与项目导入
这个项目是标准的Android Studio工程,用Gradle构建。需要准备的环境:Android Studio(建议最新稳定版)、JDK 17、Android SDK(API 24以上,因为Camera2的完整功能在API 21引入,但部分特性如OIS状态读取需要更高版本)。项目导入后先同步Gradle,如果卡在下载依赖,检查build.gradle里的仓库配置。
# 克隆项目后进入目录 cd VideoIMUCapture-Android # 查看Gradle版本 cat gradle/wrapper/gradle-wrapper.properties # 命令行编译(可选,Android Studio里直接点Run也行) ./gradlew assembleDebug编译产物在app/build/outputs/apk/debug/下。如果编译报错找不到CameraCharacteristics.LENS_INFO_OPTICAL_STABILIZATION,检查compileSdk是否设成了33以上。
3.2 权限配置与运行时申请
Android 6.0以上需要运行时申请相机和存储权限。AndroidManifest.xml里声明:
<uses-permission android:name="android.permission.CAMERA" /> <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" /> <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" /> <uses-feature android:name="android.hardware.camera" /> <uses-feature android:name="android.hardware.sensor.accelerometer" /> <uses-feature android:name="android.hardware.sensor.gyroscope" />运行时申请用ActivityCompat.requestPermissions,在onRequestPermissionsResult里判断结果。注意Android 10以上要用MediaStore或getExternalFilesDir(),直接写/sdcard/会失败。
3.3 采集参数配置与启动流程
启动采集前需要配置几个关键参数:分辨率、帧率、IMU采样率、是否开启OIS。这些参数建议做成UI上的选项,方便不同场景切换。下面是一个配置和启动的示例:
// 配置采集参数 CaptureConfig config = new CaptureConfig.Builder() .setResolution(1920, 1080) // 1080p .setFrameRate(30) // 30fps .setImuSampleRate(100) // 100Hz .setEnableOIS(false) // 关闭OIS,保留原始运动 .setEnableDVS(false) // 关闭DVS .setOutputDir(getExternalFilesDir(null) + "/session_" + timestamp) .build(); // 启动采集 CaptureSession session = new CaptureSession(this, config); session.start();逻辑说明:CaptureConfig用Builder模式构造,每个参数都有默认值。setEnableOIS(false)表示关闭光学防抖,这样采集到的图像帧保留真实的相机运动,适合SLAM算法验证。如果要做OIS对比实验,就设成true再采一组。CaptureSession封装了相机打开、会话创建、IMU注册、文件写入的完整流程,start()方法里按顺序执行。
参数说明:分辨率建议用1920x1080或1280x720,太高会导致帧率下降和存储压力。帧率30fps是平衡选择,60fps对IMU对齐要求更高。IMU采样率100Hz起步,200Hz更好但耗电增加。输出目录用getExternalFilesDir()避免权限问题。
3.4 采集过程中的状态监控
采集启动后需要实时监控几个指标:实际帧率、IMU数据量、丢帧数、存储剩余空间。建议在界面上显示一个简单的状态栏。如果发现帧率远低于设定值,检查是不是图像格式设成了JPEG直接输出(应该用YUV_420_888再转JPEG),或者写入线程阻塞了相机回调。
// 简单的帧率统计 private long lastFrameTime = 0; private int frameCount = 0; private float currentFps = 0; private void onFrameAvailable(Image image) { long now = image.getTimestamp(); if (lastFrameTime != 0) { float instantFps = 1e9f / (now - lastFrameTime); currentFps = currentFps * 0.9f + instantFps * 0.1f; // 滑动平均 } lastFrameTime = now; frameCount++; // ... 后续处理 }这段代码用滑动平均算实时帧率,系数0.9是经验值,越大越平滑但响应越慢。image.getTimestamp()返回纳秒,所以除以1e9换算成秒。
4. 避坑与排查:采集端最容易翻车的五个地方
4.1 图像时间戳和IMU时间戳对不上
现象:采集完的数据拿去跑VIO,发现轨迹漂移严重,检查发现图像帧和IMU数据的时间戳差了十几毫秒甚至更多。
原因:Camera2的Image.getTimestamp()返回的是传感器曝光开始的时间,而IMU的SensorEvent.timestamp是事件上报到应用层的时间,两者之间隔着驱动和框架的处理延迟。另外不同厂商对时间戳的实现不一致,有的用System.nanoTime(),有的用SystemClock.elapsedRealtimeNanos()。
解决:在采集开始时记录一个基准时间,所有时间戳都减去这个基准。同时采集结束后做一次离线对齐,用互相关或者简单的最近邻匹配检查时间偏移量。如果偏移固定,可以在后处理时补偿。
4.2 OIS开启后内参失效
现象:开了OIS采集的数据,用标定好的内参去做去畸变和三角化,结果误差很大。
原因:OIS工作时镜头会移动,等效于内参在每一帧都在变。标定得到的内参是OIS关闭状态下的,开了OIS就不适用了。
解决:做SLAM或SfM采集时关闭OIS,用纯机械固定或者后期做数字稳像。如果必须开OIS,需要在每帧记录OIS的补偿量(部分设备通过CaptureResult的LENS_OPTICAL_STABILIZATION_MODE和统计信息可以拿到),但支持度参差不齐。
4.3 写入阻塞导致丢帧
现象:采集过程中帧率从30fps掉到十几fps,日志里看到ImageReader的onImageAvailable回调间隔变大。
原因:在相机回调线程里直接做JPEG编码和文件写入,IO耗时太长,ImageReader的缓冲区被占满,后续帧直接丢弃。
解决:相机回调里只做最轻量的操作——把Image对象丢进一个阻塞队列,由单独的写入线程池处理编码和落盘。队列长度设成3到5,满了就丢弃最旧的帧并记录丢帧计数。
4.4 不同设备IMU坐标系不一致
现象:在A手机上跑得挺好的算法,换到B手机就完全失效。
原因:Android的传感器坐标系定义是固定的(X轴向右,Y轴向上,Z轴指向屏幕外),但相机坐标系在不同设备上的朝向可能不同,导致IMU和相机之间的外参需要重新标定。
解决:采集时同时记录设备型号和传感器方向信息(Sensor.getOrientation()),后处理时根据设备型号加载对应的外参。更稳妥的做法是每次换设备都重新做一次相机-IMU联合标定。
4.5 存储空间不足导致采集中断
现象:采集到一半突然停止,日志显示IOException: No space left on device。
原因:1080p 30fps的JPEG图像每帧约200KB,一分钟就是360MB,加上IMU数据,10分钟就超过3GB。
解决:采集前检查剩余空间,估算可采集时长并提示用户。采集过程中定期检查空间,低于阈值时自动停止并保存已采集的数据。另外可以用YUV原始格式存,虽然文件更大但省去了JPEG编码时间,后处理时再转。
5. 二次开发:从采集工具到算法验证平台的改造思路
这个项目本身是一个采集工具,但它的价值在于可以作为一个算法验证平台的基础。我拿到手之后做的第一件事是把数据落盘格式改成HDF5,因为CSV在IMU数据量大的时候读写都慢,HDF5支持分块压缩和随机访问,后处理时方便按时间范围切片。改的时候注意保持时间戳精度,HDF5的int64存纳秒时间戳没问题。
第二个改造点是加一个实时预览和特征点叠加的界面。用OpenCV的Android SDK,在相机预览的SurfaceView上叠加ORB特征点,采集时就能看到当前场景的特征丰富程度。这个功能对采集质量把控很有用——特征点太少的地方采了也是白采。实现上是在onFrameAvailable里把YUV转成RGBA,送进ORB检测器,再把关键点画到预览层上。注意别在相机回调线程里做,会拖慢帧率。
第三个改造是加一个采集质量评估模块。采集结束后自动算几个指标:IMU数据的连续性(有没有大段缺失)、图像帧的时间间隔方差(稳不稳定)、特征点数量的时间序列(够不够丰富)。这些指标用Python脚本做后处理也行,但集成到App里可以当场给采集人员反馈,避免采完回去才发现数据不能用。
# 后处理质量评估示例(Python脚本,在PC上跑) import numpy as np import pandas as pd # 读取IMU数据 imu = pd.read_csv('imu_data.csv') timestamps = imu['timestamp_ns'].values dt = np.diff(timestamps) / 1e9 # 转成秒 # 检查IMU连续性 expected_dt = 0.01 # 100Hz对应10ms gaps = np.where(dt > expected_dt * 3)[0] # 超过3倍采样周期算缺失 print(f"IMU数据总时长: {timestamps[-1] - timestamps[0]:.2f}秒") print(f"缺失段数: {len(gaps)}") if len(gaps) > 0: print(f"最大缺失间隔: {dt[gaps].max():.4f}秒") # 检查图像帧时间间隔 frame_times = np.array([...]) # 从图像文件名或元数据读取 frame_dt = np.diff(frame_times) / 1e9 print(f"平均帧率: {1.0 / frame_dt.mean():.2f}fps") print(f"帧间隔标准差: {frame_dt.std() * 1000:.2f}ms")这段脚本的逻辑是:先算IMU时间戳的差分,找出超过3倍采样周期的间隔作为缺失段;再算图像帧的时间间隔,评估帧率稳定性。参数expected_dt根据实际IMU采样率调整,100Hz就是0.01秒。gaps数组存的是缺失段起始位置的索引,可以用来定位问题时间段。
还有一个容易忽略的点:采集时的设备温度。手机长时间采集会发热,发热后相机和IMU的性能都会下降,表现为帧率不稳、IMU噪声增大。我一般会在采集前让设备冷却到室温,采集过程中监控电池温度,超过40度就暂停休息。这个在session_meta.json里记一下温度曲线,后处理时可以把高温段的数据标记出来。
从那以后我每次做数据采集,都强制走一遍「冷却-标定-试采-质量检查-正式采集」的流程,试采30秒检查时间戳对齐和帧率稳定性,没问题再开始正式采集。希望帮到你。
本文还有配套的精品资源,点击获取