22. 时间流逝视频(延时摄影):定时Capture策略、帧合成与编码、延时摄影参数优化

延时摄影,说白了就是把几个小时甚至几天的变化,压缩到几十秒里。云在飞、车在跑、花在开——这些平时肉眼很难察觉的缓慢变化,一下子变得特别有冲击力。

我最早接触延时摄影是在一个户外监控项目里。客户说想看看工地三个月的变化,但总不能让人盯着24小时看吧?于是我就想到了这个方案。嗯,今天咱们就把这套技术拆开揉碎了讲清楚。

22.1 延时摄影的核心逻辑

延时摄影的本质很简单:每隔一段时间抓一帧,然后把抓到的帧拼成视频。你想想看,正常视频一秒30帧,延时摄影可能一分钟才抓1帧。时间就这样被“压缩”了。

核心公式:

延时倍率 = 拍摄间隔(秒) × 视频帧率(帧/秒)

举个例子:间隔5秒拍一帧,最终视频用30fps播放。

那么延时倍率 = 5 × 30 = 150倍。

现实中的150秒,在视频里只有1秒。

我习惯把这个逻辑画成一张图,方便理解整个流程:

延时摄影核心流程 Camera 采集 定时Capture 帧缓冲队列 内存管理 帧合成 可选处理 MediaCodec H.264编码 CaptureRequest ImageReader ConcurrentLinkedQueue 或环形缓冲 亮度/色彩校正 去闪烁处理 Mp4Muxer 输出文件 T0 T+间隔 T+2间隔 T+3间隔 T+n间隔 时间轴 → 每隔固定间隔采集一帧 最终合成视频时按正常帧率播放

22.2 定时Capture策略

定时采集是延时摄影的起点。怎么定时间隔?用什么方式触发?这里头有不少讲究。

22.2.1 基于Handler的定时方案

最简单的做法是用Handler + postDelayed。每次采集完一帧,再安排下一次采集。

// 延时摄影采集核心
private val captureHandler = Handler(Looper.getMainLooper())
private var captureInterval = 5000L // 默认5秒

private val captureRunnable = object : Runnable {
    override fun run() {
        // 发起一次Capture请求
        captureSession?.setRepeatingRequest(captureRequest, null, null)
        // 安排下一次采集
        captureHandler.postDelayed(this, captureInterval)
    }
}

// 开始延时摄影
fun startTimelapse(intervalMs: Long) {
    captureInterval = intervalMs
    captureHandler.post(captureRunnable)
}

// 停止
fun stopTimelapse() {
    captureHandler.removeCallbacks(captureRunnable)
}

我的经验:Handler方案简单可靠,适合大多数场景。但要注意——如果采集耗时超过了间隔时间,会导致任务堆积。我曾经在一个低端机上遇到过这个问题,后来加了时间戳校验才解决。

22.2.2 基于Choreographer的精准方案

如果你需要毫秒级的精准度,Handler就不太够用了。我建议用Choreographer + 帧回调。

// 更精准的定时方案
class PreciseTimelapseCapture {
    private var lastCaptureTime = 0L
    private var intervalNanos = TimeUnit.SECONDS.toNanos(5)
    
    private val frameCallback = object : Choreographer.FrameCallback {
        override fun doFrame(frameTimeNanos: Long) {
            if (frameTimeNanos - lastCaptureTime >= intervalNanos) {
                // 执行采集
                triggerCapture()
                lastCaptureTime = frameTimeNanos
            }
            // 继续监听下一帧
            Choreographer.getInstance().postFrameCallback(this)
        }
    }
    
    fun start() {
        Choreographer.getInstance().postFrameCallback(frameCallback)
    }
}

注意:Choreographer的回调频率是跟屏幕刷新率走的(60Hz或120Hz)。如果你间隔设得很大,大部分回调都会被跳过,CPU占用其实不高。但如果你间隔设得很短(比如100ms),就要小心性能问题了。

22.3 帧合成与编码

采集到的帧怎么处理?直接扔给编码器?嗯,没那么简单。

22.3.1 帧缓冲队列

我习惯用ConcurrentLinkedQueue来做缓冲。采集线程往队列里放,编码线程从队列里取。这样两边互不阻塞。

// 帧缓冲队列
private val frameQueue = ConcurrentLinkedQueue<Image>()
private val maxQueueSize = 30 // 防止OOM

// 采集回调
private val imageReader = ImageReader.newInstance(width, height, 
    ImageFormat.YUV_420_888, 2)

imageReader.setOnImageAvailableListener({ reader ->
    val image = reader?.acquireLatestImage()
    image?.let {
        if (frameQueue.size < maxQueueSize) {
            frameQueue.offer(it)
        } else {
            // 队列满了,丢弃最旧的帧
            frameQueue.poll()?.close()
            frameQueue.offer(it)
        }
    }
}, backgroundHandler)

