简介:基于Unet网络的天文图像降噪项目,完整提供Python源码与项目说明,适合计算机相关专业学生用于课程设计、毕业设计或深度学习图像处理入门。项目思路清晰:先对原图添加噪声构造训练集,再以Unet网络学习噪声图像到干净图像的映射,并可在Google Colab中直接运行。压缩包共209个文件,约36MB,包含图像样本、npy格式数据集、Python脚本及ipynb训练/测试笔记等,npy文件用于加载训练与验证数据,ipynb可逐步查看处理流程。目前已有383人学习下载。下载后可按README说明快速上手,generate_data.py负责根据黑色率筛选并生成带噪样本,模型部分基于Unet结构实现端到端降噪,适合希望复现实验或在此基础上扩展功能的读者。
1. 天文图像降噪为什么绕不开Unet:从满屏雪花里捞星点说起
拍过深空的人都有这个经历:单张RAW里星点泡在噪点里,不叠加个几十张根本没法看。我自己做天文图像降噪时跑通的方案,就是基于Unet网络实现:Python源代码加项目说明,从合成训练数据到模型推理整条链路。Unet最早因医学图像分割出名,但它的编码器-解码器结构和跳跃连接,放在“把噪声拿走、把星星留下”的回归任务上同样合适。这篇笔记写给正在做天文数据预处理、或者想在Python里跑通第一个降噪模型的人:有能直接复现的代码,也有参数怎么改、失败时看什么的踩坑记录。如果你纯做CV,里面的天文噪声模型也值得参考。
2. Unet 网络结构拆解:编码器-解码器和跳跃连接如何保住星点细节
先说结论:Unet做分割和做降噪用的是同一套网络骨架,但你要在脑海里把每一层的作用换一遍。分割关注“这个像素属于什么目标”,降噪关注“这个像素原本应该有多少灰度”。前者是分类,后者是回归。这个区别决定了后续所有细节——输出层、损失函数、数据组织方式——都会和你在医学图像教程里看到的不一样。
2.1 Unet 网络结构图中最关键的部分:跳跃连接在降噪任务里的角色换位
经典Unet网络结构图,左边一条编码器路径,右边一条解码器路径,中间是bottleneck,编码器和解码器对应层用跳跃连接横向拼起来。编码器每下采样一次,特征图分辨率减半,通道数翻倍;解码器再逐步上采样回原分辨率。分割任务里,跳跃连接为的是同时保住语义信息和边界精度;降噪任务里,它的角色变成“细节直通”。
星点PSF在像平面上通常只有2到4个像素的半高全宽。一张256×256的训练图,经过第一次2×2池化变成128×128,星点轮廓虽然还在,但高频信息已经开始被削平。等编码器走到最深层,特征图只有16×16,一颗星几乎只剩一个像素的响应,解码器靠这些信息完全没有办法重建出正常的星点形状。没有跳跃连接,输出基本就是一张被抹平的天光背景,星点全部变成小圆斑甚至直接消失。
跳跃连接把编码器浅层的高分辨率特征直接并到解码器同尺寸层,相当于给解码器一张“原图级别的参考图”。这同时也带来一个工程上的特点:Unet降噪模型的显存占用,很大一部分花在这些拼接后的特征图上。想加大batch size时,第一优先是降基础通道数,而不是缩小patch。后面第五部分会专门讲这个坑。
顺带一个个人习惯:我在每个DoubleConv里都放BatchNorm。不少人说图像降噪用BN容易出伪影,我的实测是,在天文图像这种噪声分布相对固定的合成数据上,BN能明显加速收敛,最终结果的伪影不比InstanceNorm多。如果换到真实FITS数据上发现伪影,再把BN改成InstanceNorm试,二选一,不要两个都上。
2.2 把降噪当成回归任务:输出层、损失函数与像素归一化的选择
分割模型的输出层通常接softmax,算交叉熵;降噪模型的输出层是一个1×1卷积,把特征图映射成单通道灰度图,后面不接激活函数。原因很直接:我们希望模型能输出任意灰度值,而不是被钳制在[0,1]里。即使训练时把数据归一化到[0,1],输出层也保持线性,这样推理时遇到超出训练范围的极小概率像素,模型不会因为激活函数饱和而全部压到同一个值。
损失函数直接决定成图质感。我一般用L1损失,而不是MSE。MSE对离群像素惩罚更重,收敛快,但它倾向于把预测值往均值方向拉,训练出来的图整体偏“糊”,星点会被软化成高斯斑。L1对离群点没那么敏感,梯度在零点附近恒定,星点轮廓更锐利。天文图像的特点是星点只占图像面积的很小比例,大部分像素是天光背景和噪声,如果拿MSE,模型只要把背景预测准就能拿到很低的loss,星点这种稀疏细节会被忽略。L1虽然也会偏向背景,但边缘处梯度不变,模型会在星点轮廓上多花力气。
像素归一化是这个地方最容易翻车的一步。16位FITS图原始数值范围0到65535,但真实信号通常集中在几百到几千这个低区段,直接除以65535会把输入压到接近0,网络前几层的梯度小到几乎学不动。常见做法是先做分位数截断,把[1%, 99.5%]分位数之间的像素线性映射到[0,1],超亮和超暗的坏像素、宇宙射线先被截掉。这里要特别提醒:归一化用的分位数参数必须保存下来,推理时和训练保持一致,否则换一张图就崩。
2.3 和GAN、Transformer比,为什么先选Unet做天文图像降噪
不是所有降噪需求都应该用Unet,但天文图像降噪这个场景里,Unet的启动成本和结果可解释性让它成为首选。GAN需要生成器和判别器同时训练,训练过程随机性大,很容易在某一个epoch之后开始往输出里加假纹理。星点属于科学数据,宁可不降噪也不能接受模型凭空生成一颗不存在的星,所以GAN在天文数据处理里一直不太受信任。Transformer近年在图像恢复上表现不错,但计算量随token序列长度平方增长,128×128的patch切出的token序列在单卡上训练效率远不如Unet。
Unet的优势在工程上也很明显:结构简单,用PyTorch几十行就能搭出来;显存波动小,便于稳定训练;想改进时有很明确的抓手。常规的Unet模型改进方向包括:把普通卷积换成残差块、在bottleneck里加SE或CBAM注意力、把BatchNorm换成GroupNorm、或者加深到5层下采样。这些改动每一步都能单独评估效果,不像GAN那样牵一发动全身。
下采样次数我固定用4次,基础通道数选32。下采样太浅,比如只有2次,感受野太小,网络只能看到局部纹理,区分不了“星点”和“噪声团”;下采样太深到5次甚至6次,深层特征图只有几个像素,星点的高频信息在多次池化里丢得差不多,跳跃连接也补不回来。4次下采样加base_ch=32,在128×128的patch上训练一轮batch size=8,大约需要2到3GB显存,大多数个人电脑都跑得动。
3. 训练数据从哪来:用合成星场生成带噪图,别一上来就啃观测数据
训练Unet降噪需要一个关键的东西:成对的“噪声图—干净图”。真实天文观测里没有干净的ground truth,用几十张短曝光叠加出来的参考图也会残留噪声和跟踪误差。所以我一般会先用合成数据把模型跑通。合成数据最大的好处是噪声模型完全可控:想要多少星点、什么PSF宽度、什么信噪比,都由参数决定,生成的训练样本之间互不重复。
3.1 用Python画出模拟星场:PSF、天光背景与星等的模拟
第一步是生成一张“理想无噪声”的天文图像。常见做法是:先铺一个恒定的天光背景,然后在随机位置放星点,每个星点用二维高斯核做PSF,再乘以星点强度。
import numpy as np def gaussian_psf(size=15, fwhm=2.5): # fwhm: 半高全宽,单位像素,由望远镜视宁度和光学系统决定 sigma = fwhm / (2 * np.sqrt(2 * np.log(2))) axis = np.arange(size) - size // 2 x, y = np.meshgrid(axis, axis) psf = np.exp(-(x ** 2 + y ** 2) / (2 * sigma ** 2)) return psf / psf.sum() def make_clean_field(h=256, w=256, n_stars=80, fwhm=2.5, sky_level=120.0): # sky_level 是天光背景的电子数,单位 ADU,16位图像典型值在 100 附近 img = np.full((h, w), sky_level, dtype=np.float32) psf = gaussian_psf(fwhm=fwhm) psf_h, psf_w = psf.shape for _ in range(n_stars): x = np.random.randint(psf_w // 2, w - psf_w // 2) y = np.random.randint(psf_h // 2, h - psf_h // 2) amplitude = np.random.uniform(20, 200) # 星点峰值强度,对应不同星等 img[y - psf_h // 2:y + psf_h // 2, x - psf_w // 2:x + psf_w // 2] += amplitude * psf return img逻辑说明:gaussian_psf先生成一个归一化的PSF核;make_clean_field在整张图上铺天光背景,再随机撒星点,星点强度用均匀分布模拟不同星等。这里没有做亚像素位置偏移,真实观测里星点中心很少正好落在像素中心,如果你想提升模型泛化能力,可以按浮点坐标对PSF重新采样。
参数说明:fwhm建议设在1.5到4.0之间,视宁度差时取大值;n_stars控制星点密度,80颗对应中等密度的银河星场;sky_level取100左右比较符合多数暗夜环境。amplitude从20到200,是为了让训练集里同时出现亮星和暗星,避免模型只学会处理亮星。
另一个容易被忽略的细节:PSF核的尺寸要覆盖到星点半高全宽的5倍以上。fwhm=2.5时,15×15的核够用;核太小会把星点截断,产生生硬边缘,核太大则浪费计算量。
3.2 叠加天文噪声:泊松光子噪声、高斯读出噪声与坏像素
有了干净图,下一步是叠加噪声。天文CCD/CMOS图像的主要噪声来源有三个:光子计数噪声服从泊松分布,读出噪声近似高斯分布,还有少数坏像素和宇宙射线。
def add_astronomical_noise(clean): # 光子噪声:用泊松分布近似信号相关的随机涨落 noisy = np.random.poisson(clean).astype(np.float32) # 读出噪声:与信号无关的高斯噪声,sigma 单位 ADU read_noise_sigma = 2.0 noisy += np.random.normal(0, read_noise_sigma, size=noisy.shape).astype(np.float32) # 坏像素:少量全黑或过亮的点,对应热像素和坏列 bad_mask = np.random.rand(*noisy.shape) < 0.002 noisy[bad_mask] = np.random.choice([0, 65535], size=bad_mask.sum()) return np.clip(noisy, 0, 65535)逻辑说明:np.random.poisson(clean)的输入是期望值,输出是服从泊松分布的随机整数,正好模拟光子到达的随机性。读出噪声用零均值高斯分布叠加。坏像素概率0.002,256×256的图里平均约131个坏点,这个密度比真实CMOS偏高,好处是让模型学会容忍坏像素。
参数说明:read_noise_sigma建议先取1到3。sigma太小,合成数据里读出噪声占比过低,模型会过度依赖泊松噪声的统计特征;sigma太大,暗弱星点被噪声完全盖住,训练难度陡增。实操中可以先用默认值跑一版,看训练loss和验证结果再回调。
有一个容易迷惑的点:真实图像是先有光子噪声再有读出噪声,且泊松噪声的方差等于信号强度。用np.random.poisson生成的数据满足这个性质,但输入clean必须是非负浮点数,负值会在clip之后被强制到0,破坏背景分布的连续性,让图像底部出现一圈不自然的暗区。
3.3 切patch和轻量增强:让训练样本刷出更多多样性
整张256×256图像直接送进网络,一个epoch很快就刷完了,而且样本之间高度相似。常见做法是切patch,每张训练图上随机裁出若干小块,再配合翻转和旋转增强。这样既增加训练样本多样性,也让显存开销可控。
from torch.utils.data import Dataset import torch class AstroDenoiseDataset(Dataset): def __init__(self, patch_size=128, n_samples=2000, train=True): self.patch_size = patch_size self.n_samples = n_samples self.train = train def __len__(self): return self.n_samples def __getitem__(self, idx): clean = make_clean_field(256, 256, n_stars=80, fwhm=2.5, sky_level=120.0) noisy = add_astronomical_noise(clean) x = np.random.randint(0, 256 - self.patch_size) y = np.random.randint(0, 256 - self.patch_size) clean_p = clean[y:y + self.patch_size, x:x + self.patch_size] noisy_p = noisy[y:y + self.patch_size, x:x + self.patch_size] if self.train: if np.random.rand() > 0.5: clean_p = clean_p[:, ::-1] noisy_p = noisy_p[:, ::-1] if np.random.rand() > 0.5: clean_p = clean_p[::-1, :] noisy_p = noisy_p[::-1, :] # 归一化到 [0,1],保留绝对尺度;训练与推理必须用同一套缩放宽放 clean_p = torch.from_numpy(clean_p / 65535.0).unsqueeze(0).float() noisy_p = torch.from_numpy(noisy_p / 65535.0).unsqueeze(0).float() return noisy_p, clean_p逻辑说明:__getitem__里每次都重新生成一张模拟星场图,再随机裁剪,相当于训练集是无限生成的,不需要预先存文件。翻转只在train=True时做,避免增强进入验证集。归一化直接除以65535,是刻意保留图像绝对尺度。如果你在训练时做分位数截断,推理时就必须沿用同一组分位数。
参数说明:patch_size取128,是感受野和显存占用的折中;取256时模型能学到更大范围背景变化,但训练速度明显变慢。n_samples取2000,每轮生成2000张母图并裁剪,普通CPU也能跑动,GPU训练时这个值可以更高。
4. 用PyTorch把Unet训起来:最小可复现的训练与推理流程
数据生成和Dataset准备好之后,就是模型定义和训练循环。这一章的代码是一个最小可跑通流程,建议按“模型定义→训练→推理”的顺序读,每段都能单独复用。
4.1 从零定义一个Unet降噪模型:通道数、下采样次数与参数量
标准Unet的PyTorch实现网上很多,但不少版本为多分类设计,最后的输出通道和激活函数要改。降噪模型只需要单通道输出,输入也是单通道灰度图。下面这个版本我做了一些裁剪,适合128×128 patch。
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 UNetDenoise(nn.Module): def __init__(self, in_ch=1, base_ch=32): 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, in_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)逻辑说明:forward完全按经典Unet路径走。torch.cat拼接的是解码器上采样结果和编码器同尺寸特征,通道数在拼接后翻倍,所以dec4输入通道是base_ch16,输出恢复到base_ch8,依此类推。输出层用1×1卷积,不加激活函数。
参数说明:base_ch=32时,模型参数量在700万左右,单张消费级显卡就能训。显存紧张就把base_ch降到16,参数量降到约180万,但降噪能力会打折;显存充足时可试试base_ch=64。in_ch固定为1,因为天文降噪是灰度图,不需要RGB三通道。
这个模型没有任何花哨改进,但足以跑通整个流程。先让它收敛,再谈Unet模型改进,比如换成残差块、加注意力,效果都建立在基线正常的前提下。
4.2 训练循环里的关键参数:优化器、损失函数和学习率怎么设
训练部分用Adam优化器加StepLR学习率衰减,损失函数用L1。这是天文降噪里比较稳的一套组合。
import torch.optim as optim from torch.utils.data import DataLoader device = 'cuda' if torch.cuda.is_available() else 'cpu' model = UNetDenoise(in_ch=1, base_ch=32).to(device) loader = DataLoader( AstroDenoiseDataset(patch_size=128, n_samples=2000), batch_size=8, shuffle=True, num_workers=2 ) optimizer = optim.Adam(model.parameters(), lr=1e-3) scheduler = optim.lr_scheduler.StepLR(optimizer, step_size=20, gamma=0.5) loss_fn = nn.L1Loss() for epoch in range(30): model.train() total_loss = 0.0 for noisy, clean in loader: noisy, clean = noisy.to(device), clean.to(device) pred = model(noisy) loss = loss_fn(pred, clean) optimizer.zero_grad() loss.backward() optimizer.step() total_loss += loss.item() * noisy.size(0) scheduler.step() mean_loss = total_loss / len(loader.dataset) print(f"epoch {epoch + 1:02d} loss {mean_loss:.5f} lr {scheduler.get_last_lr()[0]:.2e}")逻辑说明:为什么用L1而不是MSE,前面已经说过。Adam初始学习率1e-3,对大多数Unet降噪任务适用;如果前几个epoch loss完全不动,可以把lr调到3e-3试一次。StepLR每20个epoch把学习率乘0.5,训练后期用更小学习率精修细节。
参数说明:batch_size=8是base_ch=32在128×128 patch下的安全值,显存8GB以上可以开到16;显存只有4GB就把patch_size改成96或64,不要硬撑。num_workers在Windows上建议设0,否则DataLoader可能报错;Linux可以设2或4。
跑完30个epoch,loss一般会降到0.01以下。有两个经验判断:如果loss在0.02附近下不去,多半是训练数据里噪声占比太高,或patch里含星点的样本太少;如果loss快速降到0.005以下,模型大概率已经开始把星点当成噪声抹掉,需要降低噪声强度或调损失权重。
4.3 对大图做滑窗推理:如何在一张FITS图上跑出降噪结果
训练保存下来的权重,最后要处理一张或多张真实FITS图。FITS图尺寸往往是2048×2048甚至更大,整图直接进网络会显存溢出,常见做法是滑窗推理,把大图切成patch,分别预测再拼回去。
import torch model.eval() with torch.no_grad(): img = np.load('noisy_2048.npy') # 已按训练时的尺度归一化到 [0,1] h, w = img.shape patch_size = 128 result = np.zeros_like(img) for i in range(0, h, patch_size): for j in range(0, w, patch_size): block = img[i:i + patch_size, j:j + patch_size] ph, pw = block.shape # 边缘不够patch大小的地方用edge填充,推理后裁掉 padded = np.pad(block, ((0, patch_size - ph), (0, patch_size - pw)), mode='edge') inp = torch.from_numpy(padded).unsqueeze(0).unsqueeze(0).to(device) out = model(inp).squeeze().cpu().numpy() result[i:i + ph, j:j + pw] = out[:ph, :pw] np.save('denoised_2048.npy', result)逻辑说明:双循环按patch_size步长滑窗,每个小块单独推理。如果原图宽高不能被patch整除,最后一行或一列会不够大,这里用edge模式填充后推理,再把填充部分裁掉。最终结果和输入尺寸完全一致。滑窗之间没有重叠,所以拼接缝处可能出现轻微差异,如果在意,可以用stride=patch_size/2并做加权平均,但多数场景直接跑也能用。
参数说明:推理时记得把模型切成eval模式并包在torch.no_grad()里,否则速度会差很多,还会多占显存。如果一张大图要处理很久,可以先把数据转成float16再推理,肉眼几乎看不出差别。
5. 天文降噪常见问题:训练不收敛、星星被抹平、边缘伪影的排查方向
这一章写得像翻车记录,因为这些问题我自己都踩过。天文图像降噪有个特点:视觉上“看起来干净”不等于“降噪成功”,星点被抹掉是最隐蔽的失败方式。
5.1 现象:loss一直降,输出却接近一张平均图
训练时L1 loss从0.1稳步降到0.01,看起来一切正常,但把模型输出保存成图片一看,整张图接近一个常数,背景被压平,星点几乎全部消失。这种情况最容易出现在直接拿原始FITS数据做训练的项目里。
原因有两类。第一类是归一化出了问题:如果直接对0到65535的FITS图除以65535,大部分像素的数值会集中在0.002附近,信号被压缩到极低动态范围,网络在训练早期就学会输出均值来降低loss。L1损失在这个尺度下,对星点这类少数像素的错误几乎不敏感。第二类是数据配比问题:sky_level设得太高、星点强度太低,星点在loss里占比太小,模型自然忽略它们。
解决:训练前先做分位数截断,把[1%, 99.5%]分位数之间的像素线性映射到[0,1],推理时沿用同一套参数。同时把sky_level降到50到100,amplitude下限从20提高到50,让模型更早注意到星点。
5.2 现象:星点被抹成“荷包蛋”,边缘还有振铃伪影
降噪后的星点中心过亮、周围出现一圈暗环,像荷包蛋;亮星周围还有同心波纹。这种情况比直接抹掉星点更隐蔽,因为图像看起来确实“干净了”,但星点形状已经失真。
原因主要是训练数据的PSF过于理想:所有星点fwhm都设为同一个固定值,模型把“固定尺寸的高斯斑”当成唯一模式,遇到真实中不同尺寸的星点就开始出错。另一个原因是解码器上采样次数太多或基础通道数太少,模型表达能力不足以精细重建星点轮廓,只能用近似形状硬凑。
解决:把fwhm改成随机采样,比如np.random.uniform(1.5, 4.0),并在PSF采样时加入亚像素偏移。模型端可以把base_ch从32提升到48,或把普通卷积块换成残差块。验证时把降噪前后同一颗星的FWHM拉出来对比,如果变宽超过0.3像素,说明模型在“抹平”星点。
5.3 现象:训练集上PSNR很高,换成真实FITS图就失效
合成测试集上PSNR能到30dB以上,拿一张真实短曝光FITS图进去,输出反而出现斑块和条纹。很多人会怀疑模型过拟合,实际往往是数据分布对不上。
原因是真实图像里包含了合成数据没模拟的偏置电平、暗电流梯度和坏列。偏置和暗电流在图像上表现为大范围缓变结构,模型没见过这种低频成分,就把背景当成噪声的一部分来抹,结果背景被抹平,暗电流梯度被放大成条纹。
解决:训练前先对真实数据做预处理,至少减掉master dark和bias,做一次flat矫正。如果不想过度依赖预处理,可以在合成数据里加入随机缓变背景场,比如用低频正弦场模拟暗电流梯度,让模型学会区分“梯度”和“噪声”。这一步对落地到真实观测非常关键。
5.4 现象:batch size上不去,一碰就显存溢出
batch_size设12,运行几个batch之后CUDA out of memory,降到8又能跑。这属于Unet降噪的典型显存问题,不是代码写得有问题。
原因是Unet的显存大头在解码器拼接后的特征图。128×128×64的中间张量有不少份,batch_size稍微涨一点,显存占用就跳一个台阶。
解决:优先降base_ch,从32降到16,显存占用几乎减半;其次才是缩小patch到96。用torch.cuda.amp混合精度能省约30%显存,收益不如降通道明显。两种方法合用,batch_size 32在8GB卡上也能跑起来。
6. 进阶验证与轻量技巧:用PSNR、FWHM和残差学习判断降噪是否成功
模型训完,第一步不是看效果图,而是量化验证。天文图像降噪有两个指标比PSNR更贴近实际:星点FWHM和背景标准差。PSNR是整图层面的,星点只占极少像素,PSNR高但星点被抹掉的案例很常见。FWHM能看出星点有没有变宽,背景标准差能看出噪声有没有降下来。
def psnr(clean, pred, peak=1.0): mse = np.mean((clean - pred) ** 2) return 10 * np.log10(peak * peak / mse) def fwhm_from_image(img, x, y): # 在星点周围取一块区域,沿x轴找半高宽,快速对比降噪前后 cut = img[y - 5:y + 5, x - 5:x + 5] profile = cut.max(axis=0) half = (profile.max() + profile.min()) / 2 above = np.where(profile >= half)[0] return above[-1] - above[0] + 1第一段psnr是标准公式。第二段是我常用的粗测方法:在星点附近取小窗口,沿x轴取最大亮度投影,找到超过半高宽度的像素范围。精度一般,但用来对比降噪前后FWHM是否变宽足够。peak要与训练归一化尺度一致,训练缩放到[0,1]就用1.0,保留16位尺度就用65535。
我验证时按这个顺序看:先看FWHM有没有明显变化,再看背景std降了多少,最后才看PSNR和SSIM。FWHM变宽超过0.3像素说明星点被破坏;背景std降到原来的三分之一以下说明噪声有实质下降;PSNR只要不异常低,就代表整体数值误差可接受。这个顺序能避免被“好看”的降噪图误导。
想让模型在相同训练成本下恢复更多细节,一个改动很小但收益稳定的Unet模型改进是残差学习:让模型输出噪声图,而不是直接输出干净图。训练目标改为noisy - clean,推理时用输入减去输出。这样模型输出的数值范围更小、更集中在噪声分量上,训练更容易稳定。这个改动只改一行数据准备和一行推理代码,却经常能让FWHM下降0.1到0.2像素。
我在这上面吃过一次亏:第一次拿真实FITS图验证,背景确实干净了,但所有星点都变成小圆斑,一测FWHM比原始图宽了接近0.5像素。后来把归一化改成百分位截断,再用残差学习重训,问题才解决。建议你在正式处理批量数据前,先保存两三颗亮星的FWHM,降噪前后对比一次再做全流程。希望帮到你。
本文还有配套的精品资源,点击获取