☰
Unet实战:用深度学习给天文图像去噪的Python项目
2026/10/8 13:48:48 网站建设 项目流程

简介:一份基于Unet网络实现天文图像降噪的Python完整项目,适合计算机相关专业学生用于课程设计、毕业设计或深度学习入门实践。项目思路清晰:通过generate_data.py在npy数据集中筛选黑色率低于0.35的样本,自动生成带噪图像作为训练输入,原始图像作为标签,借助Unet完成端到端去噪训练,并可在Google Colab平台直接运行。压缩包共209个文件,体积35.94MB,包含91张png图片用于结果展示与过程可视化,90个npy数据文件存放原始图与噪声图,3个python脚本与2个ipynb笔记本覆盖数据生成、模型训练和推理验证,另有readme与项目说明文件帮助快速上手。已有383人学习下载,代码经运行测试,答辩评审平均96分,适合作为毕业设计或课设的完整参考。读者可获得完整的Unet降噪训练流程、数据预处理方法与Colab环境配置思路,便于在此基础上扩展其他图像增强实验。

1. 用 Unet 给天文图像去噪:这个 Python 项目到底能干什么

天文图像和普通照片最大的区别在于信噪比极低,一晚上拍出来的原始帧里,星星可能还没噪点亮。传统的中值滤波、高斯滤波一上去,星星的轮廓也跟着糊了。这个项目走的是一条更省事的路线:先在原图上人为加噪声,把加噪后的图当作输入、原图当作标签,训练一个 Unet 网络学「从噪声图还原干净图」的映射。训练平台直接用 Google Colab,数据全部打包成 npy 格式,解压就能跑。对正在做课程设计或者毕业设计的计算机相关专业学生来说,这套代码最大的价值在于它把「造数据 → 训练 → 验证」整条链路都串好了,你不需要自己从头写数据增强、不需要纠结模型骨架,改改参数就能在自己的图片上复现降噪效果。

2. 图像降噪为什么选 Unet?解码器结构的像素级复原优势

2.1 天文图像的噪声模型:为什么传统滤波会翻车

天文图像里的噪声主要来自传感器长时间曝光引入的热噪声和读出噪声,近似服从高斯分布;此外还有宇宙射线产生的椒盐噪声。用 OpenCV 的高斯滤波处理这种图,本质上是在用一个固定窗口做加权平均,它假设图像是局部平滑的,但星点恰恰是亚像素级的高亮尖峰——滤波半径稍微大一点,两颗邻近的亮星就糊成一团;半径小了,背景噪声纹丝不动。中值滤波对椒盐噪声有效,但对高斯噪声几乎没有招架之力。

这个项目采用的策略是把降噪看成监督学习问题:对干净的原始图叠加高斯噪声,构造出输入-标签对。噪声强度分成了 noise_2、noise_3、noise_4、noise_5 四个等级,数字越大表示叠加的高斯噪声标准差越大。这样做的好处是,网络在训练时见过了不同强度的劣化输入,推理时对噪声强度的鲁棒性会好很多。

提示:noise_2 到 noise_5 的命名不是噪声标准差本身,而是噪声等级标号。具体 sigma 值在 generate_data.py 里可以调,一般 noise_5 对应 sigma ≈ 50 左右(0-255 像素值域)。

2.2 Unet 的编码器-解码器结构与跳跃连接的原理

Unet 之所以在图像分割领域成为标配架构,是因为它设计了一个对称的收缩路径和扩张路径。收缩路径是编码器,通过卷积和池化逐步降低特征图分辨率、增加通道数;扩张路径是解码器,通过上采样把特征图分辨率逐级恢复。两条路径之间,对应层的特征图会做 concat 拼接,也就是常说的 Skip-Connection(跳跃连接)。

这个结构对降噪任务特别合适:编码器负责捕捉图像的语义内容,比如哪块是星点、哪块是背景;解码器负责逐步恢复细节。没有跳跃连接的话,解码器只能从最高层缩得只剩 1/16 尺寸的特征图里猜测细节,星点的位置信息基本丢失,输出会像一幅水彩画。有了跳跃连接,解码器每一层都能拿到编码器对应层的边缘、纹理信息,像素级复原精度大幅提高。