关键点:一定要控制队列大小。我见过有人不限制队列,结果内存直接飙到几百MB,应用被系统杀掉。30帧的缓冲足够用了,再多就是浪费。

22.3.2 编码线程循环

编码线程需要一直跑,不断从队列里取帧,然后喂给MediaCodec。

// 编码线程
private fun startEncodingLoop() {
    Thread {
        while (isEncoding) {
            val image = frameQueue.poll()
            if (image != null) {
                // 将Image转成ByteBuffer
                val buffer = imageToByteBuffer(image)
                // 喂给MediaCodec
                val inputIndex = mediaCodec.dequeueInputBuffer(10000)
                if (inputIndex >= 0) {
                    val inputBuffer = mediaCodec.getInputBuffer(inputIndex)
                    inputBuffer?.clear()
                    inputBuffer?.put(buffer)
                    mediaCodec.queueInputBuffer(inputIndex, 0, 
                        buffer.remaining(), presentationTimeUs, 0)
                    presentationTimeUs += 33333 // 30fps的每帧时长
                }
                image.close()
            } else {
                // 队列为空,稍等再试
                Thread.sleep(10)
            }
        }
    }.start()
}

22.4 延时摄影参数优化

参数调优是延时摄影里最磨人的部分。我踩过不少坑,下面这几个参数是我觉得最重要的。

22.4.1 采集间隔的选择

场景 推荐间隔 说明
云彩流动 2-5秒 云动得快,间隔短一点
日落/日出 10-30秒 光线变化慢,间隔可以长
花朵开放 1-5分钟 非常缓慢,需要耐心
建筑工地 5-15分钟 变化周期长

我的建议:如果你不确定间隔设多少,先按5秒来。拍一段看看效果,再调整。总比拍了一整天发现间隔太密或太疏要好。

22.4.2 曝光锁定

延时摄影最怕什么?画面忽明忽暗。自动曝光在延时摄影里是个灾难。我强烈建议锁定曝光参数。

// 锁定曝光
val captureRequestBuilder = cameraDevice
    .createCaptureRequest(CameraDevice.TEMPLATE_PREVIEW)
    
// 锁定AE和AWB
captureRequestBuilder.set(CaptureRequest.CONTROL_AE_MODE, 
    CaptureRequest.CONTROL_AE_MODE_OFF)
captureRequestBuilder.set(CaptureRequest.SENSOR_EXPOSURE_TIME, 
    exposureTimeNs) // 固定曝光时间
captureRequestBuilder.set(CaptureRequest.CONTROL_AWB_MODE, 
    CaptureRequest.CONTROL_AWB_MODE_OFF)
captureRequestBuilder.set(CaptureRequest.CONTROL_AWB_LOCK, true)

避坑指南:我曾经在一个日落延时摄影项目里忘了锁曝光。结果拍出来的视频前半段正常,后半段越来越暗,最后全黑了。因为相机在自动调整曝光,但延时摄影的帧之间时间跨度太大,自动曝光根本反应不过来。从那以后,我每次都会手动锁定曝光参数。

22.4.3 去闪烁处理

如果你用人工光源(比如室内延时摄影),可能会遇到画面闪烁的问题。这是因为交流电的频率和相机快门速度不匹配。

解决办法有两个:

  • 调整快门速度:在50Hz地区用1/50秒,60Hz地区用1/60秒
  • 后期去闪烁:在帧合成阶段做亮度归一化
// 简单的亮度归一化
fun normalizeBrightness(image: Image): Image {
    // 计算当前帧的平均亮度
    val avgLuminance = calculateAverageLuminance(image)
    // 和目标亮度比较
    val targetLuminance = 0.5 // 归一化目标
    val gain = targetLuminance / avgLuminance
    // 应用增益
    applyGain(image, gain.toFloat())
    return image
}

22.5 完整工作流

把上面这些串起来,一个完整的延时摄影工作流大概是这样的:

  1. 初始化:打开Camera,创建ImageReader,配置MediaCodec
  2. 开始采集:启动定时器,每隔指定间隔发起一次Capture
  3. 缓冲管理:采集到的帧放入队列,控制队列大小
  4. 帧处理:可选做去闪烁、色彩校正等处理
  5. 编码输出:从队列取帧,喂给MediaCodec,输出到Mp4Muxer
  6. 结束:停止定时器,释放所有资源

嗯,这套流程我在好几个项目里都用过,稳定性和效果都还不错。你如果自己实现的话,建议先从最简单的Handler方案开始,跑通了再逐步优化。

延时摄影的魅力在于——你给了它时间,它回馈你惊艳。参数调好了,效果真的让人上瘾。


公众号:蓝海资料掘金营,微信deep3321