夜里举起手机,画面先是一层雪花,过一会儿才慢慢变亮。
手机没有把 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 已经完整开源(源代码、本文档、全部截图),托管在公开仓库,可直接下载来玩:
- Demo Park 仓库:gitcode.com/OPCTeam/dem...
- 本案例目录:gitcode.com/OPCTeam/dem...
打开上面的链接,进入 codearts-night-mode-stacking-lab 文件夹,把 index.html 下载到本地,用浏览器打开即可运行,无需安装任何依赖。
总结
夜景模式不是魔法,是用两三秒的手持时间换信噪比。
帧数翻倍,噪声降 3 dB,这条曲线在页面里能自己跑出来。
前提是对齐。
对不准,平均出来的就是一张带重影的糊图,SNR 还可能骗你说没事。
把这间实验室留在手边,下次有人问「为什么夜景要举那么久」,直接把帧数拉到 16 给他看。