22. 时间流逝视频(延时摄影):定时Capture策略、帧合成与编码、延时摄影参数优化
延时摄影,说白了就是把几个小时甚至几天的变化,压缩到几十秒里。云在飞、车在跑、花在开——这些平时肉眼很难察觉的缓慢变化,一下子变得特别有冲击力。
我最早接触延时摄影是在一个户外监控项目里。客户说想看看工地三个月的变化,但总不能让人盯着24小时看吧?于是我就想到了这个方案。嗯,今天咱们就把这套技术拆开揉碎了讲清楚。
22.1 延时摄影的核心逻辑
延时摄影的本质很简单:每隔一段时间抓一帧,然后把抓到的帧拼成视频。你想想看,正常视频一秒30帧,延时摄影可能一分钟才抓1帧。时间就这样被“压缩”了。
核心公式:
延时倍率 = 拍摄间隔(秒) × 视频帧率(帧/秒)
举个例子:间隔5秒拍一帧,最终视频用30fps播放。
那么延时倍率 = 5 × 30 = 150倍。
现实中的150秒,在视频里只有1秒。
我习惯把这个逻辑画成一张图,方便理解整个流程:
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 完整工作流
把上面这些串起来,一个完整的延时摄影工作流大概是这样的:
- 初始化:打开Camera,创建ImageReader,配置MediaCodec
- 开始采集:启动定时器,每隔指定间隔发起一次Capture
- 缓冲管理:采集到的帧放入队列,控制队列大小
- 帧处理:可选做去闪烁、色彩校正等处理
- 编码输出:从队列取帧,喂给MediaCodec,输出到Mp4Muxer
- 结束:停止定时器,释放所有资源
嗯,这套流程我在好几个项目里都用过,稳定性和效果都还不错。你如果自己实现的话,建议先从最简单的Handler方案开始,跑通了再逐步优化。
延时摄影的魅力在于——你给了它时间,它回馈你惊艳。参数调好了,效果真的让人上瘾。