在 Android 上获取视频流逐帧时间戳并与 GPS/IMU 对齐(CameraX 实践)

在 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。
  • 权限:在清单中声明 CAMERARECORD_AUDIOACCESS_FINE_LOCATIONHIGH_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 找到最近的 GPS tsNs,计算差值毫秒,输出中位数与最大值作为对齐精度指标。
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 ImageAnalysisimageInfo.timestamp 与 GPS/IMU 的 elapsedRealtimeNanos 统一到同一时间域,可可靠对齐并验证。
  • 若需要更高精度及与编码帧严格一致的分析,建议迁移到 Camera2 + ImageReader 并结合媒体时间轴进行深度校验。
相关推荐
张拭心10 分钟前
春节后,有些公司明确要求 AI 经验了
android·前端·人工智能
张拭心21 分钟前
Android 17 来了!新特性介绍与适配建议
android·前端
Kapaseker3 小时前
Compose 进阶—巧用 GraphicsLayer
android·kotlin
黄林晴3 小时前
Android17 为什么重写 MessageQueue
android
阿巴斯甜1 天前
Android 报错:Zip file '/Users/lyy/develop/repoAndroidLapp/l-app-android-ble/app/bu
android
Kapaseker1 天前
实战 Compose 中的 IntrinsicSize
android·kotlin
xq95271 天前
Andorid Google 登录接入文档
android
黄林晴1 天前
告别 Modifier 地狱,Compose 样式系统要变天了
android·android jetpack
冬奇Lab2 天前
Android触摸事件分发、手势识别与输入优化实战
android·源码阅读
城东米粉儿2 天前
Android MediaPlayer 笔记
android