手机夜景模式为什么要举 3 秒?8 帧 RAW 堆栈 SNR 实测

夜里举起手机,画面先是一层雪花,过一会儿才慢慢变亮。

手机没有把 ISO 拉到天上,它让你举住两三秒,连拍一叠,再把这一叠平均成一张。

我把这件事做成了一个网页实验室。

16 帧平均之后,信噪比从 5.2 dB 升到 16.6 dB,噪声标准差从 15.15 降到 4.07。

改帧数、改 ISO、改手抖幅度,曲线和指标都会当场重算,没有一张图是提前截好的。

单文件、无依赖,公开收录在 Demo Park:gitcode.com/OPCTeam/dem...。

下载里面的 index.html,用浏览器打开就能跑。

页面底部 8 项自检全部通过。

图 1:左边是单帧,右边是 16 帧均值堆栈,指标面板同时给出 SNR、噪声标准差和合成耗时。

这不是相机评测,也不是 RAW 解码器。

夜景是 canvas 画的,噪声是我按散粒噪声和读出噪声合成的。

它能说明堆栈为什么有效,但不能替代真机样张。

一、为什么手机让你举 3 秒

单帧只曝光八分之一秒,进光量就那么点。

光子本身是随机到达的,信号越弱,随机涨落占的比例越大,画面就越花。

把曝光拉长能进更多光,但手会抖。

手机换了个办法:用同样的短曝光连拍一叠,每张都带着同一份景物和各自独立的噪声。

景物在每个像素上基本不变,噪声每次都换位置。

把 16 张平均,信号留了下来,噪声被摊薄,理论上降到原来的四分之一。

这就是夜景模式要你举住的原因。

8 帧乘八分之一秒是一秒,16 帧是两秒,再加上对齐、合成和防抖余量,体感就是两三秒。

值得看的地方不在界面,而在于你能亲手把「多帧降噪」这四个字拆成可验证的数字。

二、这间实验室怎么搭

场景是一张 400×300 的 canvas 夜景:深蓝夜空、月亮、楼群、亮着的窗、路灯光晕,还有地面反光。

每一帧都从这张无噪真值出发,叠上两层噪声。

散粒噪声随亮度开方增长,读出噪声是固定强度的高斯分布,ISO 滑块控制整体强度。

手抖用整数像素平移模拟,范围 0 到 8 像素。

真实手抖还有旋转和透视,这里只做平移,先把最影响清晰度的那一维讲清楚。

对齐用的是一块 160×100 的中心区域做 SAD 搜索。

把当前帧平移若干个像素,逐点比较亮度和参考帧的差,差最小的地方就是位移。

js 复制代码
for (let dy = -8; dy <= 8; dy++) {
  for (let dx = -8; dx <= 8; dx++) {
    let sad = 0, count = 0;
    for (let y = cy0; y < cy0 + 100; y += 2) {
      for (let x = cx0; x < cx0 + 160; x += 2) {
        const ri = (y * W + x) * 4;
        const fi = ((y + dy) * W + (x + dx)) * 4;
        sad += Math.abs(gray(frameData, fi) - gray(refData, ri));
        count++;
      }
    }
    if (sad / count < bestSad) { bestSad = sad / count; bestDx = dx; bestDy = dy; }
  }
}

合成有两种:均值和中值。

均值把每个像素的所有帧加起来除以帧数,中值把每个像素的 16 个值排序取中间那个。

指标全部相对无噪真值计算。

SNR 看信号和误差的功率比,噪声标准差看平坦区域的起伏,锐度看相邻像素的梯度总和。

页面底部有 8 条断言,每次调整参数都会重跑。

它们不是写死的文案,是通过和失败都要当场算出来。

三、实测:帧数翻倍,SNR 涨 3 dB

固定 ISO 3、手抖 0、开启对齐,只改帧数,读数如下。

帧数 SNR 相对单帧 噪声标准差 等效手持时长
1 5.2 dB 0 15.15 约 0.15 s
2 8.1 dB +2.9 dB --- 约 0.30 s
4 11.0 dB +5.8 dB 7.71 约 0.55 s
8 13.8 dB +8.6 dB --- 约 1.05 s
16 16.6 dB +11.4 dB 4.07 2.38 s
32 19.0 dB +13.8 dB --- 5.50 s

每翻一倍帧数,SNR 涨 2.5 到 2.9 dB。

