在 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 并结合媒体时间轴进行深度校验。
相关推荐
TAEHENGV1 天前
关于应用模块 Cordova 与 OpenHarmony 混合开发实战
android·javascript·数据库
程序员码歌1 天前
短思考第265天,小红书起号3周怎么样?实操总结这3点!
android·ai编程
小徐Chao努力1 天前
【Langchain4j-Java AI开发】05-对话记忆管理
android·java·人工智能
大白要努力!1 天前
Android 项目历史提交远程仓库资源过大,如何清理历史提交中无用的大文件
android·git
橙子199110161 天前
Scaffold
android·kotlin·android jetpack
我命由我123451 天前
Android 消息机制 - Looper(Looper 静态方法、Looper 静态方法注意事项、Looper 实例方法、Looper 实例方法注意事项)
android·java·android studio·安卓·android jetpack·android-studio·android runtime
消失的旧时光-19431 天前
从 JVM 到 Linux:一次真正的系统级理解
android·linux·jvm
习惯就好zz1 天前
Android 12 RK3588平台电源菜单深度定制指南
android·rockchip·3588·电源按钮
nono牛1 天前
Android.bp 语法编程指南 1
android
李坤林1 天前
Android 12 BLASTBufferQueue 深度分析
android