在 Android 上获取视频流逐帧时间戳并与 GPS/IMU 对齐(CameraX 实践)
本文面向移动端开发者,完整讲解如何在 Android 上获取视频流的每一帧时间戳,并与 GPS/IMU 数据统一到同一个"单调时钟"时间域,从而支持离线防抖、传感器数据挂载与可验证的时间对齐。
为什么需要逐帧时间戳
- 离线防抖:IMU 驱动的稳定化算法按时间戳对齐视频帧,时间线是核心输入。
- 传感器挂载:叠加定位、速度、水印等信息,需要把视频帧与 GPS/IMU 的时间线统一。
- 诊断与验证:通过时间戳可以定位帧丢失/延迟,并量化跨传感器对齐的精度。
核心原理:统一到单调时钟域
- 统一使用"开机到现在的纳秒"(单调时钟):
elapsedRealtimeNanos()。 - 视频帧:
ImageProxy.imageInfo.timestamp(ns 单调时钟)。 - GPS:
Location.elapsedRealtimeNanos(ns 单调时钟)。 - IMU:
SensorEvent.timestamp(ns 单调时钟)。 - 人类可读日志:可映射到墙钟毫秒或相对录制起点毫秒,但跨传感器计算务必用单调时钟。
实现总览
- 核心类:
FrameTimestampRecorder:逐帧采集与日志、写出frames.json(android-sdk/stabilizer-demo/src/main/java/com/driveverse/demo/FrameTimestampRecorder.kt)。GpsTimestampRecorder:GPS 时间戳与经纬度,写出gps.json(android-sdk/stabilizer-demo/src/main/java/com/driveverse/demo/GpsTimestampRecorder.kt)。MainActivity/RecordingActivity:绑定 CameraX 预览/录制/分析,协调开始/结束并保存 JSON。
- 权限:在清单中声明
CAMERA、RECORD_AUDIO、ACCESS_FINE_LOCATION、HIGH_SAMPLING_RATE_SENSORS等。
帧时间戳采集(CameraX ImageAnalysis)
- 绑定
ImageAnalysis,在后台线程接收每帧,读取imageInfo.timestamp(纳秒),记录缓冲并打印人类可读时间与相对毫秒,录制结束写出 JSON。
kotlin
// android-sdk/stabilizer-demo/src/main/java/com/driveverse/demo/FrameTimestampRecorder.kt:31-49
fun createAnalysis(): ImageAnalysis {
if (analysis != null) return analysis as ImageAnalysis
val a = ImageAnalysis.Builder()
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
.build()
a.setAnalyzer(executor) { image: ImageProxy ->
if (running) {
val ts = image.imageInfo.timestamp
buffer.add(FrameInfo(index++, ts))
if (firstTsNs < 0) firstTsNs = ts
val wallMs = baseWallMs - (baseElapsedNs / 1_000_000L) + (ts / 1_000_000L)
val relMs = (ts - firstTsNs) / 1_000_000L
Log.d(tag, "frame #$index wall=${sdf.format(Date(wallMs))} rel=${relMs}ms tsNs=$ts")
}
image.close()
}
analysis = a
return a
}
kotlin
// android-sdk/stabilizer-demo/src/main/java/com/driveverse/demo/FrameTimestampRecorder.kt:51-59
fun start() {
running = true
index = 0
buffer.clear()
baseElapsedNs = SystemClock.elapsedRealtimeNanos()
baseWallMs = System.currentTimeMillis()
firstTsNs = -1L
Log.i(tag, "start")
}
kotlin
// android-sdk/stabilizer-demo/src/main/java/com/driveverse/demo/FrameTimestampRecorder.kt:61-76
fun stopAndSave(outFile: File) {
running = false
val gson = Gson()
val arr = JsonArray()
for (f in buffer) {
val obj = JsonObject()
obj.addProperty("index", f.index)
obj.addProperty("tsNs", f.tsNs)
arr.add(obj)
}
val root = JsonObject()
root.add("frames", arr)
outFile.writeText(gson.toJson(root))
buffer.clear()
Log.i(tag, "saved count=${arr.size()} file=${outFile.absolutePath}")
}
GPS 时间戳采集(FusedLocationProvider)
- 请求高精度定位、定期回调
LocationResult,读取elapsedRealtimeNanos与经纬度/速度,定期打印日志并写出 JSON。
kotlin
// android-sdk/stabilizer-demo/src/main/java/com/driveverse/demo/GpsTimestampRecorder.kt:25-49
@SuppressLint("MissingPermission")
fun start() {
if (running) return
running = true
buffer.clear()
val req = LocationRequest.Builder(1000)
.setMinUpdateIntervalMillis(500)
.setPriority(LocationRequest.PRIORITY_HIGH_ACCURACY)
.build()
val cb = object : LocationCallback() {
override fun onLocationResult(result: LocationResult) {
for (loc: Location in result.locations) {
val tsNs = loc.elapsedRealtimeNanos
buffer.add(GpsInfo(tsNs, loc.latitude, loc.longitude, loc.speed))
if (buffer.size % 5 == 0) {
Log.d(tag, "gps tsNs=$tsNs lat=${loc.latitude} lon=${loc.longitude} speed=${loc.speed}")
}
}
}
}
callback = cb
client.requestLocationUpdates(req, cb, context.mainLooper)
Log.i(tag, "start updates")
}
kotlin
// android-sdk/stabilizer-demo/src/main/java/com/driveverse/demo/GpsTimestampRecorder.kt:50-69
fun stopAndSave(outFile: File) {
if (!running) return
running = false
callback?.let { client.removeLocationUpdates(it) }
val gson = Gson()
val arr = JsonArray()
for (g in buffer) {
val obj = JsonObject()
obj.addProperty("tsNs", g.tsNs)
obj.addProperty("lat", g.lat)
obj.addProperty("lon", g.lon)
obj.addProperty("speed", g.speed)
arr.add(obj)
}
val root = JsonObject()
root.add("gps", arr)
outFile.writeText(gson.toJson(root))
buffer.clear()
Log.i(tag, "saved count=${arr.size()} file=${outFile.absolutePath}")
}
Activity 集成与写出时机
- 绑定分析器、预览与录制到同一生命周期会话:
kotlin
// android-sdk/stabilizer-demo/src/main/java/com/driveverse/demo/MainActivity.kt:151-157
val analysis = frameRecorder.createAnalysis()
cameraProvider.unbindAll()
cameraProvider.bindToLifecycle(this, cameraSelector, preview, videoCapture, analysis)
tvStatus.text = "预览就绪,点击开始录制"
Log.i("DriveVerse-FrameTS", "ImageAnalysis bound for frame timestamp recording")
- 录制开始/结束,协调 IMU/帧/GPS 并保存 JSON:
kotlin
// android-sdk/stabilizer-demo/src/main/java/com/driveverse/demo/MainActivity.kt:179-201
when (event) {
is VideoRecordEvent.Start -> {
runOnUiThread {
tvStatus.text = "录制中..."
btnRecord.text = "停止录制"
}
imuRecorder.start()
frameRecorder.start()
gpsRecorder.start()
Log.i("DriveVerse-Record", "start video=${videoFile.absolutePath}")
lastRecordedVideo = videoFile
lastRecordedImu = imuFile
}
is VideoRecordEvent.Finalize -> {
runOnUiThread {
tvStatus.text = if (event.hasError()) "录制失败: ${event.error}" else "录制完成"
btnRecord.text = "开始录制"
preparePlaybackOriginal(videoFile)
}
imuRecorder.stopAndSave(imuFile)
frameRecorder.stopAndSave(framesFile)
gpsRecorder.stopAndSave(gpsFile)
Log.i("DriveVerse-Record", "finalized video=${videoFile.absolutePath} imu=${imuFile.absolutePath} frames=${framesFile.absolutePath} gps=${gpsFile.absolutePath}")
}
}
验证:帧时间戳与 GPS 对齐的精度
- 对每个帧的
tsNs找到最近的 GPStsNs,计算差值毫秒,输出中位数与最大值作为对齐精度指标。
kotlin
// android-sdk/stabilizer-demo/src/main/java/com/driveverse/demo/MainActivity.kt:265-314
private fun compareFramesAndGps(framesFile: File, gpsFile: File): String {
return try {
val gson = com.google.gson.Gson()
val framesRoot = com.google.gson.JsonParser.parseString(framesFile.readText()).asJsonObject
val gpsRoot = com.google.gson.JsonParser.parseString(gpsFile.readText()).asJsonObject
val framesArr = framesRoot.getAsJsonArray("frames")
val gpsArr = gpsRoot.getAsJsonArray("gps")
val gpsTs = ArrayList<Long>(gpsArr.size())
gpsArr.forEach { el -> gpsTs.add(el.asJsonObject.get("tsNs").asLong) }
gpsTs.sort()
val diffsMs = ArrayList<Long>(framesArr.size())
framesArr.forEach { el ->
val ts = el.asJsonObject.get("tsNs").asLong
val nearest = nearestNs(ts, gpsTs)
val diff = kotlin.math.abs(ts - nearest) / 1_000_000L
diffsMs.add(diff)
}
diffsMs.sort()
val median = if (diffsMs.isNotEmpty()) diffsMs[diffsMs.size / 2] else 0L
val max = diffsMs.maxOrNull() ?: 0L
val msg = "帧数 ${framesArr.size()},GPS点 ${gpsArr.size()},时间差中位数 ${median}ms,最大 ${max}ms"
Log.i("DriveVerse-Compare", msg)
msg
} catch (_: Exception) {
val msg = "帧/GPS 对比失败"
Log.w("DriveVerse-Compare", msg)
msg
}
}
权限与常见问题
- 清单权限声明(AndroidManifest):
xml
<!-- android-sdk/stabilizer-demo/src/main/AndroidManifest.xml:3-9 -->
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.HIGH_SAMPLING_RATE_SENSORS" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
- 传感器高采样崩溃(
HIGH_SAMPLING_RATE_SENSORS):缺少权限会导致SecurityException,需在清单声明;设备仍有限制时,降低采样速率(如SENSOR_DELAY_GAME)。 - 时间域混用导致错位:跨传感器对齐统一使用纳秒单调时钟,不用
System.currentTimeMillis();人类可读日志仅用于观察。 - 分析帧与编码帧不完全一致:
ImageAnalysis与最终编码序列不同步,适合做时间戳采集与对齐;若需与成片严格一致,使用 Camera2 +ImageReader或事后用MediaExtractor提取presentationTimeUs校验。
日志与可观测性
- 日志标签:
DriveVerse-FrameTS:每帧打印墙钟与相对毫秒。DriveVerse-GPS:定位时间戳与经纬度。DriveVerse-Record:录制开始/结束与输出路径。DriveVerse-Compare:帧/GPS 对比统计。
- 示例:
- 帧日志:
frame #123 wall=2025-12-04 20:15:13.456 rel=1234ms tsNs=... - 对比日志:
帧数 XXX,GPS点 YYY,时间差中位数 ZZZms,最大 MMMms
- 帧日志:
JSON 数据示例
frames.json
json
{ "frames": [ { "index": 0, "tsNs": 1234567890 }, { "index": 1, "tsNs": 1234567990 } ] }
gps.json
json
{ "gps": [ { "tsNs": 9876543210, "lat": 39.9, "lon": 116.3, "speed": 1.2 } ] }
总结与扩展
- CameraX
ImageAnalysis的imageInfo.timestamp与 GPS/IMU 的elapsedRealtimeNanos统一到同一时间域,可可靠对齐并验证。 - 若需要更高精度及与编码帧严格一致的分析,建议迁移到 Camera2 +
ImageReader并结合媒体时间轴进行深度校验。