理论值是 3.01 dB,差的这一点来自场景结构误差和边缘处理,不是随机波动。

噪声标准差的下降更直观。

1 帧 15.15,4 帧 7.71,16 帧 4.07。

1 到 4 帧降了 1.97 倍,理论根号 4 是 2.00 倍。

32 帧以后曲线开始变平。

再往上加帧,收益越来越小,手持时间却线性上涨,这就是手机把上限卡在 8 到 16 帧的原因。

中值堆栈和均值堆栈的差别也测了。

同样 32 帧、ISO 3,均值 19.0 dB,中值 17.7 dB,中值低了 1.3 dB。

中值用一部分降噪效率换抗坏点能力。

这个交换值不值,要看你更怕噪声还是更怕坏帧。

图 2:单帧加高 ISO,画面几乎被噪声淹没,指标面板给出负的 SNR。

图 3:帧数拉到 32,中值堆栈的噪点更干净,但合成耗时也涨到了 1.5 秒。

四、验证与踩坑:第一版对齐把画面越修越糊

第一版的对齐为了快,先把图缩到四分之一,在缩略图上搜 ±8 像素,找到结果再乘 4 回原图。

问题就出在这个乘 4。

缩略图上的 1 像素等于原图 4 像素,1 到 3 像素的手抖会被量化成 0 或 4。

结果是对齐后的 16 帧 SNR 只有 1.2 dB,不对齐反而有 5.2 dB。

画面也确实更糊,边缘像被抹了一层。

修复办法是回到原分辨率搜索。

中心 160×100 的区域,±8 像素范围,每隔一个点采样,整数像素精度。

改完之后,同样 16 帧、同样手抖,对齐后 16.5 dB,不对齐 6.3 dB。

这一版才真正把位移补掉。

第二个坑更隐蔽。

所有帧都对齐到第一帧,而第一帧自己也可能带着几像素手抖。

堆栈结果整体偏了一点,和真值比 SNR 就被低估。

我在评价前加了一次全局配准,把堆栈结果和真值再对一次。

画面本身没变,读数回到了正常水平。

第三个坑是指标选错。

一开始用「堆栈 SNR 是否低于单帧」判断有没有拖影,结果关掉对齐、手抖 8 像素时,SNR 还有 4.3 dB,比单帧的 -1.1 dB 高。

原因是多帧平均本身还在降噪,模糊造成的结构误差被平均带来的增益盖住了。

SNR 能看噪声,看不出拖影。

后来把失败判定改成看锐度:堆栈锐度 17.74,真值锐度 21.7,掉了 20.7%。

画面糊没糊,这个数字比 SNR 诚实。

五、失败路径:坏帧与手抖

坏帧路径留了一个按钮。

点「注入坏帧」,其中一帧会变成全黑,用来验证中值堆栈能不能扛住。

16 帧里坏 1 帧,均值 15.6 dB,中值 14.4 dB,均值反而更好。

坏帧只占十六分之一,平均之后影响很小。

坏 3 帧就反过来了。

均值掉到 4.9 dB,中值还有 10.0 dB。

坏帧比例上来以后,中值的抗性才体现出来。

图 4:注入坏帧后切到中值堆栈,SNR 14.5 dB,按钮变成「坏帧已注入(点击取消)」。

关掉对齐、手抖拉到 8 像素,堆栈结果出现明显的拖影,窗户和月亮都带上了重影。

指标面板弹出红色提示,原文是:「未对齐警告 / 手抖 8px 未对齐 → 画面拖影 / 边缘锐度下降 20.7% / SNR 4.3 dB(单帧 -1.1 dB):多帧平均仍在降噪,模糊要看锐度」。

图 5:失败路径的现场,锐度从 21.7 掉到 17.74,SNR 却仍有 4.3 dB。

六、本地复现:三步点出这些数

第一步,打开 Demo Park 的案例目录,把 index.html 下载到本地。

整个应用就这一个文件,没有 npm,没有后端。

不想双击就在目录里跑 python -m http.server 8000,用 http://localhost:8000/index.html 打开。

第二步,双击文件,或者拖进浏览器。

页面打开后会自动生成场景、跑完 16 帧,并执行 8 项自检。

第三步,按顺序动三个控件,对照上面的数字。

帧数从 1 拉到 16,看 SNR 从 5.2 走到 16.6。

打开页面时的自检是 8/8 通过,改参数后会重跑。

