
简介基于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_ch32在128×128的patch上训练一轮batch size8大约需要2到3GB显存大多数个人电脑都跑得动。3. 训练数据从哪来用合成星场生成带噪图别一上来就啃观测数据训练Unet降噪需要一个关键的东西成对的“噪声图—干净图”。真实天文观测里没有干净的ground truth用几十张短曝光叠加出来的参考图也会残留噪声和跟踪误差。所以我一般会先用合成数据把模型跑通。合成数据最大的好处是噪声模型完全可控想要多少星点、什么PSF宽度、什么信噪比都由参数决定生成的训练样本之间互不重复。3.1 用Python画出模拟星场PSF、天光背景与星等的模拟第一步是生成一张“理想无噪声”的天文图像。常见做法是先铺一个恒定的天光背景然后在随机位置放星点每个星点用二维高斯核做PSF再乘以星点强度。import numpy as np def gaussian_psf(size15, fwhm2.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(h256, w256, n_stars80, fwhm2.5, sky_level120.0): # sky_level 是天光背景的电子数单位 ADU16位图像典型值在 100 附近 img np.full((h, w), sky_level, dtypenp.float32) psf gaussian_psf(fwhmfwhm) 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倍以上。fwhm2.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, sizenoisy.shape).astype(np.float32) # 坏像素少量全黑或过亮的点对应热像素和坏列 bad_mask np.random.rand(*noisy.shape) 0.002 noisy[bad_mask] np.random.choice([0, 65535], sizebad_mask.sum()) return np.clip(noisy, 0, 65535)逻辑说明np.random.poisson(clean)的输入是期望值输出是服从泊松分布的随机整数正好模拟光子到达的随机性。读出噪声用零均值高斯分布叠加。坏像素概率0.002256×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_size128, n_samples2000, trainTrue): 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_stars80, fwhm2.5, sky_level120.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__里每次都重新生成一张模拟星场图再随机裁剪相当于训练集是无限生成的不需要预先存文件。翻转只在trainTrue时做避免增强进入验证集。归一化直接除以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, padding1), nn.BatchNorm2d(out_ch), nn.ReLU(inplaceTrue), nn.Conv2d(out_ch, out_ch, 3, padding1), nn.BatchNorm2d(out_ch), nn.ReLU(inplaceTrue) ) def forward(self, x): return self.conv(x) class UNetDenoise(nn.Module): def __init__(self, in_ch1, base_ch32): 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, stride2) self.dec4 DoubleConv(base_ch * 16, base_ch * 8) self.up3 nn.ConvTranspose2d(base_ch * 8, base_ch * 4, 2, stride2) self.dec3 DoubleConv(base_ch * 8, base_ch * 4) self.up2 nn.ConvTranspose2d(base_ch * 4, base_ch * 2, 2, stride2) self.dec2 DoubleConv(base_ch * 4, base_ch * 2) self.up1 nn.ConvTranspose2d(base_ch * 2, base_ch, 2, stride2) 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], dim1)) d3 self.dec3(torch.cat([self.up3(d4), e3], dim1)) d2 self.dec2(torch.cat([self.up2(d3), e2], dim1)) d1 self.dec1(torch.cat([self.up1(d2), e1], dim1)) return self.out(d1)逻辑说明forward完全按经典Unet路径走。torch.cat拼接的是解码器上采样结果和编码器同尺寸特征通道数在拼接后翻倍所以dec4输入通道是base_ch16输出恢复到base_ch8依此类推。输出层用1×1卷积不加激活函数。参数说明base_ch32时模型参数量在700万左右单张消费级显卡就能训。显存紧张就把base_ch降到16参数量降到约180万但降噪能力会打折显存充足时可试试base_ch64。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_ch1, base_ch32).to(device) loader DataLoader( AstroDenoiseDataset(patch_size128, n_samples2000), batch_size8, shuffleTrue, num_workers2 ) optimizer optim.Adam(model.parameters(), lr1e-3) scheduler optim.lr_scheduler.StepLR(optimizer, step_size20, gamma0.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(fepoch {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_size8是base_ch32在128×128 patch下的安全值显存8GB以上可以开到16显存只有4GB就把patch_size改成96或64不要硬撑。num_workers在Windows上建议设0否则DataLoader可能报错Linux可以设2或4。跑完30个epochloss一般会降到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)), modeedge) 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模式填充后推理再把填充部分裁掉。最终结果和输入尺寸完全一致。滑窗之间没有重叠所以拼接缝处可能出现轻微差异如果在意可以用stridepatch_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到100amplitude下限从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, peak1.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(axis0) 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降噪前后对比一次再做全流程。希望帮到你。本文还有配套的精品资源点击获取