ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

基于深度学习的轿车背景去除:U-Net语义分割实战

2026/9/10 5:38:16 拓冰建站 浏览量
基于深度学习的轿车背景去除:U-Net语义分割实战 简介基于深度学习的轿车背景去除算法课程设计资源包面向计算机、数学、电子信息类专业学生尤其适合作为课程设计、期末大作业或毕业设计项目。资源以Python源码为核心13个py脚本覆盖数据加载、模型定义、损失函数、训练与配置等完整环节2个h5预训练权重文件支持直接加载模型进行推理2份docx说明文档与1份md说明详细阐述算法原理、软件体系结构与设计模式的应用1个pptx可用于答辩汇报。整个压缩包共19个文件大小约37.26MB超过六成为Python脚本并附带开发工作日志目录结构清晰、模块拆分规范。目前已有八十六人浏览学习。通过这份资料读者可以快速掌握基于深度学习的图像分割任务流程理解软件架构与设计模式在实际项目中的落地方式并基于完整源码进行二次开发或功能扩展。1. 从课程设计到落地轿车背景去除到底在解决什么问题期末前两周才确定题目既要交软件体系结构与设计模式的课程设计又想体现深度学习算法能力多数人最后都卡在“模型跑通了但说不清工程结构”这步。这个基于深度学习的轿车背景去除项目正是用于解决这类问题的完整样例输入一张任意场景下的轿车照片输出只有车身保留、背景被置为纯色的掩码图本质是逐像素的语义分割任务。相比人脸抠图、通用物体分割车辆目标轮廓清晰但包含车窗反光、地面阴影、车漆高光等干扰很适合作为入门级深度图像分割实战。代码库划分为数据集加载、模型定义、损失函数、训练配置、推理应用五个模块直接映射软件体系结构课程里分层与解耦的考核点。适合正在做图像分割入门、准备课程设计答辩或期末大作业、希望把设计模式落到代码里的学生与开发者。2. 任务定义与数据准备mask 标注与数据增强管线的搭建2.1 背景去除为什么是逐像素语义分割背景去除和常见的物体检测有本质区别。物体检测输出的是边界框而背景去除要对图像中的每一个像素做二分类判断属于轿车还是属于背景。这个任务在计算机视觉里被称为语义分割它比分类任务多保留了空间位置信息也比检测任务更精细。轿车背景去除的难点在于三个区域车窗玻璃会反射周围环境、车漆颜色与背景接近时会混淆、车轮与地面阴影的边界难以划分。课程设计的考核重点通常不只是“效果好不好”还包括“为什么这么设计”。语义分割采用编码器-解码器结构编码器逐层下采样提取高层语义特征解码器逐层上采样恢复空间分辨率。车辆轮廓的精细程度取决于解码器对边缘信息的恢复能力这也是后续第 3 章选择 U-Net 作为骨干网络的原因。理解这一点才能在软件设计说明文档里交代清楚模型选型的依据而不是简单写一句“使用了深度学习”。2.2 数据目录结构与 dataset.py 的实现项目中的数据由原始车辆图像和对应的 mask 标注组成。标准目录结构如下第一部分是课程设计里交付时要讲清楚的内容dataset/ ├── train/ │ ├── input/ # 原始轿车图像 │ │ ├── 0001.jpg │ │ └── ... │ └── mask/ # 二值掩码图白色为轿车黑色为背景 │ ├── 0001.png │ └── ... └── val/ ├── input/ └── mask/注意区分这里的 train/input 与模型训练环节的 train 数据集前者是磁盘上的数据组织后者是训练循环中的批次数据。加载数据时使用 torchvision 的 transforms 做尺寸统一和增强代码实现如下这也是 dataset.py 的核心内容class CarSegDataset(Dataset): def __init__(self, img_dir, mask_dir, img_size(512, 512), augFalse): self.img_paths sorted(glob.glob(os.path.join(img_dir, *.jpg))) self.mask_paths sorted(glob.glob(os.path.join(mask_dir, *.png))) self.img_size img_size self.aug aug # 两个目录下的文件应一一对应常见错误是按文件名排序不一致导致图文错位 assert len(self.img_paths) len(self.mask_paths), image count ! mask count def __len__(self): return len(self.img_paths) def __getitem__(self, idx): img Image.open(self.img_paths[idx]).convert(RGB) mask Image.open(self.mask_paths[idx]).convert(L) img img.resize(self.img_size, Image.BILINEAR) mask mask.resize(self.img_size, Image.NEAREST) # 掩码不能做插值平滑 if self.aug: if random.random() 0.5: img img.transpose(Image.FLIP_LEFT_RIGHT) mask mask.transpose(Image.FLIP_LEFT_RIGHT) if random.random() 0.5: img img.transpose(Image.FLIP_TOP_BOTTOM) mask mask.transpose(Image.FLIP_TOP_BOTTOM) # 颜色抖动只作用于原图不作用于 mask img transforms.ColorJitter(brightness0.2, contrast0.2)(img) img_tensor transforms.ToTensor()(img) mask_tensor torch.as_tensor(np.array(mask), dtypetorch.float32) / 255.0 mask_tensor mask_tensor.unsqueeze(0) # 增加通道维变成 [1, H, W] return img_tensor, mask_tensor代码逻辑上有两个容易踩坑的参数要重点说明。第一mask 缩放必须用Image.NEAREST最近邻插值不能用BILINEAR双线性插值因为 mask 是离散的二值标签双线性插值会在边缘产生 0.3、0.7 这类中间灰度值直接污染损失函数的计算。第二mask_tensor / 255.0是为了把像素值从 0255 归一化到 01与模型输出的 sigmoid 概率值对齐。unsqueeze(0)是为配合 PyTorch 的通道维度约定语义分割的数据格式为[batch, channel, height, width]单通道 mask 需要补上 channel 维度。2.3 数据增强参数怎么定数据增强解决的是模型泛化问题不是越多越好每一项增强都有代价。整理出下面的参数对照表课程设计文档里直接描述为“训练阶段采用轻量数据增强”即可注意本表不涉及训练超参数的内容仅描述数据增强环节增强方式推荐参数作用代价与陷阱水平翻转p0.5消除左右视角偏差样本量翻倍车牌文字镜像但不影响分割任务垂直翻转p0.5增加多样化天空与地面语义被颠倒慎用于有方向性数据集随机裁剪0.8 比例范围模拟局部遮挡增强目标局部特征可能裁掉整个车需配合重采样色彩抖动brightness0.2, contrast0.2增强对光照变化的鲁棒性只作用于原图绝不作用于 mask归一化mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]统一像素分布加速收敛必须与预训练权重配套不可随意换一个常见误区是认为增强越强效果越好。对于轿车背景去除这种目标相对居中的任务过度的随机裁剪会导致训练样本中经常丢失整车结构模型反而学不到完整的车身形态。实践中的做法更倾向于保留水平翻转与轻量色彩抖动垂直翻转根据数据分布判断如果数据集中车头朝向无规律则保留。3. U-Net骨干实现与损失函数设计3.1 为什么课程设计选 U-Net 而不是 DeepLabV3模型选型是需要给出理由的环节。DeepLabV3 使用空洞卷积在保持分辨率的同时扩大感受野在 PASCAL VOC、Cityscapes 这类大规模分割数据集上表现更好但它的结构复杂、预训练权重体积大在课程设计这种单 GPU、少量数据、短周期的场景下并不划算。U-Net 的优势在三个地方结构对称、包含跳跃连接、实现代码短。编码器下采样 4 次解码器对应上采样 4 次中间通过 concat 把同尺度的低层特征拼接到解码器让边缘信息不会因为逐层下采样而丢失。轿车车轮与背景的交界处只需要 2 到 4 个像素的精度U-Net 的跳跃连接恰好能保住这个级别的细节。另外U-Net 几乎不依赖特定预训练权重也能在几百张图上收敛出可用效果属于训练策略里“从零训练也能有基础效果”的模型。换个角度从软件体系结构的视角看U-Net 是天然的模块化结构编码器与解码器可以拆成两个独立组件中间通过接口对接这个特征在写软件设计说明时就非常容易画出组件图。对于课程设计考核“架构设计能力”的评分项这一条是额外的加分点。3.2 Encoder-Decoder 残差块与跳跃连接的代码实现U-Net 的核心实现拆成卷积块、编码器、解码器三部分下面的代码对应 model.py 的核心逻辑class DoubleConv(nn.Module): 双层卷积块卷积 批归一化 ReLUU-Net 的基本组成单元 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 UNet(nn.Module): def __init__(self, in_ch3, out_ch1, base_ch64): super().__init__() # base_ch 表示第一层卷积的输出通道数之后每下采样一次通道翻倍 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, out_ch, 1) def forward(self, x): e1 self.enc1(x) # [B, 64, H, W] e2 self.enc2(self.pool(e1)) # [B, 128, H/2, W/2] e3 self.enc3(self.pool(e2)) # [B, 256, H/4, W/4] e4 self.enc4(self.pool(e3)) # [B, 512, H/8, W/8] b self.bottleneck(self.pool(e4)) # [B, 1024, H/16, W/16] d4 self.up4(b) d4 torch.cat([d4, e4], dim1) # 跳跃连接沿通道拼接 d4 self.dec4(d4) d3 self.up3(d4) d3 torch.cat([d3, e3], dim1) d3 self.dec3(d3) d2 self.up2(d3) d2 torch.cat([d2, e2], dim1) d2 self.dec2(d2) d1 self.up1(d2) d1 torch.cat([d1, e1], dim1) d1 self.dec1(d1) return self.out(d1)base_ch64是指第一层输出 64 个特征图每下采样一次通道翻倍到最底层是base_ch * 16 1024。通道数翻倍与分辨率减半同步进行这样模型的计算量基本维持稳定。跳跃连接对应的代码是torch.cat([d4, e4], dim1)注意 dim1 是通道拼接不是在空间上叠加这要求编码器第 4 层输出与解码器上采样后的空间尺寸必须一致否则拼接会直接报维度错误。训练阶段输入尺寸应该能被 16 整除这是为什么前面 dataset 里把图像缩放成 512×512 而不是 500×500 的深层原因。3.3 BCE与Dice Loss的组合逻辑轿车背景去除是二分类问题最直接的损失函数是 BCEBinary Cross Entropy。单独使用 BCE 在正负样本极度不平衡时有明显缺陷一张图里背景像素经常占 80% 以上模型只要把所有像素预测为背景就能把 BCE 压到很低但输出的 mask 里根本没有车。Dice Loss 是从评估指标 Dice 系数反推出来的损失函数直接优化“预测区域与真实区域的重叠度”对类别不平衡不敏感。实践中更稳定的是两者组合即 BCE 加 Diceclass BCEDiceLoss(nn.Module): BCE Dice 组合损失bce_weight 控制两者占比 def __init__(self, bce_weight0.5): super().__init__() self.bce_weight bce_weight def forward(self, pred, target): pred torch.sigmoid(pred) # 把 logits 压缩到 0~1 bce F.binary_cross_entropy(pred, target) smooth 1e-6 # 防止分母为 0 的平滑项 intersection (pred * target).sum() union pred.sum() target.sum() dice 1 - (2.0 * intersection smooth) / (union smooth) return self.bce_weight * bce (1 - self.bce_weight) * dicebce_weight0.5表示两者等权混合。如果训练时发现 loss 下降到 0.3 左右就停滞但预测的掩码粘连、边缘粗糙可以调成bce_weight0.7加大逐像素约束如果发现训练前期模型输出的区域覆盖不全把bce_weight调低到 0.3让 Dice 主导模型聚焦整体结构。下表列出三种损失函数的适用差异便于答辩时说明损失组合优势劣势适用场景BCE 单独梯度平稳实现简单正负样本不平衡时偏向背景前景占比均衡时Dice 单独直接优化重叠度梯度震荡明显小目标不稳定前景占比极低时BCE Dice两者互补收敛平滑需要多调一个权重参数车辆分割首选方案需要特别说明sigmoid BCE的组合在数值上不如nn.BCEWithLogitsLoss稳定后者内部做了数值保护。上面的代码为了直观展示梯度计算流程才显式调用sigmoid在损失函数中先 sigmoid 再计算 BCE梯度会经过两次非线性变换实际项目中直接用nn.BCEWithLogitsLoss会更安全这个细节可以写进课程设计的改进说明里。4. 训练配置与设计模式视角下的工程化重构4.1 config.py如何统一管理超参数训练阶段涉及的参数数量远比想象中多学习率、批次大小、迭代轮数、图像尺寸、数据路径、损失权重分散在代码各处时调参就是一场灾难。软件体系结构课程设计里提倡的高内聚低耦合落到训练代码上就是先把所有可调参数集中到 config.py 统一管理class Config: 集中管理训练与推理参数避免魔法数字散落在各模块 # 数据路径 train_img_dir dataset/train/input train_mask_dir dataset/train/mask val_img_dir dataset/val/input val_mask_dir dataset/val/mask # 图像与训练 img_size 512 # 必须能被 16 整除U-Net 下采样 4 次 batch_size 8 # 显存不足时优先降到 4而不是调小图片 epochs 40 learning_rate 1e-4 # Adam 下 1e-4 比默认 1e-3 更稳 num_workers 4 # Windows 上建议设为 0否则可能报错 # 损失与优化器 bce_weight 0.5 weight_decay 1e-5 save_path checkpoints/best_model.pthimg_size512对应之前提到的 16 整除要求batch_size8在单张 1080Ti 上刚好合适learning_rate1e-4是实践中最稳的选择默认的1e-3在分割任务上经常出现训练早期 loss 震荡甚至直接发散这一点会在训练循环里通过学习率策略进一步控制。参数集中之后所有模块通过Config.xxx访问参数后续做实验只需要改这一个文件答辩演示时也比较直观。下表汇总了一份可直接套用的训练超参数规划其中优化器、学习率策略对收敛影响最显著参数推荐值说明优化器Adam对学习率不敏感适合课程设计阶段初始学习率1e-4高于 1e-3 时容易震荡learning rate 策略ReduceLROnPlateau指标停滞时降低为原来的 0.1批次大小8显存不足时降低 batch_size训练轮数305040 轮左右 val loss 进入平台期权重初始化kaiming_normal配合 ReLU 使用4.2 训练循环的实现与学习率策略训练循环是每个课程设计必须提交的核心代码。完整逻辑包括前向传播、计算损失、反向传播、梯度更新、验证集评估、模型保存六个步骤。下面的代码去掉了无关的打印信息保留主干def train_one_epoch(model, dataloader, optimizer, criterion, device): model.train() total_loss 0.0 for imgs, masks in dataloader: imgs imgs.to(device) masks masks.to(device) optimizer.zero_grad() outputs model(imgs) loss criterion(outputs, masks) loss.backward() optimizer.step() total_loss loss.item() return total_loss / len(dataloader) def evaluate(model, dataloader, criterion, device): model.eval() total_loss 0.0 with torch.no_grad(): for imgs, masks in dataloader: imgs imgs.to(device) masks masks.to(device) outputs model(imgs) loss criterion(outputs, masks) total_loss loss.item() return total_loss / len(dataloader)训练主循环部分结合前面提到的学习率策略加进去ReduceLROnPlateau的完整调用model UNet(in_ch3, out_ch1).to(device) optimizer torch.optim.Adam(model.parameters(), lrConfig.learning_rate, weight_decayConfig.weight_decay) scheduler torch.optim.lr_scheduler.ReduceLROnPlateau( optimizer, modemin, factor0.1, patience5, verboseTrue ) best_val_loss float(inf) for epoch in range(Config.epochs): train_loss train_one_epoch(model, train_loader, optimizer, criterion, device) val_loss evaluate(model, val_loader, criterion, device) # 检测 val_loss 连续多个 epoch 不下降时降低学习率 scheduler.step(val_loss) if val_loss best_val_loss: best_val_loss val_loss torch.save(model.state_dict(), Config.save_path) print(fEpoch {epoch:02d}, saved best model, val_loss: {val_loss:.4f})optimizer.zero_grad()放在每个 batch 之前作用是清空上一次反向传播累积的梯度这个步骤遗漏会导致梯度跨 batch 累加、loss 异常波动。torch.save(model.state_dict(), ...)只保存权重不保存模型结构加载时需要先用UNet()实例化模型再load_state_dict。ReduceLROnPlateau的modemin表示监控指标越低越好factor0.1表示每次降为原来的十分之一设成 0.5 会更平滑但会拉长训练时间。4.3 用策略模式与工厂模式解耦数据与损失模块软件体系结构与设计模式课程设计的核心考核点体现在这里。数据加载与损失函数是两个最容易替换的扩展点换数据集、换损失函数是调优过程中的高频操作。如果代码里到处是if dataset_type carvana这样的分支每加一种数据集就要动已有代码违反开闭原则。用工厂模式封装数据加载器用策略模式封装损失函数class LossFactory: 策略模式根据名称返回对应的损失函数实例 _losses { bce_dice: BCEDiceLoss, dice: DiceLoss, bce: nn.BCEWithLogitsLoss, } classmethod def create(cls, name, **kwargs): if name not in cls._losses: raise ValueError(fUnknown loss: {name}) return cls._losses[name](**kwargs) class DatasetFactory: 工厂模式按数据集类型构造对应的 Dataset staticmethod def create(dataset_type, img_dir, mask_dir, **kwargs): if dataset_type car: return CarSegDataset(img_dir, mask_dir, **kwargs) if dataset_type general: return GeneralSegDataset(img_dir, mask_dir, **kwargs) raise ValueError(fUnsupported dataset: {dataset_type})两个工厂类的设计意图不同LossFactory是对创建逻辑的集中封装用字典注册类名与类的映射新增损失函数时只需要在_losses字典里加一行其余训练代码零改动DatasetFactory做的是条件分发当新增一种数据集时不必在每个用到Dataset的地方加判断。实际项目中如果只做课程设计不需要过度设计但这两个工厂类的代码量很少又恰好覆盖了设计模式的两个经典考核点属于性价比很高的工程化改造。5. 从IoU到批量抠图评估脚本与推理后处理5.1 IoU / Dice评估与常见统计误区训练完成后需要回答一个关键问题这个模型到底好不好。评估指标不应只看 loss因为 BCE Loss 很小不代表分割结果好。语义分割的标准评估指标是 IoU即预测区域与真实区域的交集除以并集。另一个常用指标是 Dice 系数它与 IoU 之间可以互相换算Dice 2 * IoU / (1 IoU)。计算代码很短但统计过程有一个常见误区def compute_metrics(pred_mask, gt_mask, threshold0.5): pred_mask: 模型输出的概率图, gt_mask: 真实标签 pred_bin (pred_mask threshold).astype(int) gt_bin (gt_mask threshold).astype(int) intersection (pred_bin gt_bin).sum() union (pred_bin | gt_bin).sum() iou intersection / union dice (2 * intersection) / (pred_bin.sum() gt_bin.sum()) return iou, dice误区在于不要把 batch 内所有样本的 IoU 先求平均而应该先累加所有样本的 intersection 和 union最后再统一相除两种统计方式在小样本测试集上可能相差 2 到 3 个百分点。误用场景是当某张图完全没有车时union 为 0直接计算会产生除零错误正确做法是跳过该样本或在分子分母同时加平滑项。课程设计里只需写清楚你用的是哪种统计口径。5.2 单张推理与批量抠图的可执行步骤最后一步是把训练好的模型应用到真实图片上。推理脚本需要完成加载权重、预处理、前向传播、后处理、保存结果五个步骤。后处理部分有一个容易被忽略的操作预测出的概率图直接以 0.5 为阈值二值化后可能会出现一些小面积噪点或细小孔洞用形态学开运算去除噪点、用闭运算填充孔洞是标准做法import cv2 import torch def inference_one_image(model, img_path, device, save_path, thresh0.5): # 1. 预处理读图、缩放、归一化、转 tensor img cv2.imread(img_path) img_rgb cv2.cvtColor(img, cv2.COLOR_BGR2RGB) img_resized cv2.resize(img_rgb, (Config.img_size, Config.img_size)) # 2. 归一化ImageNet 均值方差 img_norm img_resized / 255.0 img_tensor torch.from_numpy(img_norm).permute(2, 0, 1).unsqueeze(0).float() img_tensor img_tensor.to(device) # 3. 前向传播得到概率图 model.eval() with torch.no_grad(): prob torch.sigmoid(model(img_tensor)).cpu().numpy()[0, 0] # 4. 二值化 形态学后处理 mask (prob thresh).astype(np.uint8) * 255 kernel cv2.getStructuringElement(cv2.MORPH_RECT, (5, 5)) mask cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel) # 先开运算去噪点 mask cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel) # 再闭运算填空洞 # 5. 原图尺寸恢复并叠加背景 mask_resized cv2.resize(mask, (img.shape[1], img.shape[0]), interpolationcv2.INTER_NEAREST) result img.copy() result[mask_resized 0] [255, 255, 255] # 背景置为白色 cv2.imwrite(save_path, result)批量推理时把inference_one_image放进一个循环遍历目录下所有图片即可无需额外写多进程版本。MORPH_OPEN先腐蚀再膨胀能去除小于卷积核尺寸的白色噪点MORPH_CLOSE先膨胀再腐蚀能填充黑色区域里的白色空洞。对于轿车背景去除5×5 的卷积核大小适中改大会让车轮边缘的细小结构被抹掉。最终保存结果时用INTER_NEAREST把 mask 恢复为原图尺寸保持边缘锐利不产生锯齿色偏。跑通这个流程之后就完成了从课程设计考核的代码逻辑说明到真实场景应用的完整衔接。本文还有配套的精品资源点击获取