把 ISO 拉到 6、帧数压回 1,SNR 会掉到负值,画面全是彩色噪点。

这是失败路径的第一种看法。

关掉对齐、手抖拉到 8,等红色警告出现,再看锐度那一栏。

这是失败路径的第二种看法,也是文章里那个 bug 的现场。

最后点一次「注入坏帧」,把算法切到中值,对比均值的读数。

三步走完,堆栈的收益和边界就都摸到了。

七、进入码道 Web 与准备环境

浏览器打开码道 Web 版:devcloud.cn-north-4.huaweicloud.com/chat?source...

页面本身不依赖码道。

下载 index.html 后双击即可运行,想走本地服务就在文件所在目录执行 python -m http.server 8000,再访问 http://localhost:8000。

八、使用码道体会

第一,验收标准要写进需求。

我在需求里明确要求「自检面板显示至少 8 条断言」,交付时页面就真的带了一块可点可验的区域。

第二,让页面把关键数字暴露出来。

曲线数据挂在 window.lastSnrData 上,写文章时直接读,不用靠眼睛估。

第三,实测和理论并排。

SNR 曲线旁边画了根号 N 参考线,读者一眼能看出实测和理论差多少。

第四,截图之后再提具体修改点。

第一版布局最大宽度只有 1200,在 1920 的窗口里空了一半,这是看截图才发现的。

第五,模型说「已完成」不算验收。

对齐那个 bug 是自检红了两条才暴露的,页面能跑和页面算得对是两件事。

第六,边界由人定。

哪些是模型、哪些是近似、哪些没做,写清楚比多堆一个功能更有用。

码道有三种使用方式:WebUI(浏览器对话)、TUI(终端命令行)和桌面 IDE(IDE 插件)。

本文用 WebUI 版演示。

九、边界与下一步

场景是画的,不是真照片。

真实夜景有材质、色温、镜头暗角和传感器热噪点,这些都没有建模。

噪声只有散粒和读出两层。

没有 PRNU、没有坏点、没有去马赛克,也没有相机里的降噪和锐化。

对齐只做平移,范围 ±8 像素。

旋转、透视、前景运动都没处理,真实手持会比这难。

中值是逐像素的时间中值,不是 AI 降噪。

它不会补细节,只会把偏离多数的值压下去。

手机夜景模式的完整链路还包括多曝光融合、色调映射和语义分割。

堆栈只是其中一层,不是全部。

下一步想加旋转对齐和真实照片上传。

前者能验证这套 SAD 在转动下还成不成立,后者能把合成场景换成真样张。

直接下载试玩

这个 Demo 已经完整开源(源代码、本文档、全部截图),托管在公开仓库,可直接下载来玩:

打开上面的链接,进入 codearts-night-mode-stacking-lab 文件夹,把 index.html 下载到本地,用浏览器打开即可运行,无需安装任何依赖。

总结

夜景模式不是魔法,是用两三秒的手持时间换信噪比。

帧数翻倍,噪声降 3 dB,这条曲线在页面里能自己跑出来。

前提是对齐。

对不准,平均出来的就是一张带重影的糊图,SNR 还可能骗你说没事。

把这间实验室留在手边,下次有人问「为什么夜景要举那么久」,直接把帧数拉到 16 给他看。

相关推荐
松仔log15 分钟前
Kotlin中级——callbackFlow
java·前端·kotlin
三小河15 分钟前
SWC 凭什么比 Babel 快 ?项目优化迁移踩坑记录
前端·后端
再吃一根胡萝卜34 分钟前
不改一行官方源码:用一个插件把 DeepSeek Harness 换成「平安one」
前端
涛涛ing1 小时前
2026年,你选的不再是框架,是渲染模型
前端
AiMan1 小时前
Perplexity还是不可导出左侧所有对话,只能单条内容,方法不对吗
前端
AiMan1 小时前
千问生成的内容能否下载吗?
前端
北城笑笑1 小时前
Vue 109 ,从零搭建 Vue3 + Electron 工程:生态、安全桥接与打包选型( Vue + Electron )
前端·javascript·vue.js·安全·electron·vue
JarvanMo1 小时前
Flutter 3.50 & Dart 3.14发布,一起看看有哪些变化!
前端
莪_幻尘2 小时前
4 个 3D 游戏,总体积 3.82MB:拆解 Claude 的 one-shot 工程链路
前端·ai编程·three.js