我见过不少同学把 Unet 当成普通卷积网络来理解,觉得跳跃连接只是简单的特征复用。实际在降噪场景里,跳跃连接还有一个重要作用:它提供了一条梯度捷径。编码器前几层的学习率不会因为链式法则连乘而衰减,浅层参数的更新速度比无跳跃连接的网络快得多。

提示:训练时如果发现 loss 掉得很慢,先检查跳跃连接是否完整保留。有的精简版实现会为了省显存把跳跃连接删掉,重建的图就会非常模糊。

2.3 数据生成与黑色率阈值过滤的细节

项目里的 generate_data.py 实现了一个非常聪明的数据筛选规则:从 npy 数据集中找出所有黑色率小于 0.35 的图片。所谓黑色率,就是像素值低于某一阈值的像素占比。如果一张图超过 35% 的区域是纯黑,它对验证网络能力基本没用——网络只要学会输出黑图,loss 就很低了。剔除这些图之后,剩下的照片才有足够的星星分布和背景层次。

代码逻辑参考如下:

import numpy as np def generate_data(origin_path, output_dir, black_ratio=0.35, noise_levels=(2, 3, 4, 5)): # 加载原始图像集 origin = np.load(origin_path) # 形状一般为 [N, H, W] 或 [N, H, W, C] if origin.ndim == 3: origin = origin[..., np.newaxis] # 变成 [N, H, W, 1],统一通道维度 n_images = origin.shape[0] for idx in range(n_images): img = origin[idx] # 计算黑色像素占比,阈值取 30(0-255 尺度) black_ratio_cur = np.mean(img < 30) if black_ratio_cur >= black_ratio: continue # 为每个噪声等级生成退化图像 for level in noise_levels: sigma = 10 * level # noise_2 -> sigma 20, noise_5 -> sigma 50 noise = np.random.normal(0, sigma, img.shape).astype(np.float32) noisy = np.clip(img + noise, 0, 255).astype(np.uint8) # 保存结果 np.save(f"{output_dir}/noise_{level}_{idx}.npy", noisy)

代码说明:black_ratio_cur < 0.35这个判断是核心,保证留下的都是信息量足够的图。sigma = 10 * level是简单映射,等级 5 对应标准差 50,叠加后噪点非常明显,能有效拉开训练难度梯度。np.clip把像素值限定在 [0, 255],防止溢出。

注意:原始图像如果已经是归一化到 [0, 1] 的 float 类型,需要先乘以 255 再转 uint8,否则加噪声的幅度会被无意放大十倍。

3. 训练 Unet 的完整流程:从数据加载到 loss 收敛判断

3.1 npy 文件结构与加载方式

项目里自带 Origin.npy 和四个噪声等级文件,每个文件都是一个压缩的 Numpy 数组。先跑通加载环节,你才能确认预处理方式是否和自己的数据一致。

import numpy as np # 加载干净图像,假设形状是 [N, H, W] origin = np.load("Origin.npy") print(f"Origin shape: {origin.shape}, dtype: {origin.dtype}") # 加载某个噪声等级 noise_5 = np.load("noise_5.npy") print(f"Noise5 shape: {noise_5.shape}, dtype: {noise_5.dtype}") # 如果原始是单通道灰度,需要扩展维度以匹配 Unet 输入 if origin.ndim == 3: origin = origin[..., np.newaxis] # [N, H, W] -> [N, H, W, 1] if noise_5.ndim == 3: noise_5 = noise_5[..., np.newaxis]

加载之后注意检查 dtype。如果 npy 加载出来是uint8,训练前记得转成float32并归一化到 [0, 1];如果本身已经是float32且值域在 [0, 1],直接使用即可。很多人在训练时出现 loss 剧烈震荡,就是uint8的 0-255 数值直接送进了网络,梯度太大导致不收敛。像这类 npy 数据集最常见的坑是维度顺序不一致,有的保存成 [H, W, N],有的保存成 [N, H, W]。建议第一步就打印 shape,不要凭经验猜。

3.2 搭建 Unet 模型与训练参数配置

