
简介基于DCGAN的低对比度红外图像增强项目面向从事红外图像处理的研究者和开发者针对红外图像对比度低、轮廓模糊等问题提供一套完整的深度增强方案。压缩包共16个文件约21.71MB含4个Python脚本训练、数据预处理、推理、3个H5预训练权重文件、6张JPEG样例图像、2张PNG效果对比图以及1份Markdown说明文档结构清晰便于按流程复现。已有91人学习下载属于可直接运行的实战项目。源码中给出DCGAN生成器与判别器网络结构、训练迭代细节与超参数设置并配套数据预处理和结果可视化脚本读者可借此理解对抗训练在红外增强中的实际应用快速迁移到夜视监控、气象观测等场景。整体来看这是一份兼顾理论参考与工程实现的优质资源能够帮助上手低对比度红外图像的增强算法开发。1. 低对比度红外图像增强项目拆解DCGAN 为什么值得投入做红外图像增强的人多数是从直方图均衡化HE或者 CLAHE 入手的。这两招在可见光图像上很管用但放到红外图像上最典型的问题就是背景被拉得太亮目标人、车、热源反而和背景融在一起对比度没救回来噪点倒是放大了。夜视设备、安防监控、测温仪拿到的红外图对比度低是常态不是个例。基于 DCGAN 实现低对比度红外图像增强思路和传统方法完全不同它不靠手工设计映射曲线而是让生成器自己学“低对比度输入 → 高对比度输出”的映射关系判别器负责把增强结果往“真实高对比度分布”上逼。这个项目源码的核心价值在于它给出一条完整的、可复现的深度学习实战路径从配对数据怎么造、到生成器和判别器怎么搭、再到训练参数怎么调都有迹可循。适合正在做深度学习实战项目、机器视觉项目手头有红外数据但效果一直提不上去的工程师。2. 数据准备是第一步没有配对红外数据DCGAN 学不到东西2.1 低对比度红外图像的痛点公开数据集里根本没有“增强前/增强后”配对DCGAN 是有监督生成模型训练时必须有“输入低对比度图→ 标签高对比度目标图”成对数据。但红外图像领域和可见光不同公开数据集LITIV、OTCBVS、FLIR 等大多是原始热像序列没有人帮你标注“这张是增强前、那张是增强后”。我第一次跑这类项目时就吃过亏直接从网上下了一个红外数据集不分青红皂白丢进 DCGAN训练出来的生成器只会复制输入因为判别器根本不知道什么是“增强后的理想分布”。后来我采用的方案是“低频分量当输入原图当目标”对同一张红外原图做高斯低通滤波模拟出“对比度被压低”的状态作为生成器输入原图作为监督目标。这样成对数据不需要人工标注成本接近零而且生成器学到的是“从模糊低对比度恢复到清晰高对比度”的逆映射正好对应实战场景中的增强需求。import cv2 import numpy as np def build_pair(img, sigma3.0): # 高斯模糊模拟低对比度退化sigma 控制退化程度 low cv2.GaussianBlur(img, (0, 0), sigma) # 对比度压制的另一种做法把像素往均值方向压缩 mean np.mean(low) low_contrast (low - mean) * 0.6 mean low_contrast np.clip(low_contrast, 0, 255).astype(np.uint8) return low_contrast, img # 读取红外灰度图 img cv2.imread(ir_frame.png, cv2.IMREAD_GRAYSCALE) low_in, high_target build_pair(img, sigma2.5) cv2.imwrite(input_low.png, low_in) cv2.imwrite(target_high.png, high_target)这段代码的逻辑不复杂但 sigma 和对比度系数 0.6 这两个参数决定了训练集的质量。sigma 太大比如 7 以上低对比度图丢失的细节太多生成器学不出来训练后期会出现输出全是一片灰的情况sigma 太小1 以下输入和目标几乎一样生成器直接退化成恒等映射增强效果为零。我一般把 sigma 设在 2.5 到 4.0 之间配合对比度系数 0.5 到 0.7保证输入图“看得出轮廓但看不清细节”的状态。2.2 红外视频序列的“帧差伪配对”不用标注也能造出针对性数据如果手头有红外视频序列监控、车载热像仪采集的还有更好的办法利用帧间关系造配对。红外视频中静止背景的低对比度几乎是固定的但目标行人、车辆在不同帧中的温度分布有变化相邻帧之间取帧差可以得到“该区域目标相对背景的对比度变化”。具体做法是取同一场景下、间隔几帧的两张图短曝光或低增益的那一帧当作低对比度输入正常曝光的那一帧当作高对比度目标。这对固定机位的监控视频尤其好用。cap cv2.VideoCapture(ir_video.mp4) frames [] while True: ret, frame cap.read() if not ret: break gray cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) frames.append(gray) # 取间隔 5 帧的两图作为伪配对 inputs, targets [], [] for i in range(0, len(frames) - 5, 5): inputs.append(frames[i]) targets.append(frames[i 5]) # 保存成训练用的 npy 文件避免每次训练都重新读视频 np.save(ir_inputs.npy, np.stack(inputs)) np.save(ir_targets.npy, np.stack(targets)) print(配对数量:, len(inputs))需要特别注意一个参数帧间隔。间隔太小1 到 2 帧两帧差距太小输入和目标几乎一样间隔太大20 帧以上目标可能已经离开画面或者姿态变化太大配对失去意义。5 到 8 帧是相对稳的范围。另外红外视频通常有固定噪声模式条纹噪声如果直接喂给网络生成器会把条纹噪声也当作“特征”学进去输出图会有横条纹。所以入网之前要做一次中值滤波去噪这是很多深度学习实战项目容易漏掉的一步。2.3 Dataset 封装与数据增强Batch 里不能全是同一场景的图数据准备好了接下来用 PyTorch 的 Dataset 封装。红外图像增强项目里有个容易被忽视的问题红外视频相邻帧高度相似如果按顺序取 batch一个 batch 里全是同一个场景的图判别器很快就“记住”了这些图训练过程会变得非常不稳定这是实际跑模型时大概率会遇到的翻车点。解决方法是每个 epoch 开始前打乱数据并在 Dataset 里做随机裁剪和随机翻转。from torch.utils.data import Dataset import torchvision.transforms as T class InfraredDataset(Dataset): def __init__(self, input_path, target_path, crop_size256): self.inputs np.load(input_path) self.targets np.load(target_path) self.crop_size crop_size self.transform T.Compose([ T.RandomHorizontalFlip(p0.5), T.RandomVerticalFlip(p0.2), ]) def __len__(self): return len(self.inputs) def __getitem__(self, idx): inp self.inputs[idx] tgt self.targets[idx] h, w inp.shape # 随机裁剪固定尺寸防止网络见过大的图导致显存溢出 y np.random.randint(0, h - self.crop_size) x np.random.randint(0, w - self.crop_size) inp inp[y:y self.crop_size, x:x self.crop_size] tgt tgt[y:y self.crop_size, x:x self.crop_size] inp T.ToTensor()(inp).float() / 255.0 tgt T.ToTensor()(tgt).float() / 255.0 if self.transform is not None: seed torch.randint(0, 10, (1,)).item() torch.manual_seed(seed) inp self.transform(inp) torch.manual_seed(seed) tgt self.transform(tgt) return inp, tgt dataset InfraredDataset(ir_inputs.npy, ir_targets.npy, crop_size256)一个容易踩的坑是随机裁剪的坐标越界。红外图像尺寸多种多样有些数据集里存在 640×480 和 320×240 混着的情况crop_size 设 256 后320×240 的图直接randint(0, 240-256)会报错。这里要加一个判断如果图的宽或高小于 crop_size就先把图 resize 到 crop_size 以上再裁剪。另一个细节是输入和标签的随机翻转必须用同一个随机种子否则输入是水平翻转的、标签是没翻转的模型训练的损失函数值会震荡到让人怀疑人生。3. DCGAN 架构选型U-Net 生成器 PatchGAN 判别器的搭配逻辑3.1 经典 DCGAN 生成器在红外增强上为什么不够用标准的 DCGAN 生成器输入是一个随机噪声向量目标是生成一整张图。但低对比度红外增强是图像到图像的翻译任务输入是一张图输出是另一张图。如果硬套标准 DCGAN生成器会无视输入图像的内容只顾着生成“看起来像红外图”的图——目标位置、轮廓、纹理全对不上这在实际项目里是完全不可用的。正确做法是采用 U-Net 结构的生成器编码器逐层下采样提取特征解码器逐层上采样恢复分辨率编码器和解码器之间加跳过连接skip connection把浅层细节直接拼接到深层特征上。这样做的好处是红外图像里目标的边缘、高温区域的轮廓这些细节信息不会在多次卷积池化中被丢掉。3.2 生成器源码跳过连接是红外细节保真的关键import torch import torch.nn as nn class UNetGenerator(nn.Module): def __init__(self, in_channels1, out_channels1, base64): super().__init__() # 编码器4 层下采样每层通道数翻倍 self.enc1 self._block(in_channels, base, normFalse) # 256 - 256 self.enc2 self._block(base, base*2, normTrue) # 128 - 128 self.enc3 self._block(base*2, base*4, normTrue) # 64 - 64 self.enc4 self._block(base*4, base*8, normTrue) # 32 - 32 # 解码器4 层上采样每层与对应编码器输出拼接 self.dec4 self._deblock(base*8 base*8, base*4, normTrue) # 32 - 64 self.dec3 self._deblock(base*4 base*4, base*2, normTrue) # 64 - 128 self.dec2 self._deblock(base*2 base*2, base, normTrue) # 128 - 256 self.dec1 nn.Conv2d(base base, out_channels, kernel_size1) self.tanh nn.Tanh() def _block(self, cin, cout, normTrue): layers [nn.Conv2d(cin, cout, 4, 2, 1), nn.LeakyReLU(0.2, inplaceTrue)] if norm: layers.append(nn.BatchNorm2d(cout)) return nn.Sequential(*layers) def _deblock(self, cin, cout, normTrue): layers [nn.ConvTranspose2d(cin, cout, 4, 2, 1), nn.ReLU(inplaceTrue)] if norm: layers.append(nn.BatchNorm2d(cout)) return nn.Sequential(*layers) def forward(self, x): e1 self.enc1(x) e2 self.enc2(e1) e3 self.enc3(e2) e4 self.enc4(e3) d4 self.dec4(torch.cat([e4, e3], dim1)) d3 self.dec3(torch.cat([d4, e2], dim1)) d2 self.dec2(torch.cat([d3, e1], dim1)) out self.tanh(self.dec1(d2)) return out gen UNetGenerator() print(gen)这段代码里有几个细节值得展开说。第一编码器的卷积核都是 4×4、步长 2每次下采样分辨率减半通道数翻倍从 1 通道逐步提取到 512 通道的高层语义特征。第二解码器使用ConvTranspose2d上采样恢复分辨率的同时通过torch.cat把对应层的编码器输出拼接进来这种跳连结构让网络在恢复红外图像细节时既有全局语义又有局部纹理。第三最后一层用了 Tanh 激活输出范围在 [-1, 1]这意味着训练时输入图像也要归一化到 [-1, 1]很多人会忘记这个匹配关系结果训练时 Loss 降不下去。3.3 PatchGAN 判别器为什么不用“整图真/假”二分类判别器如果只输出一个 0 到 1 的标量判断“整张图是真的还是假的”会出现一个问题生成器只要整体像红外图就能骗过判别器局部区域比如目标边缘的对比度增强就算做得差也没人管。PatchGAN 的做法是把图像切成 N×N 个 patch对每个 patch 单独判断真伪最后取平均。这样判别器会更关注局部纹理和细节质量逼迫生成器把每一个局部区域都做好而不是糊弄整体。class PatchDiscriminator(nn.Module): def __init__(self, in_channels2, base64): super().__init__() # 输入是“生成图 目标图”拼接输出是 30x30 的 patch 真伪图 self.model nn.Sequential( nn.Conv2d(in_channels, base, 4, 2, 1), nn.LeakyReLU(0.2, inplaceTrue), nn.Conv2d(base, base*2, 4, 2, 1), nn.BatchNorm2d(base*2), nn.LeakyReLU(0.2, inplaceTrue), nn.Conv2d(base*2, base*4, 4, 2, 1), nn.BatchNorm2d(base*4), nn.LeakyReLU(0.2, inplaceTrue), nn.Conv2d(base*4, 1, 4, 1, 1), nn.Sigmoid() ) def forward(self, x): return self.model(x) disc PatchDiscriminator() d_out disc(torch.randn(1, 2, 256, 256)) print(判别器输出尺寸:, d_out.shape) # 期望 [1, 1, 30, 30]这里最容易出问题的地方是输出尺寸的计算。如果输入是 256×256经过三个 stride2 的卷积后尺寸变成 32×32最后一个 stride1 的卷积配合 padding1 保持尺寸所以最终输出是 30×30。如果算出来不是 30×30通常是 padding 或 kernel_size 配错了直接导致后续的判别器 Loss 计算维度对不上。PatchGAN 输入是生成图和目标图在通道维度的拼接所以in_channels2。也就是说判别器的输入是把预测图或真实目标图和低对比度输入图绑在一起让判别器知道“在这个低对比度条件下这个增强结果合不合理”。3.4 损失函数不是只有对抗 LossL1 Loss 是稳定训练的定心丸只用对抗 Loss 训练图像生成模型有一个经典问题损失降不下去而且训练不稳定生成器今天学会增强、明天就忘记输出忽明忽暗。这在红外增强项目里表现得尤其明显因为红外图对比度低、像素值分布集中生成器稍微乱改一下灰度分布判别器很容易看出来但改过头了细节就丢了。常见的做法是在对抗 Loss 基础上叠加 L1 LossMAE让生成器的输出不仅骗得过判别器还要在像素级上与目标图接近。L1 相比 L2MSE的好处是不会过度惩罚大的灰度偏差边缘保持得更好红外目标轮廓不容易糊。import torch.nn.functional as F def gan_loss(real_valid, fake_valid, gen_out, target, lambda_l1100): # 判别器 Loss真实图判真 生成图判假 real_loss F.binary_cross_entropy(real_valid, torch.ones_like(real_valid)) fake_loss F.binary_cross_entropy(fake_valid, torch.zeros_like(fake_valid)) d_loss (real_loss fake_loss) / 2 # 生成器 Loss骗过判别器 L1 像素约束 adv_loss F.binary_cross_entropy(fake_valid, torch.ones_like(fake_valid)) l1_loss F.l1_loss(gen_out, target) g_loss adv_loss lambda_l1 * l1_loss return d_loss, g_loss, l1_loss.item()这里最关键的参数是lambda_l1它控制“对抗真实感”和“像素一致性”的平衡。我跑过很多轮实验经验区间是 50 到 150。小于 50生成器会把大量精力花在骗过判别器上输出图可能锐利但不忠实于原始输入红外场景里表现为目标温度分布被乱改大于 150生成器偏向保守输出接近输入的线性拉伸增强效果微弱等于白跑了 DCGAN。另外要注意一个逻辑判别器的“假图”输入是生成器输出的梯度流要保留的所以计算 d_loss 时要先把生成器的参数冻结这在实际代码中通过分步更新来保证。4. 训练配置与参数调优让 Loss 曲线正常下降而不玄学4.1 PyTorch 训练循环这是整个项目源码里最核心的主干训练循环的代码结构不复杂但很多人写出来跑不动问题通常出在优化器的参数设置和梯度更新顺序上。先把完整的训练循环写出来再逐行解释。def train_epoch(gen, disc, dataloader, opt_g, opt_d, lambda_l1100): for batch_idx, (low, high) in enumerate(dataloader): low low.cuda() high high.cuda() # 1. 前向生成 fake gen(low) # 2. 更新判别器冻结生成器 disc_real disc(torch.cat([low, high], dim1)) disc_fake disc(torch.cat([low, fake.detach()], dim1)) d_loss (F.binary_cross_entropy(disc_real, torch.ones_like(disc_real)) F.binary_cross_entropy(disc_fake, torch.zeros_like(disc_fake))) / 2 opt_d.zero_grad() d_loss.backward() opt_d.step() # 3. 更新生成器 disc_fake_for_g disc(torch.cat([low, fake], dim1)) adv_loss F.binary_cross_entropy(disc_fake_for_g, torch.ones_like(disc_fake_for_g)) l1_loss F.l1_loss(fake, high) g_loss adv_loss lambda_l1 * l1_loss opt_g.zero_grad() g_loss.backward() opt_g.step() if batch_idx % 50 0: print(f[{batch_idx}] D: {d_loss.item():.4f} G: {g_loss.item():.4f} L1: {l1_loss.item():.4f}) # 4. 定期把生成结果写出来看不要等全部训练完 if batch_idx % 200 0: torchvision.utils.save_image( torch.cat([low[:4], fake[:4], high[:4]], dim0), foutputs/epoch_{epoch}_batch_{batch_idx}.png, nrow4, normalizeTrue, value_range(-1, 1) )这段代码有个细节值得注意。更新判别器时我这里对fake.detach()做了截断意思是判别器回传的梯度不能影响生成器同时这个 d_loss 只反传到判别器自身参数。这是一个旧习惯。让我修正一下表述更规范的做法是fake.detach()确实阻断了生成器参数的梯度等效于在判别器更新阶段冻结生成器。这样 d_loss 的反向传播只会更新判别器网络的权重。同样更新生成器时判别器参数不更新但这里disc_fake_for_g的计算让生成器的梯度能通过判别器网络回传到生成器——虽然判别器不更新但它的权重要参与前向计算生成器这边的对抗信号。这是 GAN 训练的常规操作。这个函数里可以明显看出out-of-the-box 的 DCGAN 跑在红外数据上大概要 300500 个 epoch 才能出效果。如果你发现输出的预览图前 50 个 epoch 都是纯黑色调不要慌这是 Tanh 输出配合value_range(-1, 1)的正常现象继续跑。4.2 学习率、Batch Size、优化器三个必须记住的参数区间DCGAN 训练是出了名的敏感参数稍微不一样结果就是天壤之别。整理一个我自己常用的参数表按红外图像增强这个特定任务调过参数推荐值说明优化器Adam不要用 SGDGAN 训练中 SGD 的梯度方差太大学习率0.0002生成器和判别器都可以用这个低于 0.0001 训练太慢高于 0.0004 一定会不收敛Beta10.5Adam 默认 0.9但 GAN 里 0.9 会让 Loss 震荡剧烈Beta20.999保持默认Batch Size16256×256 输入显存不够就降到 8但不要低于 4Epochs300 ~ 500低于 200 效果不明显红外增强不是快速收敛的任务L1 权重 lambda_l1100和 Generator Loss 里的对抗项平衡图像尺寸256×256低于 128 细节丢失严重高于 512 显存和训练时间成倍上涨你可能会注意到这里和原始 DCGAN 论文有一点差异。原论文里beta10.5是标配学习率 0.0002 也是原版参数。红外图像增强任务里我建议beta1保持 0.5 不要动。如果你改成了 0.9 或 0.99判别器 Loss 会在训练早期飙升到几十甚至几百因为动量太大导致梯度更新剧烈震荡。这是新手最容易踩的坑之一。Batch Size 和 L1 权重的取值是相互影响的。Batch Size 越大判别器每个 batch 看到的样本越多样对抗信号越稳定此时 L1 权重可以适当调低到 80 左右Batch Size 越小比如 4对抗信号噪声大L1 权重需要调高到 120 以上用强像素约束压住训练漂移。4.3 训练不收敛的排查顺序先看判别器再看输出预览Loss 曲线不下降时先不要盲目调学习率而是按以下顺序排查。第一步看判别器 Loss 是不是长期在 0 附近。如果是说明判别器太强了生成器任何输出都骗不过它梯度传回生成器后没有有效信号训练就死了。解决办法是降低判别器学习率到生成器的三分之一比如生成器 0.0002判别器 0.00007或者在判别器的每次更新时做 Gaussian 噪声注入。第二步看生成的预览图是不是所有图几乎一模一样。如果是说明生成器出现了模式崩溃它找到了一个“能稳定骗过判别器”的固定输出常见应对是加大 Batch Size、调高 L1 权重或者给判别器网络加 Dropout。第三步看 L1 Loss 是否在持续下降但视觉上没有改善。这种最玄学常见原因是数据配对本身有问题比如帧间隔过小导致目标没变化生成器学到的其实就是恒等映射这种情况下训练多久都没用需要回头检查数据。一个实用的辅助技巧是每训练 50 个 epoch 保存一次生成器的权重并在固定的一组验证图上做推理把结果拼成一张大图对比。这样能直观看到增强效果的变化趋势确认模型的优化方向。我经常在训练到中途发现前面的 100 个 epoch 是在浪费算力——比如输入输出尺寸不匹配、归一化方式不对这些问题都要靠这种固定验证图才能及时暴露。5. 实战避坑红外图像增强常见问题排查与参数修正5.1 生成图像出现棋盘格伪影红外目标区域像马赛克现象输出图像上出现明显的棋盘格子纹理尤其在红外目标的高亮区域看起来像是低分辨率图案被粗暴放大。原因这就是反卷积层ConvTranspose2d带来的棋盘格效应反卷积是有重叠的如果 kernel_size 不是 stride 的整数倍输出就会出现不均匀的重叠。解决优先检查 U-Net 生成器中的ConvTranspose2d(4, 2)是否有配置问题。常见做法是替换成 PixelShuffle也就是先把通道数乘 4再通过排列操作把特征图放大两倍。项目中如果不想动网络结构还有一种快速的后悔药训练结束后对输出做一次 3×3 中值滤波棋盘格会弱化很多但这是治标不治本的方案。class UpsampleBlock(nn.Module): def __init__(self, cin, cout): super().__init__() self.body nn.Sequential( nn.Conv2d(cin, cout * 4, 3, 1, 1), nn.PixelShuffle(2), # 把 cout*4 通道重排为 cout 通道、2 倍分辨率 nn.ReLU(inplaceTrue) ) def forward(self, x): return self.body(x)PixelShuffle 的好处是上采样过程是均匀的不会产生重叠区域的强弱条纹代价是参数量略增。如果换掉之后发现训练速度明显变慢把base通道数从 64 降到 48一般能追回来。5.2 增强结果把红外目标“过曝”高亮区域细节全丢现象生成器输出里原本温度高的区域比如人脸、发动机变得整片纯白内部纹理完全消失。原因L1 Loss 本质上是逐像素求平均误差它对像素值大的区域给的梯度绝对值也大导致生成器倾向于把高灰度区域拉得过亮来迎合目标均值。解决在计算 L1 Loss 时改为加权 L1即对灰度值低于某个阈值比如 0.3和非高亮区域给不同权重。一个不引入额外参数的做法是把目标图的灰度分成两个区间分别算 Loss高亮区权重 0.5、背景区权重 1.5这样能保住暗部细节。def weighted_l1(pred, target, threshold0.35, high_w0.5, low_w1.5): # 高亮区权重低防止过曝暗区、背景区权重高保住轮廓和细节 mask (target threshold).float() high_loss F.l1_loss(pred * mask, target * mask) low_loss F.l1_loss(pred * (1 - mask), target * (1 - mask)) return high_w * high_loss low_w * low_loss注意这里的 mask 是“目标图的像素是否大于阈值”不是预测图的。因为训练早期预测图可能整体灰蒙蒙的用预测图来分区间会让阈值判断失去意义。另外阈值 0.35 是针对归一化到 [-1, 1] 的图来看的换算成 0~255 大概是 172 左右。如果是一张夜空背景下的红外图高亮目标占比很小这个阈值不需要调整如果场景里大面积是高温区域比如白天户外人员密集场景阈值调高到 0.45 更合适。5.3 训练 Loss 一直下降但增强图在真实视频上表现很差现象训练集上的指标每天都在变好但拿到真实监控视频上一测低对比度场景完全没增强甚至引入奇怪的光晕。原因这是配数据方式的根本问题。低频模糊对比度压制模拟出来的退化和真实红外传感器的退化大气衰减、镜头雾化、传感器噪声、非均匀性校正残留差异很大模型学到了“怎么去高斯模糊”没有学到“怎么去真实模糊”。这是所有深度学习实战项目都会遇到的分布偏移问题。解决最有效的办法是在配对数据生成时混合多种退化方式不要只用高斯模糊。我一般会在build_pair里随机从高斯模糊、均值模糊、运动模糊带角度三种里挑一种再叠加 5% 的高斯噪声。这样生成器被迫学到“从多种未知退化中恢复”——会更接近真实的低对比度红外场景。def build_pair_mixed(img): h, w img.shape choice np.random.randint(0, 3) if choice 0: ksize np.random.choice([3, 5, 7]) blur cv2.GaussianBlur(img, (ksize, ksize), 0) elif choice 1: ksize np.random.choice([3, 5, 7]) blur cv2.blur(img, (ksize, ksize)) else: # 运动模糊生成线性 kernel 并做滤波 kernel np.zeros((5, 5), np.float32) kernel[2, :] 1.0 / 5 # 水平方向的运动模糊 blur cv2.filter2D(img, -1, kernel) # 叠加传感器噪声 noise np.random.normal(0, 5, (h, w)).astype(np.float32) low np.clip(blur.astype(np.float32) noise, 0, 255).astype(np.uint8) return low, img运动模糊的方向、核大小都可以再调比如做对角方向的模糊kernel[2, :]改为kernel[2, 2] 1配合旋转。真实红外视频里目标在运动时导致的模糊多为水平方向所以水平运动模糊最有实战价值。加了混合退化后训练集的数据多样性明显提升生成器在真实视频上的泛化能力会有可见改善。5.4 显存不足与训练中断OOM 与断点恢复方案红外图像增强任务里256×256 的输入、Base64 的 U-Net加上 PatchGAN 判别器Batch Size 16 大约需要 11GB 显存。如果用的是 8GB 显存的显卡训练跑到一半就会 OOM。报错信息会直接抛CUDA out of memory。解决思路分两路第一路降 Batch Size 到 4同时把 L1 权重调高到 120训练会慢一些但能跑通第二路打开 PyTorch 的cudnn.benchmarkTrue和gradient_checkpointing后者对 U-Net 尤其管用用时间换显存。另外训练中断后要从 checkpoint 恢复这几行代码值得固化到你的训练脚本里import os def save_checkpoint(gen, disc, opt_g, opt_d, epoch, pathcheckpoints): os.makedirs(path, exist_okTrue) torch.save({ epoch: epoch, gen_state: gen.state_dict(), disc_state: disc.state_dict(), opt_g_state: opt_g.state_dict(), opt_d_state: opt_d.state_dict(), }, f{path}/ir_dcgan_epoch_{epoch}.pt) def load_checkpoint(gen, disc, opt_g, opt_d, path): ckpt torch.load(path, map_locationcuda) gen.load_state_dict(ckpt[gen_state]) disc.load_state_dict(ckpt[disc_state]) opt_g.load_state_dict(ckpt[opt_g_state]) opt_d.load_state_dict(ckpt[opt_d_state]) return ckpt[epoch]这里有个容易让人掉进去的坑只保存模型权重、不保存优化器状态。恢复训练后 Loss 曲线会突然跳变因为 Adam 的动量信息丢了学习率调度也会乱。所以优化器状态一定要随 checkpoint 一起存下来。另外建议每 50 个 epoch 存一个文件不要只存“最新”因为如果最新一个 epoch 恰好出现了模式崩溃你还能回滚到 50 或 100 epoch 之前的版本这是一剂很实用的后悔药。6. 落在真实场景验证增强效果与部署取舍模型训练完最终要在真实红外视频序列或单帧图上验证。验证不能只看训练时的 Loss要把生成器和预处理流程串起来测端到端效果。我通常的做法是准备 20 到 30 张完全没有参与训练的红外图覆盖低对比度的几种典型场景薄雾背景下的行人、夜间车辆热源、远距离建筑轮廓然后用生成器逐张推理保存输入与输出并排对比图。如果你手头的真实数据没有“高对比度目标图”就无法计算 PSNR 和 SSIM此时的可靠验证指标是局部对比度变化和边缘强度。推荐算两个客观指标目标区域的平均灰度与背景平均灰度之差对比度差以及 Canny 边缘检测后的边缘像素密度。def evaluate_enhancement(input_img, output_img): # 对比度差目标中心区域 vs 背景边缘区域的灰度差 h, w output_img.shape center output_img[h//4:3*h//4, w//4:3*w//4] border np.concatenate([output_img[:h//8].ravel(), output_img[-h//8:].ravel(), output_img[:, :w//8].ravel(), output_img[:, -w//8:].ravel()]) contrast_diff np.mean(center) - np.mean(border) # 边缘密度增强后边缘像素占比应该更高 edges_in cv2.Canny(input_img, 50, 150) edges_out cv2.Canny(output_img, 50, 150) edge_density_in np.mean(edges_in 0) edge_density_out np.mean(edges_out 0) return { contrast_diff_in: np.mean(input_img[h//4:3*h//4, w//4:3*w//4]) - np.mean( np.concatenate([input_img[:h//8].ravel(), input_img[-h//8:].ravel(), input_img[:, :w//8].ravel(), input_img[:, -w//8:].ravel()])), contrast_diff_out: contrast_diff, edge_density_in: edge_density_in, edge_density_out: edge_density_out, }对比度差要对比输入和输出两个值增强后对比度差变大说明目标的灰度相对背景更突出边缘密度要适度上升如果上升太多比如翻了三倍说明输出被过度锐化噪声也被当成边缘强化了这种增强在实际部署里有很大风险。阈值怎么定要看具体场景运动目标检测场景更看重对比度差有细节识别需求比如安检热像仪更看重边缘密度的合理性。最后说一个我自己踩过的习惯模型训练好之后不要急着直接拿整张大红外图做推理先用一个较小的窗口比如 256×256滑动推理然后再拼接回原尺寸。原因是整图输入时生成器对图像边缘区域的增强效果明显弱于中心区域感受野不够导致边缘出现“增强断层”。滑动窗口配合 50% 重叠和加权平均拼接可以明显缓解这个问题。def sliding_inference(model, image, window256, stride128): h, w image.shape output np.zeros((h, w), dtypenp.float32) weight np.zeros((h, w), dtypenp.float32) for y in range(0, h - window 1, stride): for x in range(0, w - window 1, stride): patch image[y:ywindow, x:xwindow] patch_t torch.from_numpy(patch).float().unsqueeze(0).unsqueeze(0).cuda() with torch.no_grad(): patch_out model(patch_t).cpu().squeeze().numpy() output[y:ywindow, x:xwindow] patch_out weight[y:ywindow, x:xwindow] 1 # 边缘处的重叠权重自动修正 return output / np.maximum(weight, 1)窗口大小一般和训练分辨率一致重叠率设 50% 即可。拼接后如果边缘还有细微接缝先检查weight是否有零值区域比如原图尺寸不是窗口的整数倍导致边缘少算了一次再检查模型输出是否做了clamp到有效灰度范围。一个要注意的点是滑动推理的速度256 窗口 128 步长一张 640×480 的图需要 15 次推理在消费级 GPU 上约 1 到 2 秒在 CPU 上就完全没法用了。所以边缘拼接方案只适合离线处理或 GPU 推理CPU 部署时直接用整图推理加padding会更快更简单。红外图像增强这个方向我从直方图方法做到 GAN最大的体会是DCGAN 不是那种纯玄学的魔法它的成功高度依赖数据配对质量和训练参数的耐心程度。跑通这个项目之后你也可以把同样的 U-Net PatchGAN 框架迁移到其它图像增强任务上比如微光可见光增强、水下图像去雾只需要换数据和调 Loss 权重。训练曲线翻车是常态关键是有固定的验证手段和 checkpoint 回滚能力一步步排查。希望这篇基于源码和实战经验的拆解帮你在复现这个项目的路上少走一段弯路。本文还有配套的精品资源点击获取