简介:毕设项目围绕红外图像非均匀性校正展开,基于Python和卷积神经网络实现,提出一种借助两个残差块级联进行残差学习的RNUC网络。资源面向毕业设计、课程设计及工程实训人群,既适合初学者入门深度学习与图像处理,也能为中期项目立项提供参考。压缩包内含8个文件,以3个Python源码脚本和模型结构摘要、训练日志文本为主,另含模型导出图片与说明文档,zip包整体约374KB,体积小巧但结构清晰。当前已有165人学习下载。通过阅读模型定义、训练入口与摘要文件,可以复现非均匀性校正网络的基本流程,理解残差级联如何提升校正效果,也能观察特征提取与模型导出环节;在此基础上进行参数调整或模块替换,再结合自己的实验数据训练,即可较快形成一篇可供答辩使用的毕业设计原型。对于图像处理方向的课程作业或初期项目,这份资源是一份紧凑而完整的参考样例。
1. 红外图像非均匀性校正:用卷积神经网络把条纹噪声交给残差网络处理
红外焦平面阵列(IRFPA)成像时,探测器像元响应率不一致,出图总带竖向条纹或固定斑块,这就是红外图像非均匀性。传统两点校正需要黑体标定,标定条件一变就容易失效。这个项目给的思路是把校正当成图像到图像的回归任务,用 Python 实现的卷积神经网络处理:RNUC(Residual-learning based Non-uniformity Correction Net)用两个残差块级联做残差学习,配合合并式特征提取单元,从单帧红外图上直接预测条纹噪声,再与原图相减得到干净输出。它对毕业设计、课程设计尤其合适——网络轻、效果能用图表说话,训练流程也适合在普通显卡上跑完。接下来我把结构拆开,从代码到训练再到排查一条条过。
2. RNUC网络结构拆解:残差块级联、合并式特征提取与model_summary解读
2.1 非均匀性校正的数学建模:为什么学噪声比学干净图更容易收敛
先看问题本质。红外图像的非均匀性可以近似写成:
I(x, y) = G(x, y)·S(x, y) + B(x, y) + n(x, y)
其中 G 是像元的非均匀增益,B 是偏置,S 是真实辐射响应,n 是随机噪声。G 和 B 在整幅图上形成固定模式噪声(Fixed Pattern Noise, FPN),也就是肉眼看到的竖条纹。传统两点校正法通过黑体标定计算增益和偏置系数,优点是直观、计算快,缺点是系数随环境温度和工作状态漂移,一旦漂移,校正效果直接变差。
这个项目的出发点不一样:把 FPN 当做一个可学习的残差分量。网络输入是带条纹的红外图,不直接回归干净图,而是回归残差噪声 n̂,再用原图 I 减去 n̂ 得到校正图。这就是残差学习(Residual Learning)在非均匀性校正里的应用。相比直接学干净图,学噪声分量更符合物理意义——噪声和条纹是高度结构化的固定模式,网络要拟合的映射更简单。在训练数据量不大的毕业设计场景里,残差学习收敛更快、更稳。
那训练数据从哪来?真实 IRFPA 很难同时拿到“带条纹图”和“完美干净图”。常见做法是合成:取灰度图作为真值,在列方向乘一个缓慢变化的增益、叠加列偏置,生成退化图。用一批成对数据训练 I → (I − n̂),不需要真实硬件标定,这也是这个项目能在纯 Python 环境里复现的原因。
2.2 合并式特征提取单元:三路卷积拼接与1×1卷积降维的实现
网络骨干不是标准 VGG 式堆叠,而是先过合并式特征提取单元。它的设计动机很直接:竖条纹的宽度不是固定的,窄的几条像素,宽的能到十几条,单用 3×3 卷积感受野不够;所以用 3×3、5×5、7×7 三路并行,把多尺度条纹响应都捕捉到,再拼接、降维。我给出一份可用的实现:
# models.py —— 合并式特征提取单元 import tensorflow as tf from tensorflow.keras import layers, Model def merge_extract_unit(x, filters=32): # x: 输入特征图, shape=(batch, h, w, c) b1 = layers.Conv2D(filters, 3, padding='same', activation='relu')(x) b2 = layers.Conv2D(filters, 5, padding='same', activation='relu')(x) b3 = layers.Conv2D(filters, 7, padding='same', activation='relu')(x) # 在通道维拼接三路特征 merged = layers.Concatenate(axis=-1)([b1, b2, b3]) # 1x1卷积压缩通道,并保留原始通道数 out = layers.Conv2D(x.shape[-1], 1, padding='same')(merged) return out核心在后面两行。三路卷积各自输出 filters 个通道,拼接后总通道数是 3×filters,直接往下传,后续残差块的计算量会成倍上涨。1×1 卷积在这里做两件事:通道混合与降维,把 96 通道压回输入通道数,保证后面残差块的输入尺寸和主干一致。filters 我一般先给 32,显存紧张就 16,图像噪声复杂给到 48。5×5、7×7 卷积参数多,但对条纹这种列方向相关性很强的噪声模式,更大感受野能覆盖更宽的条纹区域,这个开销值得花。
2.3 两个残差块级联:串行精修条纹特征的结构设计与参数量
网络主体是两个残差块级联。这里“级联”要着重强调:第二个残差块的输入是第一个的输出,两个块串行连接,形成“特征提取 → 残差精修 → 再精修”的层级。每个残差块内部是经典结构:两组“Conv + BN + ReLU”,加一条跳跃连接,最后主分支与 shortcut 逐点相加。
# models.py —— 残差块定义 def residual_block(x, filters=32): shortcut = x h = layers.Conv2D(filters, 3, padding='same')(x) h = layers.BatchNormalization()(h) h = layers.ReLU()(h) h = layers.Conv2D(filters, 3, padding='same')(h) h = layers.BatchNormalization()(h) h = layers.ReLU()(h) # 主分支输出要回到shortcut的通道数 h = layers.Conv2D(x.shape[-1], 3, padding='same')(h) return layers.Add()([shortcut, h])从参数量看,单通道输入时第一层 3×3 卷积输入 1 通道输出 32 通道,权重是 1×32×3×3=288 个;残差块内两个 32 通道的 3×3 卷积,每个有 32×32×3×3=9216 个权重,最后压回单通道的卷积只有 32×1×3×3=288 个。整个网络跑下来,参数量在 3 万到 5 万区间,属于轻量级模型,CPU 都能做前向推理。这个体量对答辩现场演示非常关键——笔记本上也能实时跑。
注意两级残差块级联和“双分支并联”不同。并联是两条支路同时处理,后面再融合,优势是梯度传播路径短、训练稳;级联强调逐层抽象,第一块定位条纹候选区域,第二块修正幅度和边界,对全局方向性强的条纹噪声,级联的精修作用更明显。代价是训练时梯度要穿过两级块,学习率控制不好容易后面某块学不到东西。所以第 4 章讲训练时会强调学习率衰减,这里先记住这个因果。
2.4 完整网络组装与model_summary、export model.png的生成
把合并式特征提取单元和两个残差块串起来,再接一个 3×3 卷积输出单通道噪声图,最后用 Subtract 层做原图减噪声。完整结构如下:
# models.py —— RNUC 完整模型 def build_rnuc(input_shape=(256, 256, 1)): inputs = layers.Input(shape=input_shape) x = merge_extract_unit(inputs, filters=32) x = residual_block(x, filters=32) x = residual_block(x, filters=32) # 预测固定条纹噪声,单通道,尺寸与原图一致 noise = layers.Conv2D(1, 3, padding='same', name='noise_head')(x) # 残差学习:原图减预测噪声得到校正图 corrected = layers.Subtract(name='corrected')([inputs, noise]) return Model(inputs, corrected)训输出的是 corrected 层,直接和干净真值算 MSE。生成 model_summary.txt 用重定向:
python -c "from models import build_rnuc; m = build_rnuc(); print(m.summary())" > model_summary.txt看 summary 时我关注三个位置。第一,MergeExtractUnit 末尾的 1×1 卷积输出 shape 是否和输入一致,不一致说明后面 Add 层维度对不上。第二,两个残差块之间特征图尺寸是否稳定保持,某个 Conv 漏了 padding='same' 中间尺寸缩小就接不上。第三,noise_head 输出通道必须是 1,否则 Subtract 层广播规则不对,训练 loss 可能变 nan。仓库里还带了 export model.png,这是模型结构图,用一行代码导出:
from tensorflow.keras.utils import plot_model plot_model(build_rnuc(), to_file='export_model.png', show_shapes=True)答辩 PPT 里放这张图,比贴十几行代码直观得多。一个小的提示:plot_model 默认靠 pydot 和 Graphviz,Windows 上装好 Graphviz 并把 bin 目录加进 PATH,否则会报"pydotfailed to import"。
3. 训练数据与主流程跑通:mian.py脚本、合成条纹和TensorBoard日志
3.1 合成条纹训练数据:列增益与偏置模板的生成方法
仓库里没有打包数据集,README 只给了目录结构。训练对要自己生成。我用的方式是把条纹拆成乘性增益和加性偏置两块,分别对应像元响应率不一致和暗电流不一致。下面这段工具代码放在 utilis.py 里:
# utilis.py —— 合成条纹噪声工具 import numpy as np def generate_stripe_noise(shape=(256, 256), gain_range=0.06, col_range=8): h, w = shape # 每列一个增益系数和偏置系数 col_gain = 1.0 + np.random.uniform(-gain_range, gain_range, size=(1, w)) col_bias = np.random.uniform(-col_range, col_range, size=(1, w)) # 扩展到整幅图像的模板 gain_map = np.tile(col_gain, (h, 1)) bias_map = np.tile(col_bias, (h, 1)) return gain_map, bias_map def apply_stripe(clean, gain_map, bias_map): noisy = clean * gain_map + bias_map return np.clip(noisy, 0, 255).astype(np.float32)参数方面,gain_range 控制增益起伏幅度,0.03 到 0.08 比较常见。太小条纹肉眼看不清,训练了也看不出效果;太大网络学到的是“压暗图像”而不是“去除条纹”。col_range 控制偏置强度,单位是灰度值,我一般给 5 到 15。每张训练图用不同随机种子生成,保证条纹位置不固定,否则网络会把所有样本的条纹位置背下来,换图就失效。生成完成后把成对数据存成 npy:
python -c " import numpy as np from utilis import generate_stripe_noise, apply_stripe import cv2, glob clean_paths = glob.glob('data/clean/*.png') for i, p in enumerate(clean_paths[:500]): img = cv2.imread(p, cv2.IMREAD_GRAYSCALE).astype(np.float32) gain, bias = generate_stripe_noise((256, 256)) noisy = apply_stripe(img, gain, bias) "这里提醒一点:glob 匹配到的文件要过滤扩展名白名单,数据目录里混入 .DS_Store 或临时文件会直接读成 None,这在后面避坑章会展开。
3.2 mian.py训练脚本拆解:归一化、超参与回调配置
主脚本文件名叫 mian.py,这是 main.py 的笔误。这个拼写错误在毕设代码里很常见,不影响运行,但迁移到自己工程时建议改回 main.py。脚本核心逻辑按我习惯的方式拆解如下:
# mian.py —— RNUC 训练主流程 import numpy as np import tensorflow as tf from tensorflow.keras.callbacks import ModelCheckpoint, TensorBoard, ReduceLROnPlateau from models import build_rnuc EPOCHS = 200 BATCH_SIZE = 16 LR = 1e-4 # 读取已生成的成对数据 train_noisy = np.load('data/train_noisy.npy') train_clean = np.load('data/train_clean.npy') val_noisy = np.load('data/val_noisy.npy') val_clean = np.load('data/val_clean.npy') # 归一化到[0,1],输入和标签必须同尺度 train_noisy = train_noisy / 255.0 train_clean = train_clean / 255.0 val_noisy = val_noisy / 255.0 val_clean = val_clean / 255.0 # 构建模型并编译 model = build_rnuc(input_shape=(256, 256, 1)) model.compile( optimizer=tf.keras.optimizers.Adam(learning_rate=LR), loss='mse', metrics=['mae'] ) # 训练并保存日志与权重 model.fit( train_noisy, train_clean, batch_size=BATCH_SIZE, epochs=EPOCHS, validation_data=(val_noisy, val_clean), callbacks=[ ModelCheckpoint('ckpt/rnuc_best.h5', save_best_only=True, monitor='val_loss'), TensorBoard(log_dir='logs'), ReduceLROnPlateau(monitor='val_loss', factor=0.5, patience=20, min_lr=1e-6) ] )归一化顺序是个经典翻车点。clean 图除了,noisy 图忘了除,或反过来,loss 一开始会大得离谱,看着像不收敛,其实是数据尺度不匹配。Adam 初始学习率 1e-4 是我在这个项目里的默认起点。直接上 1e-3,前期 loss 跌得快,中后期曲线一定震荡,因为残差网络后面的损失面很平,学习率太大就在最优值附近来回跳。ReduceLROnPlateau 设 patience=20,连续 20 轮验证 loss 不降就把学习率减半,这个保护对残差类网络非常关键。
还有一个版本兼容性问题。logs 目录里那个 events.out.tfevents.1531822717.DESKTOP-754RGN1 是 TensorFlow 早期日志格式,这个时间戳对应 2018 年左右,原始代码大概率基于 TensorFlow 1.x。如果你现在用 TensorFlow 2.x 打开,模型代码里没有 tf.compat.v1 的兼容写法会报错,需要把 import 调整成:
import tensorflow.compat.v1 as tf tf.disable_v2_behavior()或者按本文代码改成 tf.keras 高层 API,就不需要 v1 兼容层。日志读取上,新版 TensorBoard 对老格式一般能直接读,但版本跨度太大时会空白,具体的排查在第 4 章。
3.3 单图推理与日志复盘:events文件怎么用、模型怎么落地
训练完拿到 ckpt/rnuc_best.h5,单图推理的代码很简短:
# 加载训练好的权重并推理 import cv2 import numpy as np from models import build_rnuc model = build_rnuc() model.load_weights('ckpt/rnuc_best.h5') img = cv2.imread('test/striped.png', cv2.IMREAD_GRAYSCALE).astype(np.float32) / 255.0 out = model.predict(img[None, ..., None])[0] cv2.imwrite('result/corrected.png', (out[..., 0] * 255).astype(np.uint8))predict 时输入要补两个维度:一个 batch 维,一个通道维,img.shape 从 (256, 256) 变成 (1, 256, 256, 1)。输出还要把附加维度去掉再写图,这是新手最容易报 shape 错误的地方。推理结果保存为 PNG,不要用 JPEG,JPEG 的有损压缩会引入新的条纹伪影,干扰效果评估。
日志复盘看 TensorBoard:
tensorboard --logdir=logs打开 http://localhost:6006 看 Scalars 页面。我主要看训练 loss 和验证 loss 的关系。健康形态是同步下降,验证 loss 比训练略高;过拟合形态是训练一路走低、验证到某轮开始反弹,这时回退 epoch 或加强数据增广;不收敛形态是 loss 很慢地跌到平台就完全不动,多半是学习率太小,或条纹生成方式太单一。logs 路径有中文用户名时 TensorBoard 可能读不到,把 logdir 换成纯英文绝对路径是逐步排查时的常见解法。
4. 训练避坑与常见问题排查:五个高频现场逐条拆
4.1 现象:训练到300轮左右loss震荡,验证loss不再下降
原因:Adam 在残差网络的平台期还用相对较高的学习率持续更新参数,损失面后期很平缓,学习率不降下来就会在最优值附近来回摆动,loss 曲线像锯齿一样起伏。
解决:给优化器挂学习率衰减。我用的是 ReduceLROnPlateau,监测 val_loss,patience 给 20,factor 给 0.5,学习率在平台期自动降档。如果衰减后还震荡,检查 BN 的 momentum,默认 0.99 在 batch size 小于 16 时统计不稳定,调到 0.995 能缓解。另一种情况是学习率降到最后仍不收敛,那就不是调参问题,是训练数据里的条纹模式太少,回到第 3 章把 gain_range 和 col_range 范围加大重新生成。
4.2 现象:loss很低但校正图上还能看到竖条纹痕迹
原因:MSE 损失对条纹这类高频分量不敏感。网络学到的是整体亮度补偿,条纹被压淡但没有被干净消除。本质原因是像素级 MSE 把“亮度差异”和“条纹结构差异”混在一个标量里,条纹占整幅图的比例小,梯度被平滑区域的误差稀释。
解决:在损失里加一个列方向平滑项,惩罚校正图在横轴方向的突变。竖条纹是沿垂直方向延伸的,差异发生在相邻列之间,所以要沿宽(x)方向做差分:
# 额外损失项:列方向平滑性约束 def column_smoothness_loss(y_true, y_pred): # 沿宽方向差分,捕获相邻列之间的跳变 diff = tf.abs(y_pred[:, :, :, 1:] - y_pred[:, :, :, :-1]) return tf.reduce_mean(diff) model.compile( optimizer=tf.keras.optimizers.Adam(learning_rate=1e-4), loss=lambda y_true, y_pred: tf.reduce_mean(tf.square(y_true - y_pred)) + 0.05 * column_smoothness_loss(y_true, y_pred) )0.05 是个起始权重,我一般从 0.01 试起。每调大一档,条纹更干净,但图会微微变糊。如果发现边缘开始模糊,把权重降回,改用 L1 损失替代 MSE 作为主损失,L1 对条纹轮廓的梯度响应更直接,保留细节能力更强。
4.3 现象:TensorBoard空白,events文件存在但曲线不显示
原因:两种最常见。一是 TensorFlow 和 TensorBoard 版本不匹配,早期 tfevents 格式用最新版 TensorBoard 读取,偶发空白;二是 logdir 传成了具体文件路径,比如 tensorboard --logdir=logs/events.out.tfevents.xxx,而 TensorBoard 要求传目录,目录下有以 events.out.tfevents 开头的文件才会被扫描。
解决:先确认传的是目录,再看路径里有没有中文。原来这个仓库的机器名是 DESKTOP-754RGN1,Windows 下用户名如果是中文,日志路径会带上中文字符,TensorBoard 扫描时会漏。把整个 logs 目录拷到 D:\tb_logs 这种纯英文绝对路径下,基本能解决。还不行就在训练脚本里强制指定日志目录:
TensorBoard(log_dir='D:/tb_logs/rnuc_run001', histogram_freq=1)顺带一提,histogram_freq=1 会把每层权重的分布也写进日志,答辩时展示网络在训练过程中权重分布的变化,比单纯一条 loss 曲线更有说服力。
4.4 现象:换实拍红外图后校正结果发糊,边缘细节丢失
原因:训练样本是合成条纹,模型学到的噪声分布偏向训练时的条纹形态。真实 IRFPA 的条纹宽度、盲元分布、随机噪声叠加更复杂,模型把真实图中的高频细节也当条纹减掉了。这是合成数据与真实数据分布不一致导致的常见失效。
解决:一靠数据增广,训练时随机水平翻转、垂直翻转、亮度抖动,让网络对条纹方向更稳健。注意条纹是列方向的,水平翻转会改变条纹的左右分布,垂直翻转不会改变竖条纹形态,两种翻转都要做,等价于扩充样本多样性。二靠推理时用重叠 patch 方法:把 256×256 图切成 128×128 的块,块之间重叠 8 个像素,逐块推理后拼回,重叠区取平均值。这会削弱拼接缝,也降低边缘重影。附加好处是显存占用小,batch size 可以给更大。
4.5 现象:batch size一调大就OOM崩溃
原因:红外图虽然是单通道,但合并式特征提取单元同时跑 5×5、7×7 卷积,合并后特征图通道数是输入通道的 3 倍,显存占用并不像单通道输入看起来那么小。以 256×256 输入、filters=32 计算,中间特征图尺寸约 256×256×96×4 字节,约 25MB 一帧,叠上 batch、梯度、优化器状态,16 的 batch size 已经不算保守。
解决:优先把训练输入尺寸从 256×256 改成 128×128,模型结构和代码不用动,显存占用直接降为四分之一。filters 从 32 降到 16,参数量减半,效果损失有限。还可以用混合精度训练,在模型编译前加一句:
tf.keras.mixed_precision.set_global_policy('mixed_float16')显存占用大约再降三成。混合精度有个副作用:BN 在 float16 下统计方差不稳,训练时如果出现 loss 为 nan,先关掉混合精度排查,不要在 nan 状态下硬调参数。推理时如果显存也紧张,把输入切成 128 块的 patch 方式同样适用,并且对大图效果更友好。
5. 效果验证与进阶做法:残差直方图、PSNR与消融实验把住质量关
5.1 残差图和PSNR双指标验证
训练好的模型不能只看训练 loss。我习惯先算残差:原图减去校正图,得到模型预测的噪声分量。如果网络学到了条纹,残差图应该是一张只包含列向条纹和极少量随机噪声的图;如果残差图里出现了物体边缘轮廓,说明网络把真实细节误删了,校正图细节一定受损。残差直方图更直观,正常残差分布接近以 0 为中心、展宽很小的高斯分布;如果直方图出现双峰或多峰,说明校正不彻底,还有残余的结构性条纹。
# 快速PSNR与SSIM评估 import numpy as np import cv2 from skimage.metrics import structural_similarity as ssim def evaluate(model, noisy, clean): pred = model.predict(noisy[None, ...])[0] mse_val = np.mean((pred - clean) ** 2) psnr_val = 10 * np.log10(1.0 / mse_val) ssim_val = ssim(pred, clean, data_range=1.0) return psnr_val, ssim_val, pred这里要注意,noisy 和 clean 都得是 [0,1] 的 float32,PSNR 公式分子才是 1.0。如果数据还是 0 到 255,分子要改成 65025。毕业论文里通常对比三组 PSNR:原条纹图对干净图、校正图对干净图、两点校正图对干净图。校正图相比原图提升 3dB 以上,肉眼才能明确感到“校正有效果”。
5.2 控制变量消融:证明两个残差块都有存在价值
答辩时老师最常问“为什么用两个残差块”。提前做消融实验比临场解释可靠。把第二个残差块注释掉,保持其余结构、数据、超参完全不变,重新训练同一轮数,在验证集上测 PSNR 和 SSIM,记录成对比表。同理可以消融合并式特征提取单元——把三路拼接改成单路 3×3 卷积,看 PSNR 掉多少。这种控制变量实验直接对应毕业论文的实验章节,图表都有说服力,而且跑起来成本极低,模型小,一次训练十分钟级。
做过几次这类项目后,我的习惯是训练完成后强制走一遍残差直方图检查:如果残差里还有竖条纹分量,说明损失函数权重没调到位,回到第 4 章的平滑项重新调参,而不是直接换网络结构。从那以后我每次跑非均匀性校正项目,都会先把这条验证链路固定下来再开始训练,省下大量返工时间。希望帮到你。
本文还有配套的精品资源,点击获取