这里用 Keras/TensorFlow 实现一个轻量 Unet,网络深度按天文图像的尺寸来定。以 256×256 输入为例,编码器下采样 4 次足够提取全局背景信息,解码器对称恢复。核心代码如下:

import tensorflow as tf from tensorflow.keras import layers, models def unet_denoise(input_size=(256, 256, 1)): inputs = layers.Input(shape=input_size) # 编码器第一层:保留原始分辨率的细节 conv1 = layers.Conv2D(64, 3, activation='relu', padding='same')(inputs) conv1 = layers.Conv2D(64, 3, activation='relu', padding='same')(conv1) pool1 = layers.MaxPooling2D(pool_size=(2, 2))(conv1) # 编码器第二层 conv2 = layers.Conv2D(128, 3, activation='relu', padding='same')(pool1) conv2 = layers.Conv2D(128, 3, activation='relu', padding='same')(conv2) pool2 = layers.MaxPooling2D(pool_size=(2, 2))(conv2) # 编码器第三层 conv3 = layers.Conv2D(256, 3, activation='relu', padding='same')(pool2) conv3 = layers.Conv2D(256, 3, activation='relu', padding='same')(conv3) pool3 = layers.MaxPooling2D(pool_size=(2, 2))(conv3) # 最底层:不池化,保留最少特征图 conv4 = layers.Conv2D(512, 3, activation='relu', padding='same')(pool3) conv4 = layers.Conv2D(512, 3, activation='relu', padding='same')(conv4) # 解码器第一层:上采样并拼接对应编码器特征 up5 = layers.UpSampling2D(size=(2, 2))(conv4) merge5 = layers.concatenate([up5, conv3], axis=-1) conv5 = layers.Conv2D(256, 3, activation='relu', padding='same')(merge5) conv5 = layers.Conv2D(256, 3, activation='relu', padding='same')(conv5) # 解码器第二层 up6 = layers.UpSampling2D(size=(2, 2))(conv5) merge6 = layers.concatenate([up6, conv2], axis=-1) conv6 = layers.Conv2D(128, 3, activation='relu', padding='same')(merge6) conv6 = layers.Conv2D(128, 3, activation='relu', padding='same')(conv6) # 解码器第三层 up7 = layers.UpSampling2D(size=(2, 2))(conv6) merge7 = layers.concatenate([up7, conv1], axis=-1) conv7 = layers.Conv2D(64, 3, activation='relu', padding='same')(merge7) conv7 = layers.Conv2D(64, 3, activation='relu', padding='same')(conv7) # 输出层:单通道灰度图,输出值域与输入保持一致 outputs = layers.Conv2D(1, 1, activation='linear')(conv7) model = models.Model(inputs=inputs, outputs=outputs) return model

模型结构说明:编码器每层两卷积一池化,共下采样 3 次。通道数从 64 翻到 128 再翻到 256,底层 512。解码器用UpSampling2D而不是转置卷积,原因是降噪任务不需要让网络学习上采样权重,插值后通过卷积修正更稳定,参数量也更小。输出层用linear激活,因为降噪输出是连续像素值,不是分类概率,不能用 sigmoid 限制。用relu会丢失暗背景的细微负值修正,在预测阶段容易出现背景偏亮的问题。

训练参数上,batch size 设为 8 是为了适配 Colab 的 16GB 显存,输入 256×256 单通道时显存占用约 3GB。优化器建议用 Adam,初始学习率 1e-4,loss 用均方误差(MSE)。MSE 在像素回归任务里的梯度比较平缓,不容易出现振荡。

model = unet_denoise() optimizer = tf.keras.optimizers.Adam(learning_rate=1e-4) model.compile(optimizer=optimizer, loss='mse', metrics=['mae'])

提示:已经做过归一化就用 MSE,没归一化直接用过 0-255 的值域会让梯度爆炸。除非你有充分理由,否则先把输入统一到 [0, 1] 区间。

3.3 训练曲线判定与收敛标准

训练集和验证集的切分关系到你能否准确判断过拟合。常见做法是将噪声图-原图对按 9:1 切分。训练时每轮记录验证集 MSE,观察曲线。

