本文拿视频超分练手:把 720p 的旧视频、或者低清视频流,在手机上实时放大到 1080p 甚至 4K。端侧超分的难点不在效果,在「一帧只有 16ms 预算」。
1、你遇到的场景
“直播流的源只有 480p,在大屏上糊成一团。想上超分,云端方案要上传每帧画面,流量和隐私都扛不住。找个现成模型,手机上一跑 300ms/帧,直接卡成 PPT……”
端侧超分的核心矛盾:
- 算力 vs 画质 — 大模型效果好但慢,小模型快但糊
- 实时预算 — 30fps 一帧 33ms,留给超分的只剩 10ms 左右
- 模型体积 — 好的超分模型动辄几十 MB,App 体积压力大
2、端侧超分的两条路线
| 路线 | 代表 | 参数量 | 帧耗时(iPhone15) | 效果 | 适用 |
|---|---|---|---|---|---|
| 轻量 CNN | ESPCN / FSRCNN | 5~50K | 3~8ms | 中等 | 实时视频 |
| 重 SR | SRGAN / ESRGAN | 1M+ | 100ms+ | 好 | 离线照片 |
| 高效重 SR | Real-ESRGAN-small | 300K | 15~30ms | 较好 | 半实时 |
核心洞察: 视频超分和照片超分是两个物种。视频能利用时域连续性(前后帧信息),且对单帧瑕疵容忍度高——所以用轻量 CNN + 帧间复用,比堆大模型更划算。
端侧视频超分管线:
低清帧(480p) → 预处理(YUV→RGB,归一化) → SR 模型(×2) → 后处理 → 1080p 帧
↑ ↓
复用上一帧光流/残差 送编码器
3、选一个能实时跑的 SR 模型
# 训练侧:用 ESPCN 做基线(×2 超分,亚像素卷积,轻量)
# super_resolution.py(Claude Code 生成)
import torch
import torch.nn as nn
class ESPCN(nn.Module):
"""亚像素卷积超分:先卷积升通道,再 pixel shuffle 重排上采样"""
def __init__(self, scale=2):
super().__init__()
self.conv1 = nn.Conv2d(3, 64, 5, padding=2)
self.conv2 = nn.Conv2d(64, 32, 3, padding=1)
self.conv3 = nn.Conv2d(32, 3 * scale * scale, 3, padding=1)
self.shuffle = nn.PixelShuffle(scale)
def forward(self, x):
x = torch.relu(self.conv1(x))
x = torch.relu(self.conv2(x))
return self.shuffle(self.conv3(x))
# 参数量才 20K,导出后 INT8 量化只有 ~100KB,天然适合端侧
导出 + 量化:
# ONNX → CoreML(iOS,FP16 走 ANE)
python -c "
import coremltools as ct
m = ct.convert('espc.onnx', minimum_deployment_target=ct.target.iOS16,
compute_precision=ct.precision.FLOAT16)
m.save('espc.mlpackage')
"
# ONNX → TFLite(Android,INT8)
# 量化后 20K 参数的模型几乎无损
4、端侧推理:复用引擎,逐帧喂入
关键优化:不要每帧新建 interpreter,只换输入 buffer。
// SuperResolution.swift (iOS)
// 视频超分:复用 CoreML 引擎,逐帧上采样
final class SuperResolver {
private let engine: MLInferenceEngine // 上篇的统一抽象
private let scale: Int
init(scale: Int = 2) throws {
self.scale = scale
self.engine = try CoreMLEngine(bundle: "espc")
}
/// 输入低清帧 RGBA,输出高清帧 RGBA
func upscale(_ lowRes: CVPixelBuffer) -> CVPixelBuffer? {
// 1. CVPixelBuffer → MLTensor(归一化 0~1,NHWC)
let tensor = Self.tensorFrom(pixelBuffer: lowRes)
// 2. 推理(同步,16ms 预算内)
guard let out = try? engine.predict(tensor) else { return nil }
// 3. MLTensor → CVPixelBuffer(反归一化,写回 8bit)
return Self.pixelBufferFrom(tensor: out, scale: scale)
}
}
// SuperResolver.kt (Android)
// 视频超分:SurfaceTexture 回调里逐帧处理
class SuperResolver(private val engine: MLInferenceEngine) {
// onFrame 来自 Camera2/MediaCodec 回调,跑在 GL 或专用线程
fun onFrame(lowRes: ByteBuffer, out: ByteBuffer): Boolean {
val input = MLTensor(intArrayOf(1, H, W, 3), lowRes)
val result = engine.predict(input)
result.data.rewind()
result.data.get(out) // 拷贝到预分配的输出 buffer
return true
}
companion object {
private const val W = 480
private const val H = 270
}
}
5、帧间复用:用上一帧省算力
视频相邻帧高度相似。高级玩法:只对「变化区域」做超分,静止区域复用上一帧结果。
帧 t-1: ┌──────────────┐
│ 静止区域 │ ← 复用 t-1 的超分结果
│ ┌────┐ │
│ │变化│ ← 只对这格跑 SR
│ └────┘ │
└──────────────┘
# 简单版:帧差检测(Claude Code 生成)
# 低清帧之间做像素差,超过阈值的块才进 SR
def change_mask(prev_gray, cur_gray, block=16, thr=8.0):
import numpy as np
H, W = prev_gray.shape
mask = np.zeros((H // block, W // block), dtype=np.bool_)
for by in range(H // block):
for bx in range(W // block):
b0 = prev_gray[by*block:(by+1)*block, bx*block:(bx+1)*block]
b1 = cur_gray[by*block:(by+1)*block, bx*block:(bx+1)*block]
if np.abs(b0.astype(float) - b1.astype(float)).mean() > thr:
mask[by, bx] = True
return mask # True = 该块需要重新超分
6、踩坑记录
| # | 问题 | 现象 | 根因 | 修复 |
|---|---|---|---|---|
| 1 | 越跑越卡 | 前几秒流畅后掉帧 | 每帧 new Interpreter 内存抖动 | 复用 interpreter + 预分配 buffer |
| 2 | 颜色发白 | 超分后画面灰蒙蒙 | 归一化/反归一化范围不对 | 训练与推理用同一套 [0,1] 约定 |
| 3 | 首帧黑屏 | 第一帧无输出 | 模型未 warmup | 启动时跑一次空输入 warmup |
| 4 | 绿边/伪影 | 物体边缘有颜色溢出 | INT8 量化损失 + 无校准 | 校准集量化,边缘任务用 FP16 |
| 5 | ANE 不工作 | 一直 CPU 跑 | 动态 shape | 固定输入 shape 到 mlpackage |
| 6 | 放大 4x 崩 | 2x 正常 4x 崩 | 4x 计算量平方级增长 | 4x 拆成两次 2x 级联 |
学习和提升音视频开发技术,欢迎你加入我们的知识星球

版权声明:本文内容转自互联网,本文观点仅代表作者本人。本站仅提供信息存储空间服务,所有权归原作者所有。如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至1393616908@qq.com 举报,一经查实,本站将立刻删除。