066、多尺度超分:从单尺度到任意尺度超分的理论与实现

📅 2026/7/6 17:01:55
066、多尺度超分:从单尺度到任意尺度超分的理论与实现
066、多尺度超分从单尺度到任意尺度超分的理论与实现一、一个让我熬夜三天的bug去年做工业质检项目客户要求把200×200的芯片显微图放大到任意尺寸——不是2倍、3倍这种整数倍而是1.73倍、2.41倍这种带小数的。我一开始天真地想先训练个×2模型再插值到目标尺寸不就完了结果测试时发现放大到1.73倍时边缘出现严重的锯齿像被狗啃过一样。后来翻论文才发现传统超分模型只能处理固定整数倍遇到非整数倍就露怯。这个坑让我意识到单尺度超分在真实场景中就是个玩具。工业界要的是“你给我任意尺寸我都能给你高清放大”的能力。二、单尺度超分为什么不够用先看最基础的SRCNN、EDSR这类模型。它们训练时固定了上采样倍数——比如只训练×2、×3、×4三个模型。部署时如果用户要×2.5常见做法是先×2再双三次插值到×2.5或者先双三次插值到×2.5再送进×2模型这两种做法都有问题。第一种模型只负责×2剩下的靠插值插值本身会引入模糊。第二种输入尺寸和训练时不一致模型没见过这种分布输出可能崩掉。我踩过的坑是第二种——把1.73倍的图直接塞进×2模型结果输出出现了棋盘格伪影。后来查代码发现模型内部的上采样层比如PixelShuffle要求输入尺寸能被放大倍数整除1.73倍根本没法整除只能paddingpadding后特征图边缘全是噪声。三、多尺度超分的核心思路多尺度超分要解决的核心问题就一个如何让一个模型学会任意倍数的上采样。目前主流思路分两派1. 隐式表示派Meta-SR、LIIF这类方法把图像看成连续函数。比如LIIFLearning Continuous Image Representation的做法训练一个特征提取器把低分辨率图变成特征图再训练一个“隐式函数”这个函数输入“特征图上的坐标”和“目标分辨率下的坐标”直接输出该位置的像素值。别这样写代码# 错误示范硬编码放大倍数defforward(self,x,scale_factor):ifscale_factor2:returnself.upsample2(x)elifscale_factor3:returnself.upsample3(x)# 这里踩过坑scale_factor2.5时直接报错正确做法是让模型接收坐标作为输入# 正确做法坐标驱动defforward(self,lr_feat,hr_coords):# hr_coords: [B, H*W, 2] 目标图像所有像素的归一化坐标# 对每个坐标从lr_feat中采样特征featuresself.grid_sample(lr_feat,hr_coords)# 用MLP预测RGB值rgbself.mlp(features,hr_coords)returnrgb这里有个坑坐标归一化时一定要把[0,1]区间映射到[-1,1]因为grid_sample默认用[-1,1]范围。我第一次写的时候忘了这茬结果采样出来的特征全是边界值模型直接崩了。2. 动态滤波器派ArbSR、D2C-SR这类方法更暴力根据目标放大倍数动态生成上采样卷积核。比如ArbSR的做法先预测一个“核预测图”每个位置对应一个上采样核然后用这个核对低分辨率特征做局部卷积。代码实现时要注意# 动态生成上采样核defgenerate_kernel(self,scale_factor,feat):# scale_factor是标量但需要广播到每个位置scale_embedself.scale_embedding(scale_factor)# [1, C]scale_embedscale_embed.expand(feat.size(0),-1)# [B, C]# 这里踩过坑忘记expand会导致维度不匹配kernelself.kernel_predictor(feat,scale_embed)returnkernel# [B, K*K, H, W]动态滤波器派有个隐藏问题放大倍数变化时上采样核的尺寸也要变。比如×2时用4×4核×3时用6×6核。如果核尺寸固定大倍数时感受野不够小倍数时又浪费计算量。四、实战从零搭建任意尺度超分模型我推荐用LIIF的思路因为实现简单且效果稳定。下面给出核心代码片段注意注释里的坑。1. 特征提取器用EDSR的残差块classEDSRFeatureExtractor(nn.Module):def__init__(self,n_resblocks16,n_feats256):super().__init__()self.headnn.Conv2d(3,n_feats,3,padding1)# 残差块堆叠这里省略具体实现self.bodynn.Sequential(*[ResBlock(n_feats)for_inrange(n_resblocks)])self.tailnn.Conv2d(n_feats,n_feats,3,padding1)defforward(self,x):xself.head(x)resself.body(x)xxres# 全局残差连接别漏了returnx# 输出特征图尺寸和输入一致2. 隐式函数核心部分classLIIF(nn.Module):def__init__(self,n_feats256,hidden_dim256):super().__init__()# 用MLP把特征和坐标映射到RGBself.mlpnn.Sequential(nn.Linear(n_feats2,hidden_dim),# 特征坐标nn.ReLU(),nn.Linear(hidden_dim,hidden_dim),nn.ReLU(),nn.Linear(hidden_dim,3),nn.Sigmoid()# 输出归一化到[0,1])defforward(self,feat,coord,cell): feat: [B, C, H, W] 低分辨率特征图 coord: [B, N, 2] 目标图像所有像素的坐标归一化到[-1,1] cell: [B, N, 2] 每个像素的“感受野大小”用于控制局部性 # 第一步从feat中采样特征# coord需要reshape成grid格式gridcoord.unsqueeze(2)# [B, N, 1, 2]# 这里踩过坑grid_sample要求输入是[B, C, H, W]和[B, N, 1, 2]sampled_featF.grid_sample(feat,grid,modebilinear,align_cornersFalse)sampled_featsampled_feat.squeeze(3).permute(0,2,1)# [B, N, C]# 第二步拼接特征和坐标inptorch.cat([sampled_feat,coord,cell],dim-1)# [B, N, C4]# 第三步MLP预测RGBrgbself.mlp(inp)# [B, N, 3]returnrgb这里有个容易被忽略的点cell参数。LIIF论文里说cell控制局部性但实际实现时如果cell设置不当模型会学成全局平均。我试过把cell设成常数结果输出全是模糊一片。正确做法是把cell也作为可学习的输入或者用目标分辨率下的像素间距。3. 训练时的采样策略训练时不能把所有像素都算loss否则显存爆炸。正确做法是随机采样deftrain_step(lr_img,hr_img,scale_factor):# lr_img: [B, 3, H, W]# hr_img: [B, 3, sH, sW]# 提取特征featextractor(lr_img)# [B, C, H, W]# 随机采样N个像素N1024# 每个batch采1024个点B,_,sH,sWhr_img.shape# 生成随机坐标coords_ytorch.randint(0,sH,(B,N))coords_xtorch.randint(0,sW,(B,N))# 归一化到[-1,1]coordstorch.stack([coords_x/(sW-1)*2-1,coords_y/(sH-1)*2-1],dim-1)# [B, N, 2]# 计算cell每个像素的局部范围celltorch.ones_like(coords)*(1.0/scale_factor)# 预测pred_rgbliif(feat,coords,cell)# [B, N, 3]# 采样GTgt_rgbhr_img[:,:,coords_y,coords_x].permute(0,2,1)# [B, N, 3]# losslossF.l1_loss(pred_rgb,gt_rgb)returnloss注意采样坐标时一定要用randint而不是rand因为grid_sample要求坐标在[-1,1]范围内如果用rand生成[0,1]再映射边界处会出问题。五、推理时的任意尺度实现训练完成后推理时想放大到任意尺寸只需要改变坐标的密度definference(model,lr_img,target_h,target_w):# lr_img: [1, 3, H, W]featmodel.extractor(lr_img)# [1, C, H, W]# 生成目标分辨率下的所有坐标grid_y,grid_xtorch.meshgrid(torch.linspace(-1,1,target_h),torch.linspace(-1,1,target_w))coordtorch.stack([grid_x,grid_y],dim-1).unsqueeze(0)# [1, H, W, 2]coordcoord.view(1,-1,2)# [1, N, 2]# cell根据目标分辨率计算celltorch.ones_like(coord)*(1.0/max(target_h,target_w))# 预测所有像素withtorch.no_grad():rgbmodel.liif(feat,coord,cell)# [1, N, 3]# reshape回图像hr_imgrgb.view(1,target_h,target_w,3).permute(0,3,1,2)returnhr_img这里有个性能问题如果target_h和target_w很大比如4K分辨率coord矩阵会非常大几百万个点显存直接爆掉。实际部署时我采用分块推理definference_chunk(model,lr_img,target_h,target_w,chunk_size100000):featmodel.extractor(lr_img)# 生成坐标grid_y,grid_xtorch.meshgrid(...)coordtorch.stack([grid_x,grid_y],dim-1).unsqueeze(0)coordcoord.view(1,-1,2)# 分块处理Ncoord.size(1)rgb_chunks[]foriinrange(0,N,chunk_size):chunk_coordcoord[:,i:ichunk_size,:]chunk_celltorch.ones_like(chunk_coord)*(1.0/max(target_h,target_w))chunk_rgbmodel.liif(feat,chunk_coord,chunk_cell)rgb_chunks.append(chunk_rgb)rgbtorch.cat(rgb_chunks,dim1)# reshape...六、个人经验与建议别迷信论文里的PSNR多尺度超分论文在Set5、Set14上刷PSNR但真实场景下非整数倍放大时PSNR会掉0.5-1dB。我测试过×2.0时PSNR 36.5×2.17时只有35.8。客户更关心视觉质量PSNR差0.5他们看不出来但锯齿和模糊一眼就能发现。训练时一定要做尺度增强不要只训练整数倍。我训练时随机采样1.0到4.0之间的倍数每个batch的scale_factor都不同。这样模型才能学会处理任意倍数。cell参数的设计很关键LIIF原论文用1/scale作为cell但我发现对于大倍数4这个值太小导致局部性太强输出有块状感。后来改成1/sqrt(scale)效果更好。这个需要根据你的数据调。推理速度是硬伤隐式表示方法推理时每个像素都要过MLP速度比传统超分慢一个数量级。如果做实时应用建议用动态滤波器派或者用ONNXTensorRT优化。最后一条血的教训部署到移动端时grid_sample算子可能不支持。我踩过这个坑——模型在PC上跑得好好的转到手机端直接报错。解决方案是手动实现双线性采样或者用ONNX的Resize算子替代。多尺度超分不是银弹但确实是解决真实场景下“任意尺寸放大”问题的唯一可行方案。如果你还在用固定倍数的模型建议尽快升级——客户不会告诉你他要放大1.73倍但当你拿不出方案时他会告诉你“隔壁团队能做到”。