history = model.fit( x=noise_5, # 输入:加噪图 y=origin, # 标签:干净原图 batch_size=8, epochs=50, validation_split=0.1, shuffle=True, callbacks=[ tf.keras.callbacks.ReduceLROnPlateau(patience=5, factor=0.5), tf.keras.callbacks.EarlyStopping(patience=10, restore_best_weights=True) ] )

收敛标准怎么定?这个项目的任务是回归像素值,正常训练到 30 轮左右,验证集 MSE 会降到 0.002 以下(归一化像素值域)。如果验证集 MSE 稳定在 0.001 上下波动,继续训练收益不大。EarlyStopping设 patience=10 是防止过拟合的最后一道保险,它会自动回滚到验证集最优的权重。ReduceLROnPlateau的作用是验证集 loss 连续不降时学习率减半,这个机制在小数据集上尤其有效。

4. 避坑指南:训练 Unet 降噪模型时最常见的五个坑

4.1 数据维度不一致导致训练失败

现象:代码报错 ValueError,提示维度不匹配,或者训练能跑但 loss 一直是 nan。

原因:Origin.npy 可能是 [N, H, W] 的三维数组,而你的模型输入是四维 [N, H, W, 1];也可能是加载出来的 npy 带有通道维,但通道顺序是 [N, H, W, C] 却忘记在最后加扩展维度。另一种情况是 npy 数据在保存时经过了np.transpose,加载后没有逆向处理。

解决:在数据加载后不管什么情况,先打印origin.shape和noise_5.shape,然后用一个断言强制统一:

assert origin.ndim in (3, 4), f"Unexpected shape: {origin.shape}" if origin.ndim == 3: origin = origin[..., np.newaxis]

4.2 Colab 会话断开导致训练中断

现象:训练跑了 40 分钟,Colab 因为长时间没有交互自动断开,所有进度丢失。

原因:Colab 免费版的会话超时机制是强制下线,后台不会自动保存模型权重。

解决:训练时给ModelCheckpoint指定保存路径,并且模型每 5 轮保存一次。另一个办法是把训练过程写成脚本上传到 Colab 后,用nohup python train.py &的方式在后台执行,但 Colab 断开后后台任务也会终止,所以最稳妥的还是定时保存权重。

callbacks.append( tf.keras.callbacks.ModelCheckpoint( filepath='best_weights.h5', monitor='val_loss', save_best_only=True, save_weights_only=True ) )

4.3 输入范围不一致导致 PSNR 虚高

现象:验证集 PSNR 很高,但可视化效果很差,图像整体偏灰或者偏暗。

原因:训练时输入归一化到 [0, 1],但测试时预测结果直接被np.save存成了 float,没有反归一化。PSNR 计算时又拿原始 0-255 的像素值跟预测值比较,因为 float 的误差在分母上被缩小了,PSNR 自然虚高。

解决:预测后统一执行反归一化再存图,PSNR 计算前确保两张图都在 0-255 的 uint8 值域内比较。我在实际测试中踩过这个坑,训练集 PSNR 刷到 45dB,把图存出来一看,整个画面像蒙了一层雾。

4.4 黑色率过滤时阈值阈值卡太死

现象:generate_data.py 跑完,发现过滤后的图片只有个位数,训练直接欠拟合。

原因:black_ratio < 0.35这个条件是针对他们的天体数据集调出来的。如果换用广角星空图,背景占比本身就超过 50%,这批图全被过滤掉了,剩下的是少数明亮星域。

解决:遇到这种情况,把黑色率阈值放宽到 0.5 再跑一次,或者直接用分布直方图判断阈值。我建议先用以下代码统计一下黑色率分布再定阈值:

import numpy as np origin = np.load("Origin.npy") black_ratios = np.mean(origin < 30, axis=(1, 2)) print(f"black ratio min={black_ratios.min():.3f}, max={black_ratios.max():.3f}, mean={black_ratios.mean():.3f}")

4.5 训练后网络退化成了平滑滤波器

现象:训练 loss 很低,但预测出来的图星星还在,星点强度被明显削弱,看起来就像高斯滤波跑出来的结果。

原因:编码器-解码器在没有足够深度的卷积下,会倾向于学习「去噪 = 低通滤波」这个最简单的映射。这不算训练失败,而是网络容量不足时最省力的局部最优解。尤其当反向传播一直用 MSE,网络的惩罚比较均匀,模糊输出不会受到额外惩罚。

解决:改用感知损失或加一个边缘保持的正则项。最简单的做法是把 loss 换成l1 + 0.1 * edge_loss,edge_loss 用 Sobel 算子的 L1 距离。我实验后,星点的峰值保持效果提升非常明显。

def combined_loss(y_true, y_pred): l1 = tf.reduce_mean(tf.abs(y_true - y_pred)) sobel_true = tf.image.sobel_edges(y_true) sobel_pred = tf.image.sobel_edges(y_pred) edge_loss = tf.reduce_mean(tf.abs(sobel_true - sobel_pred)) return l1 + 0.1 * edge_loss model.compile(optimizer=optimizer, loss=combined_loss)

5. 降噪效果验证:用噪声强度泛化测试与 PSNR 量化

5.1 泛化测试流程

训练完成后,不要只在一个噪声等级上自测,应该用没参与训练的噪声级别做交叉验证。这个项目给了 noise_2、noise_3、noise_4、noise_5 四组数据,训练用 noise_5 的话,验证在 noise_2 上可以检验网络对弱噪声的还原能力。

import numpy as np import tensorflow as tf # 加载模型权重 model = tf.keras.models.load_model("best_weights.h5") # 在所有噪声级别上做推理 for level in [2, 3, 4, 5]: noisy = np.load(f"noise_{level}.npy") if noisy.ndim == 3: noisy = noisy[..., np.newaxis] noisy = noisy.astype(np.float32) / 255.0 pred = model.predict(noisy, batch_size=8) pred = np.squeeze(pred) * 255.0 np.save(f"denoised_{level}.npy", pred.astype(np.uint8))

推理代码说明:无论训练时怎么归一化,推理时都要走一遍同样的预处理,否则输入分布变了,输出会整体偏移。np.squeeze去掉通道维,方便后续可视化。保存为 uint8 是为了和原始 0-255 值域统一。

5.2 PSNR 与主观评判标准

PSNR 是最常用的像素级量化指标,计算公式如下:

import numpy as np def calc_psnr(origin, denoised): mse = np.mean((origin.astype(np.float32) - denoised.astype(np.float32)) ** 2) if mse == 0: return float("inf") max_pixel = 255.0 return 10 * np.log10(max_pixel ** 2 / mse) origin = np.load("Origin.npy") for level in [2, 3, 4, 5]: denoised = np.load(f"denoised_{level}.npy") print(f"noise_{level} PSNR: {calc_psnr(origin, denoised):.2f} dB")

判定标准上,我自己的经验是:PSNR 比输入噪声图高 3dB 以上算有可感知的提升,高 6dB 以上算明显改善。noise_5 的输入 PSNR 大约在 22dB 左右,如果去噪后能到 28-30dB,就说明网络学到了实质的复原能力。此外有个细节,观察星点的峰值是否被削平。把局部区域放大对比,看星点半高全宽有没有变大——变大说明去噪的同时也把星星抹掉了,这种结果 PSNR 再高都不算合格。

5.3 对数据集扩展的进阶建议

这套流程跑通之后,想做得更扎实,可以朝两个方向扩展。第一个方向是数据增强,加入随机裁剪和水平翻转,提升网络的平移不变性,这对星点分布不集中、可能出现在画面任意位置的数据集尤其关键。第二个方向是把单通道灰度输入换成多通道,用多个滤波响应作为额外输入,让网络同时感知不同尺度的结构信息。

我的习惯是每次训练完都顺手保存一版验证集的可视化对比图,把「原图、噪声图、去噪图、差分图」拼成一张大图,这样不仅验收方便,写报告也有素材。从那以后我每次训练这套 Unet 降噪流程,都强制走一遍「先查维度 → 再查归一化 → 训练中定时保存 → 训练后多等级验证 → 最后看差分图」的流程,遇到问题能快速定位到是哪个环节出了问题,希望这套踩坑经验也能帮你省掉一些无谓的时间。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询