简介:基于Unet网络实现天文图像降噪的Python项目,提供完整源代码与项目说明,面向计算机相关专业学生的课程设计、毕业设计及图像处理入门者。项目采用先为图像添加噪声、再将噪声图作为训练集、原始图作为标签的方式训练Unet模型,并可在Google Colab环境运行,适合学习卷积神经网络在图像去噪中的应用。资源共209个文件,约35.94MB,其中npy文件为数据集,py脚本负责数据生成与模型训练,ipynb提供可交互的演示流程,png为处理结果图,md/readme包含使用说明,目录结构清晰,便于按需查阅。目前已有383人学习使用。下载后可直接对照README运行测试,也可根据自身任务修改网络结构或数据路径,快速搭建天文图像降噪实验,适用于毕设答辩、课设作业或项目初期演示。
1. 天文图像的噪声特性和Unet的回归建模
打开项目里的 Origin.npy 和 noise_5.npy 对比,两张图肉眼几乎看不出差别,但带噪图像送入 Unet 之后,输出层拿到的是一张比原图更干净的星图。这就是这个项目的核心逻辑:把天文图像降噪当作像素级回归问题来解,而不是传统的高斯滤波或小波硬阈值。天文图中暗背景占绝大多数、亮星点稀疏且动态范围极大,这种数据分布跟自然图像差异很大,普通 CNN 很难同时处理好大面积平滑背景和细小星点。Unet 的编码-解码结构配合跳跃连接,恰好能把低频背景和高频边缘分开建模。对正在做课程设计或毕业设计的人来说,这套代码把数据生成、Unet 训练、结果验证串成了完整闭环,换到自己的数据集上也不难。
2. 用generate_data.py构造带噪训练集和npy数据集
这个项目的训练数据不是外部下载的公开数据集,而是用脚本自己生成的。理解了生成逻辑,后面训练 Unet 时对训练集分布才会有直觉。
2.1 黑色率小于0.35的筛选逻辑
generate_data.py 的核心行为是遍历 npy 文件夹下所有图像,计算每张图的"黑色率",只保留黑色像素占比小于 0.35 的图。黑色率定义本身没有统一标准,常见做法是统计灰度值低于某阈值的像素占总像素的比例:
import numpy as np def black_ratio(img, threshold=10): # img: 单张灰度图,值域 0-255 black_mask = img < threshold return black_mask.mean() def filter_images(images, max_ratio=0.35): kept = [] for img in images: ratio = black_ratio(img) if ratio < max_ratio: kept.append(img) return np.stack(kept)这个筛选的意义在于:天文图像绝大多数像素是天空背景,如果一张图黑色区域占比过高,说明画面里要么没有天体、要么曝光不足,作为训练样本只会让 Unet 学到"输出全黑"的平庸解。0.35 是个经验阈值,拉高到 0.5 会引入大量无效样本,压到 0.2 则可能把含有暗弱星系的有效图像也过滤掉。你可以把 threshold 参数调低到 5 或 8,因为采集端的偏置电平不同,黑色判断基准要跟着变。
2.2 Origin.npy与noise_2到noise_5的噪声等级划分
数据目录里的 Origin.npy 是干净图堆叠,noise_2.npy、noise_3.npy、noise_4.npy、noise_5.npy 则是不同噪声强度下的退化图。从文件名来看,噪声强度分成了 2、3、4、5 四个档位。常见做法是高斯白噪声,标准差按灰度值域 0-255 分别取 2、3、4、5,或者按归一化到 0-1 后取 0.02、0.03、0.04、0.05。这两种理解的差异会直接影响 Unet 输入层的数值范围:
def add_gaussian_noise(clean, sigma): # clean: float32,值域 0-255 noise = np.random.normal(0, sigma, size=clean.shape).astype(np.float32) noisy = clean + noise return np.clip(noisy, 0, 255).astype(np.uint8)实际天文图的噪声不是纯高斯,读出噪声接近高斯,光子噪声服从泊松分布。我在类似项目里会把泊松噪声近似成信号依赖的高斯噪声,即 sigma 取base + signal * rate,这样暗背景处噪声小、亮星处噪声大,更接近真实 CCD 的行为模式。项目目前给出的噪声强度分档,也是为了让 Unet 同时适应多档噪声,而不是只对某一档过拟合。
| 文件 | 常见噪声含义 | 主观效果 | 建议用途 |
|---|---|---|---|
| noise_2.npy | 低强度噪声 | 肉眼几乎不可见 | 验证网络对微小扰动的鲁棒性 |
| noise_3.npy | 中等噪声 | 放大后可见颗粒感 | 训练集主力 |
| noise_4.npy | 较强噪声 | 星点边缘开始模糊 | 训练集主力 |
| noise_5.npy | 高强度噪声 | 背景出现明显纹理 | 测试网络泛化上限 |
2.3 生成脚本、数据增强与课程设计复用
generate_data.py 的逻辑通常是把所有干净图读进来,复制多份并分别叠加不同强度的噪声,最后保存成一组带噪声等级的 npy 文件。训练时不建议把 2 到 5 的噪声混合输给同一个模型,因为 Unet 对噪声水平敏感,混合训练容易让模型学会"取平均"。更稳的做法是每个噪声等级单独训练,或者把噪声等级作为条件信息拼到输入通道里。
数据增强方面,天文图像不像自然图像那样有明确的语义方向性,翻转、旋转 90 度、随机裁剪都安全。但不要用随机亮度抖动和色彩增强,天文图像灰度响应是线性的,亮度扰动会把 Unet 训练成一个对强度不敏感的网络,降噪后星点流量会失真。这个项目拿来做课程设计时,可以只跑 noise_3 和 noise_5 两个等级,分别训练两个模型,对比不同噪声强度下 PSNR 的差异,工作量足够且现象明显。
3. Unet的编码解码器结构与PyTorch实现
Unet 最早是给医学图像分割设计的,但用在图像降噪上同样合理,关键在它的编码器-解码器结构和跳连机制。很多资料把 Unet 降噪讲成"分割网络硬套回归任务",实际不是这样。
3.1 跳跃连接为什么保留空间细节
Unet 的编码器逐层下采样,感受野变大,能捕获大范围背景结构;解码器逐层上采样,把低分辨率特征恢复到原图尺寸。如果只有这条主链路,星点这类细小目标在多次池化后会被抹掉,解码器就算恢复了分辨率也恢复不了细节。跳跃连接把编码器每层的输出直接拼接到解码器对应层,等于给重建路径单独开了一条高分辨率旁路,让星点边缘和星核结构绕开信息瓶颈。
降噪场景里这个特性尤其重要。普通卷积自动编码器去噪后星点容易糊成一团,而 Unet 的跳跃连接保证了去噪后星点位置和形态基本不变。代价是模型参数量增加,且如果输入噪声过大,跳跃连接会把噪声特征也原样传过去,所以实际使用时要在拼接后紧跟卷积层再融合,这也是下文实现里每个解码模块都保留两层卷积的原因。
3.2 双卷积模块与Unet类实现
底层模块是连续两个 3x3 卷积加 BN 加 ReLU。编码器每下采样一次通道翻倍,解码器每上采样一次通道减半,最底层特征图尺寸为输入的四分之一或八分之一。以下实现按输入单通道灰度图、最底层 512 通道设计:
import torch import torch.nn as nn class DoubleConv(nn.Module): def __init__(self, in_ch, out_ch): super().__init__() self.conv = nn.Sequential( nn.Conv2d(in_ch, out_ch, 3, padding=1), nn.BatchNorm2d(out_ch), nn.ReLU(inplace=True), nn.Conv2d(out_ch, out_ch, 3, padding=1), nn.BatchNorm2d(out_ch), nn.ReLU(inplace=True), ) def forward(self, x): return self.conv(x) class Unet(nn.Module): def __init__(self, in_ch=1, out_ch=1, base_ch=64): super().__init__() self.enc1 = DoubleConv(in_ch, base_ch) self.enc2 = DoubleConv(base_ch, base_ch * 2) self.enc3 = DoubleConv(base_ch * 2, base_ch * 4) self.enc4 = DoubleConv(base_ch * 4, base_ch * 8) self.pool = nn.MaxPool2d(2) self.bottleneck = DoubleConv(base_ch * 8, base_ch * 16) self.up4 = nn.ConvTranspose2d(base_ch * 16, base_ch * 8, 2, stride=2) self.dec4 = DoubleConv(base_ch * 16, base_ch * 8) self.up3 = nn.ConvTranspose2d(base_ch * 8, base_ch * 4, 2, stride=2) self.dec3 = DoubleConv(base_ch * 8, base_ch * 4) self.up2 = nn.ConvTranspose2d(base_ch * 4, base_ch * 2, 2, stride=2) self.dec2 = DoubleConv(base_ch * 4, base_ch * 2) self.up1 = nn.ConvTranspose2d(base_ch * 2, base_ch, 2, stride=2) self.dec1 = DoubleConv(base_ch * 2, base_ch) self.out = nn.Conv2d(base_ch, out_ch, 1) def forward(self, x): e1 = self.enc1(x) e2 = self.enc2(self.pool(e1)) e3 = self.enc3(self.pool(e2)) e4 = self.enc4(self.pool(e3)) b = self.bottleneck(self.pool(e4)) d4 = self.dec4(torch.cat([self.up4(b), e4], dim=1)) d3 = self.dec3(torch.cat([self.up3(d4), e3], dim=1)) d2 = self.dec2(torch.cat([self.up2(d3), e2], dim=1)) d1 = self.dec1(torch.cat([self.up1(d2), e1], dim=1)) return self.out(d1)代码里 base_ch 控制参数量,64 时模型约 3100 万参数,Colab 免费版可训练;显存有限就降到 48 或 32。解码器每次torch.cat拼接的是来自编码器的同尺寸特征,所以编码器最后一层下采样次数和解码器上采样次数必须严格对应,否则拼接时尺寸不匹配。上采样用ConvTranspose2d会带来棋盘伪影,改nn.Upsample(scale_factor=2, mode='bilinear')再接卷积可以缓解,但边缘锐度会略降,星空图优先保留边缘,所以保持转置卷积更合适。
3.3 损失函数与匹配评价指标
Unet 的输出层是回归输出,所以最后不能用 softmax,就是一个普通卷积层。损失函数最常见的是 MSE,它对应高斯噪声下的最大似然估计,假设噪声是高斯分布时 MSE 最优;但 MSE 对像素误差一视同仁,暗背景区像素量大,会主导梯度方向,导致星点区域学不好。
我一般会把 MSE 和 SSIM 加权组合,loss = 0.7 * L1 + 0.3 * (1 - SSIM)。L1 比 MSE 对离群像素更鲁棒,训练前期收敛更稳,SSIM 则强制网络保留局部结构。如果只在 Colab 上做课程设计,直接跑纯 MSE 也可以,但验证时不要只看 loss,要记录 PSNR 和 SSIM 两个指标,loss 下降不等于肉眼观感变好。
4. Google Colab训练流程与超参数直觉
项目说明里提到训练平台是 Google Colab,这意味着不用在本机配 CUDA 环境,只要浏览器打开 notebook 就能跑。但 Colab 免费版有显存和时长限制,代码的组织方式要按这个约束来设计。
4.1 把npy接入PyTorch Dataset和DataLoader
npy 文件是完整数组,不能一次性全部载入训练,需要封装成 Dataset 并按批次取。读取 Origin.npy 和对应噪声 npy,按索引配对返回干净图和噪声图:
import numpy as np import torch from torch.utils.data import Dataset, DataLoader class AstroDenoiseDataset(Dataset): def __init__(self, clean_path, noisy_path, sigma): self.clean = np.load(clean_path) # (N, H, W) uint8 self.noisy = np.load(noisy_path) # (N, H, W) uint8 assert self.clean.shape == self.noisy.shape def __len__(self): return len(self.clean) def __getitem__(self, idx): x = self.noisy[idx].astype(np.float32) / 255.0 y = self.clean[idx].astype(np.float32) / 255.0 x = torch.from_numpy(x).unsqueeze(0) y = torch.from_numpy(y).unsqueeze(0) return x, y dataset = AstroDenoiseDataset("Origin.npy", "noise_3.npy", sigma=3) loader = DataLoader(dataset, batch_size=16, shuffle=True, num_workers=2)注意这里把像素从 0-255 缩放到 0-1,作用有两个:一是让网络权重初始化数值范围更平稳,二是配合 ReLU 输出直接限制在 0-1 区间。num_workers在 Colab 上设 2 即可,设大了反而会因为进程调度开销拖慢训练。数据增强可以在__getitem__里用随机翻转,但每次都生成不同翻转会破坏配对的确定性,建议用固定的随机种子做离线增强。
4.2 训练循环与学习率调度
Colab 上训练这类 Unet,batch_size 16、Adam 初始学习率 1e-3 起步,训练 60 到 120 个 epoch 就能看到明显效果。训练循环里要注意把模型切到 train 模式,BN 层的统计量在训练和推理时行为不同:
device = torch.device("cuda" if torch.cuda.is_available() else "cpu") model = Unet(in_ch=1, out_ch=1).to(device) optimizer = torch.optim.Adam(model.parameters(), lr=1e-3) scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau( optimizer, mode="min", factor=0.5, patience=8 ) criterion = nn.MSELoss() for epoch in range(80): model.train() total_loss = 0.0 for x, y in loader: x, y = x.to(device), y.to(device) pred = model(x) loss = criterion(pred, y) optimizer.zero_grad() loss.backward() optimizer.step() total_loss += loss.item() * x.size(0) avg_loss = total_loss / len(dataset) scheduler.step(avg_loss) print(f"epoch {epoch:03d} loss {avg_loss:.6f}")ReduceLROnPlateau是这项目里的关键调度策略:它监控验证集 loss,连续 8 个 epoch 不下降就把学习率减半。直接用固定学习率容易在后期震荡,用 CosineAnnealing 又需要预设总 epoch 数,Colab 训练被打断重开时不如 ReduceLROnPlateau 灵活。每个 epoch 结束后建议打印当前学习率,确认调度器确实在触发,否则后期 loss 平台期会拖很久。
4.3 Colab训练常见瓶颈与超参数调整
| 参数 | 推荐范围 | 过大或过小的后果 |
|---|---|---|
| batch_size | 8-32 | 过小导致 BN 统计量不稳定,过大容易 OOM |
| 初始学习率 | 1e-3 到 1e-4 | 1e-2 直接发散,1e-5 收敛太慢 |
| 编码器通道基数 | 32-64 | 过小拟合不足,过大训练速度骤降 |
| 降噪等级 | 训练单档或相邻两档 | 混合全部档位会让网络学成钝化滤波 |
提示:Colab 免费版显存只有 15GB 左右,base_ch=64 的 Unet 在 batch_size=16 时大约占用 6-8GB,TensorBoard 监控和验证集推理都会额外占显存。如果训练中提示 CUDA out of memory,把 batch_size 降到 8,不要动网络结构。
训练到后期常见两个现象:loss 还在下降但验证 PSNR 不涨,或者验证 PSNR 涨了但星点区域变模糊。前者是网络在拟合训练集噪声的特定分布,后者是模型把星点也当作噪声抹掉了。针对第一种情况,停止训练或增大噪声等级混合;针对第二种情况,检查损失函数里 SSIM 权重是否过高,或调整跳跃连接的融合方式。
5. 去噪结果验证和针对星空背景的调参技巧
项目里的 result.ipynb 承担的不只是画图,更核心的是量化验证。Unet 降噪效果不能只靠肉眼判断,因为人眼对低亮度区域的噪声不敏感,很容易误判。
5.1 用PSNR和SSIM验证去噪效果
验证阶段把测试集的干净图、带噪图、Unet 输出三者放在一起对比,计算 PSNR 和 SSIM:
import numpy as np import torch.nn.functional as F def psnr(pred, target, max_val=1.0): mse = F.mse_loss(pred, target).item() return 10 * np.log10(max_val * max_val / mse) def ssim(pred, target): # 简化版 SSIM,完整实现可用 pytorch_msssim pred = pred.cpu().numpy().squeeze() target = target.cpu().numpy().squeeze() mu_x, mu_y = pred.mean(), target.mean() sigma_x, sigma_y = pred.std(), target.std() sigma_xy = ((pred - mu_x) * (target - mu_y)).mean() c1, c2 = 0.01**2, 0.03**2 return ((2 * mu_x * mu_y + c1) * (2 * sigma_xy + c2)) / ( (mu_x**2 + mu_y**2 + c1) * (sigma_x**2 + sigma_y**2 + c2) )验证时要按噪声等级分别统计。noise_2.npy 上的 PSNR 可能从 28dB 提到 38dB,而 noise_5.npy 只能提到 30dB 左右,这是符合预期的,因为高噪声下的信息恢复上限本身就更低。比 PSNR 更有说服力的是残差图:把 Unet 输出减去带噪输入,看残差里是否还残留星点结构,如果残差里能看到星点轮廓,说明网络没有完全把星点从噪声中分离出来,而是连星点一起压暗了。
5.2 星点保留、暗背景伪影与混合噪声训练
针对天文图像的去噪,有三个实际调参经验值得记录。第一个是输出层激活:网络输出层直接用线性卷积,不要在最后加 Sigmoid。Sigmoid 会把输出限制在 0-1,星座背景本来就接近 0,Sigmoid 在 0 附近梯度平缓,星点亮度被压缩后难以恢复。第二个是输入归一化:把输入除以 255 而不是做标准化,能让网络权重更稳定;如果做了标准化,推理时输入数据的均值和方差必须与训练时一致,容易在换数据时踩坑。第三个是对暗背景伪影的处理:黑色率 0.35 的过滤会保留一部分几乎全黑的图,这些图的 loss 接近 0,占梯度比例很小,但会在验证时输出暗背景上的斑点状伪影。我一般会在损失函数里对暗背景区域加权,把像素值低于 0.1 的区域的 loss 系数调低,网络就会把更多容量分配给星点和中等亮度区域。
最后提一个针对 Unet 结构本身的调整思路。标准 Unet 的四层下采样对天文图来说略深,星点尺寸通常只有几像素,四层下采样后空间信息压缩过度。如果降噪效果在星点中心出现凹陷,把网络改成三层下采样,base_ch 相应加大到 96,通常能改善星点形状保持。这个改动不需要动数据生成逻辑,只改模型初始化参数即可。结合噪声等级分开训练的模型,再在推理时按噪声水平选择对应权重,比单一模型硬吃四个档位要实用得多。
本文还有配套的精品资源,点击获取