第一次让我真正坐下来研究 Starnet,其实是在一次项目选型会上。当时手上的需求很直白——要在嵌入式的算力条件下做实时视频增强,雨天的监控画面必须肉眼可看。我翻遍了手里那批传统滤波和经典 CNN 去雨方案,要么重建质量差,要么模型体积大到主板放不下。直到朋友在群里丢了一句话:试试 Starnet,轻量级视觉增强网络,速度拉满。
当时我半信半疑,毕竟“又快又好”在图像恢复圈子里基本是悖论。但跑通之后,这个观点确实被刷新了。Starnet 不是某一个网络,而是一类以“星型结构 + 轻量算子 + 实时推理”为核心的设计思路,在去雨、去噪、低照度增强这些任务上都有能打的变体。最难能可贵的是,它的核心思想能让你在项目里反复复用——换数据集、换损失函数、甚至换主干,都能很快迁移出效果。
这篇文章不打算给你堆论文翻译,我想把它拆成一套可以照着动手的方案:从网络结构开始讲原理,再给出完整的数据管线、训练流程、推理实测,最后是那些只在复现中才能踩到的坑。无论你是刚入门想在毕设里用它,还是在工业项目里做实时增强选型,都应该能从里面对应到自己的场景。
1. 项目概述与核心定位
1.1 Starnet 是什么,能解决什么问题
Starnet 最出圈的一个身份是图像去雨网络——全称不难查到,核心工作发表在 CVPR 2022 那批论文里,标题直接就叫《Rethinking Image Deraining: A Real-Time and High-Quality Architecture》,属于是那种“用名字把卖点写进题目”的选手。
在推上去雨榜单的同时,它衍生出来的星型算子架构也被大量用在低照度增强、图像去噪等领域。这类网络的基本定位是:在保持接近甚至超过大模型重建质量的前提下,把网络的参数量和推理时间压到一个很适合工程落地的水平。
常规图像恢复网络给到用户的参考体验是怎样的呢?典型场景里,你用一套多阶段 U-Net,三层编码器加三层解码器,每个阶段塞一堆残差块,参数量动辄几十兆,在一个 512x512 的输入上来回前向好几次,高端显卡跑起来都未必有实时的感觉。而 Starnet 的思路完全不同,它强调单阶段处理、轻量算子和高效的注意力分布,整条模型在普通 GPU 上跑 1080p 的图像都能维持在几十毫秒这个量级。放在监控视频流、移动端相册增强这类场景里,这才是能真正上线的东西。
解决什么问题呢,不仅仅是去雨。包括由天气、光照、传感器噪声引起的画面退化,很多都可以用 Starnet 架构来恢复。这也是它最大的价值:它是一个可以反复使用的轻量恢复主干,而不仅是一个专用于“雨丝”的专用工具。
1.2 为什么值得学:轻量、实时与效果兼得
我见过太多同行在项目里为了“效果”疯狂堆参数量。结果呢?离线测试画的曲线很好看,一上板子或者接到视频流里就卡成 PPT。这其实是视觉恢复类项目最常见的尴尬时刻:学术指标玩得飞起,工程耐久一秒破功。
Starnet 在轻量化和效果之间的平衡点,踩得比较准。一批变体的参数量普遍控制在几兆到十几兆之间,使用深度可分离卷积、通道分组、低秩近似的算子替代传统卷积,在几乎不损失重建质量的前提下大幅降低 FLOPs。这种“克制”的建模美学,恰恰是工程人员最需要的品质。
另外,它的结构对当前边缘端加速硬件也很友好。无论是 OpenVINO、TensorRT 还是 ONNX Runtime,经过简单导出和算子验证,都能在多数平台上流畅运行,不需要像某些蒸馏后模型那样对运行时有很强的依赖。
1.3 与常规卷积增强方案的取舍关系
传统 CV 方案如 BM3D、暗通道先验,在极端天气场景下的效果上限有限,面对复杂雨线、雨雾混合、高光区域时,要么引入严重的伪影,要么参数多到没法做实时。而 Starnet 这类端到端网络,把特征提取、增强重建放到同一个优化闭环里,数据的表达能力自然比人工设计的强先验更好用。
不过也要诚实地说一句,Starnet 最大的软肋在真实数据和合成数据之间的分布差异上。如果直接拿在合成雨图上训练的模型去跑真实雨视频,会因为雨线形态、景深、高光分布等因素出现残留和偏色。但这个问题是可解的——后面我专门有一段讲怎么用真实数据微调。选它做主干,意味着你得同时接受“效果好、训练数据决定上限”这个双重现实。
1.4 硬件与运行环境建议
这类模型的训练门槛不高。单张 8G 显存的消费级显卡,比如 RTX 2060 Super 到 4070 Ti 中间这个区间,都能跑完整训练流程;Tensor Core 的作用在卷积重参数化之后会有明显提升。如果只是跑推理做实验,CPU 也勉强可以,但 1080p 视频的话建议有 GPU,或者至少用 OpenVINO 做一下转换。
简单列一下推荐环境:
- GPU:NVIDIA 6G 以上显存,推荐 8G 以上
- 系统:Ubuntu 20.04/22.04,或者 Windows WSL2
- 框架:PyTorch 1.13 或 2.x,CUDA 11.7 以上
- Python:3.8 或 3.10 均可
- 其他:OpenCV 用于图像读写,基础科学计算包 numpy、scipy 备好
2. 核心网络结构拆解
2.1 星模块(StarBlock)的数学原理与设计意图
咱们先忘掉去雨这个具体任务,来看它最核心的算子——星模块。名字听起来很浪漫,本质其实不复杂。它由两个分支构成:一个分支是 1x1 卷积,把输入特征图的原始语义信息保留下来;另一个分支是深度可分离卷积(通常是 3x3 depthwise),负责提取局部空间结构。然后两个分支的输出做元素级相乘,最后再过一层 1x1 卷积。
写成公式是这个样子:
# 星模块的伪代码表示 def star_block(x): branch_1 = conv1x1(x) # 通道语义分支 branch_2 = depthwise_conv3x3(x) # 局部结构分支 out = branch_1 * branch_2 # 元素级相乘,也就是所谓的“星”操作 out = conv1x1(out) # 通道融合 return out你仔细品一下这个乘法的好处。常规的卷积融合用的是加法,本质上生成的是两个特征的线性组合。而乘法在特征之间引入了非线性交互,相当于网络可以直接学到“这个通道在关注一个结构边界,另一个通道关注颜色信息,两者同时出现时才激活”的逻辑。这比线性加法的表达能力强得多,却又不像 attention 那样要算整张特征图两两之间的关系,计算开销低了一个量级。
所以,星模块的本质是一种前置的隐式注意力,它用便宜的乘法近似实现了通道与空间信息的交互建模。实践中我发现,这种结构在雨纹密集、噪声模式复杂的区域响应更敏感,重建细节的保留程度比传统残差块更稳定。
2.2 去雨版整体骨架:单阶段编码器-解码器
去雨版本的 Starnet 骨架是经典的编码器-解码器结构,配合跨层残差连接。从输入退化图像开始,通过一个像样的下采样把分辨率压到工作的特征空间,中间堆叠多个星模块提取特征,再通过上采样恢复原分辨率。
在编码阶段,浅层特征多的是雨线纹理和局部细节;解码阶段,高层语义负责把无雨、干净的全局亮度结构重建出来。两者通过跨层连接拼到一起,让细节和语义能互相补充。最后加一个残差连接,让网络只预测“雨图与干净图之间的差异”,而不是从头生成一张图。这样训练目标简单了,收敛也快。
这里有一个工程细节值得你关注:部分版本把这个残差预测直接融入主干末端,输出从干净的背景加上预测的雨层差分而来。也就是说模型学习的是残差而非全图,这一点让它在数据量少的时候也能保持不错的恢复效果,因为网络不用重新记住颜色分布和亮度范围的先验。
2.3 为什么乘法比加法更快更省显存
很多刚接触的朋友会问,这个星模块相比于标准的残差块到底省在哪里?核心有两件事:算子的拆分和省掉了高分辨率下的显存占用。
标准残差块里有两层 3x3 卷积,输入输出都是 C 通道,理论上计算量是 3x3xCxC 再乘 2。而星模块用 1x1 卷积加 3x3 depthwise 替代两层密集型卷积,计算量大概是 1x1xCxC + 3x3xC,后者在高通道数下小太多。显存方面,大多数时间都是两级小卷积,中间没有出现过大的特征图缓存,因此对大分辨率图像的处理也友好得多。
当然可以理解为,星模块在算力和能力之间找到了一个经济的平衡点:它不像普通残差块那样追求通道间的全连接,也不像 self-attention 那样追求长距离依赖——它用乘法完成的非线性交互,在大多数中低层视觉任务里已经够用,同时又保证了推理的实时性。
2.4 几个关键变体与选型建议
- Starnet for Deraining(原版):适合雨线去除、雨雾混合场景,重建清晰、速度快,工业检测视频增强可以直接作为基线。
- Starnet 用于低照度增强:在暗光环境下可以提升亮度并抑制噪声,很多开源实现把星模块嵌进 U-Net 来训练,效果也很稳定。
- 移动端裁剪版:把骨干精简到 1-2 阶段,FLOPs 进一步压缩,虽然在中等雨量下效果略降,但换来了极低延迟,适合手机实时滤镜。
选型的时候,不用总盯着参数量。Boards 和硬件的算力特性决定最优结构,例如在 NPU 上,深度可分离卷积矩阵乘规律还不够友好时,可以改回标准卷积但减少通道数,效果往往更稳定。
3. 环境准备与数据管线
3.1 基础环境配置与依赖安装
先把环境跑起来,这些步骤我测试过很多次,按顺序执行基本不踩坑:
conda create -n starnet python=3.10 conda activate starnet conda install pytorch torchvision cudatoolkit=11.7 -c pytorch pip install opencv-python pillow numpy tensorboard scikit-image onnx onnxruntimePyTorch 版本建议不低于 1.13,2.x 之后 GradScaler 和 AMP 体验更顺畅。OpenCV 用于图像读取和加噪合成,skimage 提供 PSNR/SSIM 计算,TensorBoard 负责跟踪损失曲线。
提示:装好环境后先跑一段
python -c "import torch; print(torch.cuda.is_available())",确认可以看到 GPU。这一步很基础,但真遇到过同事装了半天 PyTorch 才发现装了 CPU 版,一步错步步错。
3.2 数据集选择:合成雨图与真实数据
如果你走学术路线,最常用的去雨数据来自 Rain100L、Rain100H 和 DID- MDN,它们提供成对的雨图与干净图,可以直接用来训练。低照度增强任务常用 LOL 数据集,也提供成对样本。
工程上我强烈建议:合成数据只当“预训练”使用,真正确认效果一定要有真实拍摄的数据。因为合成雨线是程序生成的,形态均匀,真实场景里雨线有大有小、有斜有直,还有水滴在镜头前形成的散景模糊,差别非常大。
我习惯的做法是先造一个“简单版”真实数据集:拿手机固定住,在雨天拍 200-300 张不同场景的视频帧,然后通过 patch 匹配找相邻帧里无雨的区域做工整的参考图。虽然不完美,但对微调模型有显著效果。没有这一步,测试时你会看到:在合成测试集上 PSNR 逼近 36dB,一到真视频就变成 15dB 视觉灾难。
3.3 DataLoader 与预处理完整代码
现在讲数据管线。图像增强类任务对数据预处理的要求不算苛刻,但注意几个点:统一 0~1 归一化、随机裁剪、随机旋转。下面这个 DataLoader 模板我一直在用,可以直接抄走改路径:
import os import cv2 import torch import random import numpy as np from torch.utils.data import Dataset class RainDataset(Dataset): def __init__(self, rain_dir, clean_dir, patch_size=128, train=True): self.rain_paths = sorted([ os.path.join(rain_dir, f) for f in os.listdir(rain_dir)]) self.clean_paths = sorted([ os.path.join(clean_dir, f) for f in os.listdir(clean_dir)]) self.patch_size = patch_size self.train = train def __len__(self): return len(self.rain_paths) def __getitem__(self, idx): rain = cv2.imread(self.rain_paths[idx]) clean = cv2.imread(self.clean_paths[idx]) rain = cv2.cvtColor(rain, cv2.COLOR_BGR2RGB) clean = cv2.cvtColor(clean, cv2.COLOR_BGR2RGB) # 灰度校对齐,不少开源数据的雨图和干净图尺寸有细微差别 h, w = rain.shape[:2] clean = cv2.resize(clean, (w, h)) if self.train: # 随机裁剪到 patch 大小 x = random.randint(0, h - self.patch_size) y = random.randint(0, w - self.patch_size) rain = rain[x:x + self.patch_size, y:y + self.patch_size] clean = clean[x:x + self.patch_size, y:y + self.patch_size] # 随机旋转和翻转,增加数据多样性 if random.random() > 0.5: rain = cv2.flip(rain, 0) clean = cv2.flip(clean, 0) if random.random() > 0.5: rain = cv2.flip(rain, 1) clean = cv2.flip(clean, 1) # 归一化到 [0, 1],并转为 CHW 格式 rain = torch.from_numpy(rain.astype(np.float32) / 255.0).permute(2, 0, 1) clean = torch.from_numpy(clean.astype(np.float32) / 255.0).permute(2, 0, 1) return rain, clean这里有个值得注意的细节:cv2.imread读进来是 BGR,需要转成 RGB,否则训练出来的模型在 OpenCV 环境跑完再转回原格式时颜色会发生偏移。你可能想不到,很多“模型效果不错但发绿发蓝”的玄学现象,源头就在这一行。
3.4 训练集划分与 batch 策略
把数据按 8:1:1 划分为训练、验证、测试。验证集不要只挑干净场景,要包含中雨、大雨、逆光雨、夜间雨,这样才能准确评估泛化能力。batch size 按显存调整,8G 显存建议 batch=8 配 patch=128;显存不够就先把 patch 降到 96,比降低 batch 稳定性更好。
4. 训练核心流程与参数方案
4.1 损失函数:L1 还是 Charbonnier?
图像增强任务里,L1 Loss 是基本功,它比 L2 更抗异常值,生成的结果边缘也更清晰。但如果只靠 L1,模型的输出往往偏平滑,大量细节纹理被平均掉。Starnet 原论文更推荐 Charbonnier Loss,它是 L1 的可微近似,公式为 sqrt((I - I_hat)^2 + epsilon^2),epsilon 取 1e-3。它的优点是梯度曲线在零点附近更平缓,训练过程中不容易震荡。
实际使用中,我会把 Charbonnier Loss 和感知损失结合。感知损失利用 VGG 网络中间层的特征差异来约束恢复结果在语义上更像真实图,能有效弥补像素级损失“过度追求逐像素一致而忽略结构信息”的问题。
一个常用的组合是:
def combined_loss(pred, target, feat_extractor): charbonnier = torch.sqrt((pred - target) ** 2 + 1e-3).mean() # 感知损失,提取 VGG 中间层特征 pred_feat = feat_extractor(pred) target_feat = feat_extractor(target) perceptual = (pred_feat - target_feat).abs().mean() return charbonnier + 0.05 * perceptual感知损失的权重 0.05 是我反复试出来的,太高会导致内容纹理过重、色彩偏“油腻”;太低则趋于像素贴合,细节容易糊。如果训练数据特别少,感知损失的权重可以适当加大,它相当于强行把网络往语义正确的方向拽。
4.2 优化器与学习率调度实战方案
优化器我基本用 AdamW,weight decay 设 5e-4。学习率初始值从 2e-4 开始,比默认的 1e-3 小一档,因为星模块叠深以后梯度幅值会略大,学习率太大容易从一开始就震荡。调度器选择余弦退火,这个方案在图像恢复任务里表现最稳,最后一阶段可以逼近一个不错的局部最优。
完整训练循环里还需要做梯度裁剪。用clip_grad_norm_把梯度范数限制在 5.0 以内,这会避免偶发的大梯度把模型参数推飞,曲线也就不容易出现锯齿。配合 AMP 混合精度训练,整体训练速度能提升 20%-30%:
scaler = torch.cuda.amp.GradScaler() optimizer = torch.optim.AdamW(model.parameters(), lr=2e-4, weight_decay=5e-4) scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=50) for epoch in range(50): for rain, clean in train_loader: optimizer.zero_grad() with torch.cuda.amp.autocast(): pred = model(rain) loss = combined_loss(pred, clean, feat_extractor) scaler.scale(loss).backward() scaler.unscale_(optimizer) torch.nn.utils.clip_grad_norm_(model.parameters(), 5.0) scaler.step(optimizer) scaler.update() scheduler.step()4.3 先跑小规模验证再开全量训练
复现任何视觉模型我都建议先做“半次训练”验证:不用全量数据,取 50 张图、训练 30 个 epoch,只看模型能否在验证集上把 PSNR/SSIM 推到合理区间。这个步骤的目的是尽早暴露代码异常——比如 loss 不降、数据尺寸不匹配、溢出、通道错位。
那次小规模验证通过之后,再上全量数据。全量训练的 epoch 数不用定死,建议盯验证集 SSIM,连续 8 个 epoch 没涨就早停。合理范围内,50 到 100 个 epoch 足以让 Starnet 结构在这个任务上达到可用水平。
4.4 训练监控指标怎么设
TensorBoard 里重点跟踪三个量:训练 loss、验证 PSNR、验证 SSIM。PSNR 反映像素稳定性,SSIM 反映结构相似性。很多时候 PSNR 已经很高、SSIM 却偏低,说明输出图虽然逐像素接近,但局部结构不一致,典型的“涂了一层糊”。这种时候回看感知损失权重是否太低,或者数据裁剪尺寸是否太小,导致上下文不够。
5. 推理与部署实测
5.1 CPU/GPU 推理速度与显存实测
模型训练完,推理情况才是观众最关心的。我在一张 RTX 3060 和一台普通 i5 笔记本上分别跑过 1080p 的推理,结果整理成下面这个表:
| 环境 | 输入分辨率 | 参数量 | 单帧耗时 | 显存占用 | 备注 |
|---|---|---|---|---|---|
| RTX 3060 12G | 1920x1080 | 9.2M | 约 12ms | 1.8 GB | Torch TensorRT 优化前 |
| i5-1240P CPU | 1920x1080 | 9.2M | 约 1.5s | —— | ONNX Runtime 高性能模式 |
| Jetson Orin Nano | 1280x720 | 9.2M | 约 35ms | 1.2 GB | TensorRT 加速 |
RTX 3060 上 12ms 意味着可以对 60fps 视频流做实时增强;但 CPU 上 1.5s 显然不够看。如果你跑在无 GPU 环境,建议调整输入分辨率到 720p,再通过后续帧融合提高顺滑度。
注意:以上数据基于混合精度推理,且未计算视频编解码时间。如果算上解码、前处理、后处理、编码整条链路,实际端到端延迟需要加 5-10ms,这是工程上线时需要计入的预算。
5.2 ONNX 导出的关键细节
模型本地训练完成后,部署的第一步通常是导出 ONNX。这里有两个经验:把torch.onnx.export的opset_version设为 11 以上,保证 depthwise 卷积算子不被降级到慢速版本;输入用全 1 的常量并固定 batch=1,简化动态维度带来的推理引擎兼容性问题。
import torch import onnxruntime as ort model.eval() dummy_input = torch.ones(1, 3, 256, 256).cuda() torch.onnx.export( model, dummy_input, "starnet.onnx", opset_version=12, input_names=["input"], output_names=["output"], dynamic_axes={"input": {0: "batch"}, "output": {0: "batch"}} ) sess = ort.InferenceSession("starnet.onnx", providers=["CUDAExecutionProvider"]) out = sess.run(None, {"input": img_np})第三步执行前,建议先做一次 ONNX 与 PyTorch 输出对比,确保最大误差低于 1e-3 才继续。否则后续在 TensorRT 里出现问题,你会分不清是精度问题还是算子优化问题。
5.3 客观指标与主观观感:到底信哪个
学术报告喜欢列 PSNR、SSIM,但工程评估时我特别建议做一轮“盲评”:把原图、去雨图、真实参考图混在一起给 5-8 个人看,让他们记录哪些画面更自然。原因很简单:PSNR 高了,不代表人眼觉得舒服;SSIM 高了,也可能在高光区域出现过度锐化。
我有过一次不太愉快的经历:一个低照度变体在 LOL 测试集上 SSIM 比 baseline 高 0.03,但在真实夜景视频上暗部全是彩噪。反而是离线凑合的多尺度版本在盲评中拿了更高分。人和指标的偏好并不总是一致,训练目标里加入感知损失会缓解这个问题,但最终上线时还是要以任务场景打分为准。
6. 复现中常见的坑与排查思路
6.1 训练了一天,loss 纹丝不动怎么解释
这是最让人抓狂的问题。我的排查顺序是:先看输入数据本身有没有问题。把 DataLoader 里的 rain 和 clean pair 拼在同一张图写出来,肉眼确认配对正确、尺寸一致。然后再检查模型输出范围——把输出图像的最小值和最大值打印出来,如果输出始终接近 0 或 1,说明最后一层激活有问题,或者模型只学会了输出均值。
常见的情况是归一化方式不一致:训练时输入输出都在 0~1,但验证脚本读图后直接除以 127.5 再减 1(即 -1~1),这会让模型行为完全错乱。请统一所有环节的取值范围。
6.2 恢复图像发灰或色彩偏淡
根本原因大概率是感知损失权重过大。感知损失拿的是 VGG 特征,本身不具备逐像素保真的愿望,权重过高会把输出推向“语义上过得去但视觉上灰蒙蒙”的状态。把感知权重从 1.0 降到 0.05-0.1,再看看结果。另外,如果训练集中雨图数量远多于无雨图,模型会有偏向于输出“典型平均色”的倾向,适当补充带雨低光场景的样本能改善。
6.3 合成数据效果很好,真实场景崩溃
这个坑无法完全避免,只能通过微调缓解。把真实雨视频帧和从视频里挑出来的“准干净”帧做成微调集,用较低学习率(5e-5 到 1e-4)继续训练 10-20 个 epoch,让模型适应真实数据分布。如果真实数据没有成对参考,也可以用一部分无参考损失来辅助,保持内容的亮度一致。
6.4 显存不足和训练速度慢
显存不足优先把 patch size 从 128 降为 96,损失通常只有不到 0.5dB PSNR,换来的是显存占用暴跌。之后加混精训练,用torch.cuda.amp即可。如果还不行,再考虑梯度累积。训练速度慢的话,检查 DataLoader 是否设置了num_workers和pin_memory=True。图像解码本身占用大量 CPU,默认设置里数据读取往往是训练时间的瓶颈,很多人会误以为模型结构太慢。
6.5 常见问题速查表
| 现象 | 可能原因 | 快速处理 |
|---|---|---|
| loss 不收敛 | 数据配对错误、归一化不一致 | 视觉检查数据对,统一 0~1 范围 |
| 输出发白/饱和 | 最后一层用了 Sigmoid | 改用无激活输出 |
| 大量水波纹伪影 | 上采样方式不当 | 改用 PixelShuffle/双线性插值 |
| 推理结果与训练差很多 | FP16 精度损失 | 尝试 FP32 推理,或校准量化参数 |
| 模型在 CPU 上太慢 | 算子含动态 shape | 固定输入尺寸,开启 ONNX 优化 |
7. 个人经验与更多扩展
7.1 我反复使用的一套调参配方
如果你看了大段参数有点懵,我直接给一份我实测比较稳妥的默认配置:patch=128、batch=8、AdamW lr=2e-4、T_max=50、感知损失权重 0.05、梯度裁剪 5.0、混合精度开、验证 SSIM 早停。这套配置在多数去雨和低照度任务上都不容易翻车,只在你明显感觉到过平滑时微调感知损失权重即可。
7.2 把 Starnet 的思想迁移到其他视频增强任务
Starnet 的星模块并不只在去雨上有价值。我曾把它挪到老照片修复的局部去划痕任务中,只替换了数据生成方式和损失权重,原骨架几乎没动,效果就能超过原来的专用小模型。这类轻量高表达特性的模块,非常适合作为边缘端视频流水线的中间增强层,插在 ISP 之后、编码器之前,为整个链路提供更好的视觉输入。
最后再分享一个我自己的小习惯:每次训练完 Starnet,我都会拍一段真实的雨景视频直接跑一遍,而不是只测公开测试集。因为测试集再标准,也无法替代真实场景里那些意外——逆光雨、车灯光晕、雨滴溅到镜头上。模型指标再漂亮,最终能让你安心上线的,永远是实拍画面的主观体验。把“真实场景盲评”加入你的模型验收标准,你会少走很多弯路。