Skip to content

Latest commit

 

History

History
1319 lines (1040 loc) · 40.5 KB

File metadata and controls

1319 lines (1040 loc) · 40.5 KB

阶段3.5:计算机视觉基础

预计时长:6-8周 目标:深入掌握CNN原理,熟练完成图像分类、目标检测、图像分割任务


3.5.1 卷积神经网络深入(2周)

3.5.1.1 卷积运算原理

直观理解

🔦 想象一下:你拿着一个小放大镜(卷积核)在一张大照片上滑动。每到一个位置,放大镜只能看到照片的一小块区域。卷积就是这个过程——用一个小窗口扫描整张图像,在每个位置计算"这里有什么特征"。

比如一个检测竖线的卷积核,就像一个专门找"竖条纹"的探测器。当它滑过图像时,遇到竖线就会"亮起来"(输出高值),遇到其他区域就"暗下去"(输出低值)。

核心概念

概念 说明 形象比喻 重要程度
卷积核(Kernel) 滑动窗口提取局部特征 🔍 像一个特征探测器,专门找某种模式 ⭐⭐⭐⭐⭐
步幅(Stride) 卷积核移动步长 👣 每次走几步,步子大则看得粗略 ⭐⭐⭐⭐⭐
填充(Padding) 边界处理策略 🖼️ 给图片加个边框,防止边缘信息丢失 ⭐⭐⭐⭐⭐
空洞卷积(Dilation) 扩大感受野 👀 隔着格子看,视野更广但不增加计算 ⭐⭐⭐⭐
感受野(Receptive Field) 输出对应的输入区域 📺 一个输出像素能"看到"多大范围的输入 ⭐⭐⭐⭐⭐

卷积计算公式

输出尺寸计算:
output_size = (input_size + 2 * padding - kernel_size) / stride + 1

例如:
- 输入: 32x32
- 卷积核: 3x3
- padding: 1
- stride: 1
- 输出: (32 + 2*1 - 3) / 1 + 1 = 32x32

卷积层参数量计算

参数量 = (kernel_h × kernel_w × in_channels + 1) × out_channels

例如:
- 输入通道: 64
- 输出通道: 128
- 卷积核: 3x3
- 参数量 = (3 × 3 × 64 + 1) × 128 = 73,856

PyTorch实现

import torch
import torch.nn as nn

# 2D卷积层
conv = nn.Conv2d(
    in_channels=3,      # 输入通道数
    out_channels=64,    # 输出通道数
    kernel_size=3,      # 卷积核大小
    stride=1,           # 步幅
    padding=1,          # 填充
    dilation=1,         # 空洞卷积
    bias=True           # 是否使用偏置
)

# 输入: (batch, channels, height, width)
x = torch.randn(1, 3, 224, 224)
output = conv(x)  # 输出: (1, 64, 224, 224)

3.5.1.2 池化层

直观理解

📊 想象一下:池化就像是"做总结"。假设你有一本100页的书,要缩写成25页的摘要:

  • 最大池化(MaxPool):每4页中挑最精彩的一句话保留 —— 保留最显著的特征
  • 平均池化(AvgPool):每4页算出平均重要程度 —— 保留整体信息

池化的好处:① 减少计算量(书变薄了)② 提取重要特征 ③ 增加平移不变性(猫在图片左边还是右边,都能识别出是猫)

常用池化操作

类型 作用 形象比喻 使用场景
MaxPool 取最大值,保留最强特征 🏆 选班里最高的同学代表班级 常用于分类网络
AvgPool 取平均值,保留全局信息 📈 算全班平均身高 常用于语义分割
AdaptivePool 自适应输出尺寸 🎯 无论输入多大,输出固定大小 处理不同尺寸输入
GlobalAvgPool 全局平均池化 🌍 整张特征图求一个平均值 替代全连接层
# 最大池化
maxpool = nn.MaxPool2d(kernel_size=2, stride=2)

# 平均池化
avgpool = nn.AvgPool2d(kernel_size=2, stride=2)

# 自适应平均池化(输出固定尺寸)
adaptive_pool = nn.AdaptiveAvgPool2d((1, 1))  # 输出 1x1

# 全局平均池化
x = torch.randn(1, 512, 7, 7)
out = adaptive_pool(x)  # (1, 512, 1, 1)
out = out.view(out.size(0), -1)  # (1, 512)

3.5.1.3 感受野计算

直观理解

🎥 想象一下:你在看一部电影的某一帧。

  • 第1层卷积:你只能看到屏幕的一小块(3×3像素)
  • 第2层卷积:你通过第1层的"窗口"看,视野变大了(5×5像素)
  • 第3层卷积:视野继续扩大(7×7像素)

这就像"蚂蚁看世界" vs "老鹰看世界"——层数越深,能看到的范围越大。

为什么这很重要? 检测小物体需要小感受野,检测大物体(如整只猫)需要大感受野。

感受野递推公式:
RF_n = RF_{n-1} + (kernel_size - 1) × ∏_{i=1}^{n-1} stride_i

例如:3层3x3卷积,stride=1
- 第1层: RF = 3     👁️ 只看到3×3的小区域
- 第2层: RF = 5     👁️ 视野扩展到5×5
- 第3层: RF = 7     👁️ 视野扩展到7×7

💡 重要结论:
   2个3x3卷积 = 1个5x5卷积的感受野
   3个3x3卷积 = 1个7x7卷积的感受野
   但参数量更少!(3×3×2=18 < 5×5=25)
   
这就是为什么现代网络都用小卷积核堆叠,而不是大卷积核!

3.5.1.4 BatchNorm与LayerNorm

直观理解

📊 想象一下:你是一位老师,要给来自不同学校的学生统一评分。

没有归一化时的问题

  • A学校满分100分,学生平均80分
  • B学校满分150分,学生平均90分
  • 直接比较分数没有意义!

BatchNorm做的事:把所有分数标准化到"均值0、方差1"的统一标准

  • 就像把所有分数转换成"比平均高/低几个标准差"
  • 这样网络每一层收到的数据都在相似的范围内,训练更稳定

为什么叫"Batch"Norm? 因为它在一个batch的数据上计算均值和方差,就像用全班同学的成绩来定标准。

# BatchNorm: 在batch维度归一化(像统计全班成绩)
bn = nn.BatchNorm2d(num_features=64)  # 64个通道,每个通道独立归一化

# LayerNorm: 在特征维度归一化(像统计一个学生的所有科目)
ln = nn.LayerNorm(normalized_shape=[64, 224, 224])

# 使用建议:
# - CNN通常使用BatchNorm(图像特征适合跨样本统计)
# - Transformer/RNN使用LayerNorm(序列长度不固定,用LayerNorm更稳定)
# - 小batch_size时考虑GroupNorm(batch太小统计不准)

学习目标

  • 理解卷积运算的原理和参数计算
  • 手写实现2D卷积运算(numpy)
  • 理解感受野的概念和计算方法
  • 掌握BatchNorm的作用和原理

3.5.2 经典CNN架构(2周)

3.5.2.1 CNN架构演进

LeNet (1998) → AlexNet (2012) → VGG (2014) → GoogLeNet (2014)
                                    ↓
ResNeXt (2017) ← ResNet (2015) ← Inception v2/v3/v4
       ↓
EfficientNet (2019) → Vision Transformer (2020) → ConvNeXt (2022)

3.5.2.2 VGG网络

设计理念

  • 使用多个3x3卷积堆叠代替大卷积核
  • 统一的网络结构设计
# VGG Block
def make_vgg_block(in_channels, out_channels, num_convs):
    layers = []
    for _ in range(num_convs):
        layers.append(nn.Conv2d(in_channels, out_channels, 3, padding=1))
        layers.append(nn.ReLU(inplace=True))
        in_channels = out_channels
    layers.append(nn.MaxPool2d(2, 2))
    return nn.Sequential(*layers)

# VGG16结构
class VGG16(nn.Module):
    def __init__(self, num_classes=1000):
        super().__init__()
        self.features = nn.Sequential(
            make_vgg_block(3, 64, 2),      # 224 -> 112
            make_vgg_block(64, 128, 2),    # 112 -> 56
            make_vgg_block(128, 256, 3),   # 56 -> 28
            make_vgg_block(256, 512, 3),   # 28 -> 14
            make_vgg_block(512, 512, 3),   # 14 -> 7
        )
        self.classifier = nn.Sequential(
            nn.Linear(512 * 7 * 7, 4096),
            nn.ReLU(True),
            nn.Dropout(),
            nn.Linear(4096, 4096),
            nn.ReLU(True),
            nn.Dropout(),
            nn.Linear(4096, num_classes),
        )

3.5.2.3 ResNet(重点)

残差连接原理

🏠 想象一下:你要从1楼到10楼。

传统网络(没有残差):必须一层一层爬楼梯

  • 问题:爬到第50层时,你累得走不动了(梯度消失)
  • 信息传递越来越困难

ResNet(有残差连接):每隔几层有一部电梯直达

  • 楼梯照走,但累了可以坐电梯
  • 信息可以"抄近道"直接传到后面的层
  • 梯度也能通过这条"高速公路"快速回传

另一个角度理解

  • 传统网络让每层学习"完整答案" H(x)
  • 残差网络让每层学习"还差多少" F(x) = H(x) - x
  • 学习"差异"比学习"完整答案"更容易!
传统网络:H(x) = F(x)        学习完整的变换
残差网络:H(x) = F(x) + x    学习残差(还差多少)

💡 为什么残差更容易学?
   如果最优解是恒等映射 H(x) = x
   - 传统网络需要学 F(x) = x(很难学)
   - 残差网络只需要学 F(x) = 0(很容易学,权重趋近于0即可)

        x ─────────────────────┐
        │                      │
        ▼                      │
    ┌───────┐                  │
    │ Conv  │                  │
    └───┬───┘                  │
        │                      │
        ▼                      │
    ┌───────┐                  │
    │  BN   │                  │
    └───┬───┘                  │
        │                      │
        ▼                      │
    ┌───────┐                  │
    │ ReLU  │                  │
    └───┬───┘                  │
        │                      │
        ▼                      │
    ┌───────┐                  │
    │ Conv  │                  │
    └───┬───┘                  │
        │                      │
        ▼                      │
    ┌───────┐                  │
    │  BN   │                  │
    └───┬───┘                  │
        │                      │
        ▼                      │
       (+) ◄───────────────────┘
        │
        ▼
    ┌───────┐
    │ ReLU  │
    └───────┘

ResNet实现

class BasicBlock(nn.Module):
    """ResNet基本块(用于ResNet-18/34)"""
    expansion = 1
  
    def __init__(self, in_channels, out_channels, stride=1, downsample=None):
        super().__init__()
        self.conv1 = nn.Conv2d(in_channels, out_channels, 3, stride, 1, bias=False)
        self.bn1 = nn.BatchNorm2d(out_channels)
        self.conv2 = nn.Conv2d(out_channels, out_channels, 3, 1, 1, bias=False)
        self.bn2 = nn.BatchNorm2d(out_channels)
        self.downsample = downsample
        self.relu = nn.ReLU(inplace=True)
  
    def forward(self, x):
        identity = x
      
        out = self.conv1(x)
        out = self.bn1(out)
        out = self.relu(out)
      
        out = self.conv2(out)
        out = self.bn2(out)
      
        if self.downsample is not None:
            identity = self.downsample(x)
      
        out += identity  # 残差连接
        out = self.relu(out)
      
        return out


class Bottleneck(nn.Module):
    """ResNet瓶颈块(用于ResNet-50/101/152)"""
    expansion = 4
  
    def __init__(self, in_channels, out_channels, stride=1, downsample=None):
        super().__init__()
        self.conv1 = nn.Conv2d(in_channels, out_channels, 1, bias=False)
        self.bn1 = nn.BatchNorm2d(out_channels)
        self.conv2 = nn.Conv2d(out_channels, out_channels, 3, stride, 1, bias=False)
        self.bn2 = nn.BatchNorm2d(out_channels)
        self.conv3 = nn.Conv2d(out_channels, out_channels * self.expansion, 1, bias=False)
        self.bn3 = nn.BatchNorm2d(out_channels * self.expansion)
        self.downsample = downsample
        self.relu = nn.ReLU(inplace=True)
  
    def forward(self, x):
        identity = x
      
        out = self.relu(self.bn1(self.conv1(x)))
        out = self.relu(self.bn2(self.conv2(out)))
        out = self.bn3(self.conv3(out))
      
        if self.downsample is not None:
            identity = self.downsample(x)
      
        out += identity
        out = self.relu(out)
      
        return out

3.5.2.4 MobileNet(轻量化网络)

深度可分离卷积

🍳 想象一下:你要做一道菜,需要处理3种食材(3个通道)。

标准卷积(传统方式)

  • 一个厨师同时处理所有食材,考虑它们的所有组合
  • 工作量 = 食材种类 × 调料种类 × 处理方式 = 超级大!

深度可分离卷积(分工合作)

  • 第1步 - 深度卷积:3个专门厨师,每人只处理一种食材(空间特征)
  • 第2步 - 逐点卷积:1个总厨用1×1的"调味"把所有食材融合(通道融合)
  • 分开做,工作量大大减少!

这就像"分工 + 汇总"的工作模式,效率高很多。

标准卷积参数量: kernel_h × kernel_w × in_channels × out_channels
               = 3 × 3 × 64 × 128 = 73,728 个参数

深度可分离卷积参数量: 
  深度卷积: kernel_h × kernel_w × in_channels = 3 × 3 × 64 = 576
  逐点卷积: 1 × 1 × in_channels × out_channels = 1 × 1 × 64 × 128 = 8,192
  总计: 576 + 8,192 = 8,768 个参数

🚀 压缩了 73,728 / 8,768 ≈ 8.4 倍!手机上也能跑深度学习了!
# 深度可分离卷积
class DepthwiseSeparableConv(nn.Module):
    def __init__(self, in_channels, out_channels, stride=1):
        super().__init__()
        # 深度卷积(每个通道单独卷积)
        self.depthwise = nn.Conv2d(
            in_channels, in_channels, 3, stride, 1, 
            groups=in_channels, bias=False  # groups=in_channels 是关键
        )
        self.bn1 = nn.BatchNorm2d(in_channels)
        # 逐点卷积(1x1卷积融合通道)
        self.pointwise = nn.Conv2d(in_channels, out_channels, 1, bias=False)
        self.bn2 = nn.BatchNorm2d(out_channels)
        self.relu = nn.ReLU(inplace=True)
  
    def forward(self, x):
        x = self.relu(self.bn1(self.depthwise(x)))
        x = self.relu(self.bn2(self.pointwise(x)))
        return x

3.5.2.5 EfficientNet

复合缩放策略

三个维度同时缩放:
- 深度(depth): 网络层数
- 宽度(width): 通道数
- 分辨率(resolution): 输入图像尺寸

缩放公式:
depth = α^φ
width = β^φ
resolution = γ^φ

约束: α × β² × γ² ≈ 2

EfficientNet-B0 到 B7 通过调整 φ 实现不同规模

使用预训练EfficientNet

import torchvision.models as models

# 加载预训练EfficientNet
model = models.efficientnet_b0(pretrained=True)

# 修改最后一层用于自定义分类
num_classes = 10
model.classifier[1] = nn.Linear(model.classifier[1].in_features, num_classes)

学习目标

  • 理解ResNet残差连接的原理
  • 手写实现ResNet的BasicBlock和Bottleneck
  • 理解深度可分离卷积的原理
  • 能使用预训练模型进行迁移学习

3.5.3 目标检测(1-2周)

3.5.3.1 目标检测基础概念

直观理解

🎯 目标检测要回答两个问题

  1. 图里有什么?(分类)
  2. 在哪里?(定位)

想象你是一个寻宝猎人,拿到一张藏宝图(图像),你需要:

  • 找出图上所有的宝藏(检测目标)
  • 用方框圈出每个宝藏的位置(边界框)
  • 说出每个宝藏是什么(类别)

核心概念

概念 说明 形象比喻 重要程度
Bounding Box 边界框 (x, y, w, h) 📦 用一个矩形框"圈住"目标 ⭐⭐⭐⭐⭐
IoU 交并比,衡量框重叠程度 🔵🔴 两个圆重叠部分占总面积的比例 ⭐⭐⭐⭐⭐
Anchor 预定义的参考框 📐 提前准备好的"模板框",预测时微调它 ⭐⭐⭐⭐⭐
NMS 非极大值抑制,去重复框 🏆 选美比赛,同一区域只保留"最美"的那个框 ⭐⭐⭐⭐⭐
mAP 平均精度均值,评估指标 📊 检测效果的"综合考试成绩" ⭐⭐⭐⭐⭐

IoU计算

🍕 IoU的直观理解: 想象两个披萨盒子重叠在一起:

  • 交集:两个盒子都覆盖的区域(重叠部分)
  • 并集:至少一个盒子覆盖的区域(总面积,去掉重复计算)
  • IoU = 交集 / 并集

IoU = 1.0 表示完美重合,IoU = 0 表示完全不重叠 通常 IoU > 0.5 认为检测成功

def calculate_iou(box1, box2):
    """
    计算两个边界框的IoU
    box格式: [x1, y1, x2, y2]
    """
    # 计算交集
    x1 = max(box1[0], box2[0])
    y1 = max(box1[1], box2[1])
    x2 = min(box1[2], box2[2])
    y2 = min(box1[3], box2[3])
  
    intersection = max(0, x2 - x1) * max(0, y2 - y1)
  
    # 计算并集
    area1 = (box1[2] - box1[0]) * (box1[3] - box1[1])
    area2 = (box2[2] - box2[0]) * (box2[3] - box2[1])
    union = area1 + area2 - intersection
  
    return intersection / union if union > 0 else 0

NMS实现

🏆 NMS的直观理解(选美比赛)

问题:检测一只猫,结果出现了10个框都说"这里有猫",怎么办?

NMS的做法:

  1. 把所有框按"置信度"排名(谁最确定这里有猫)
  2. 选出第1名,它肯定要保留
  3. 和第1名重叠很多的框(IoU > 阈值),说明它们在检测同一只猫,删掉
  4. 剩下的框里再选第1名,重复这个过程

最终效果:每个目标只保留最好的一个框!

def nms(boxes, scores, iou_threshold=0.5):
    """
    非极大值抑制
    boxes: (N, 4) - [x1, y1, x2, y2]
    scores: (N,) - 置信度分数
    """
    # 按分数排序
    indices = scores.argsort()[::-1]
    keep = []
  
    while len(indices) > 0:
        # 保留最高分的框
        current = indices[0]
        keep.append(current)
      
        if len(indices) == 1:
            break
      
        # 计算当前框与其他框的IoU
        current_box = boxes[current]
        other_boxes = boxes[indices[1:]]
      
        ious = np.array([calculate_iou(current_box, box) for box in other_boxes])
      
        # 保留IoU小于阈值的框
        indices = indices[1:][ious < iou_threshold]
  
    return keep

3.5.3.2 Two-Stage检测器

🔍 Two-Stage vs One-Stage 检测器

Two-Stage(两阶段):先找"哪里可能有东西",再仔细看"是什么"

  • 就像逛街:先扫一眼哪些店铺可能有你要的东西,再进去仔细看
  • 精度高,但速度慢

One-Stage(单阶段):一眼看过去,直接说"那里有猫、这里有狗"

  • 就像扫一眼照片就能说出所有内容
  • 速度快,适合实时检测

R-CNN系列演进

🚗 R-CNN系列就像"快递配送"的进化:

R-CNN (2014) - "步行送快递"
  ├── 使用Selective Search提取候选区域(~2000个"可能有货的地方")
  ├── 每个区域单独通过CNN提取特征(每个地方都单独走一趟)
  └── SVM分类 + 边界框回归
  问题:速度慢(每张图47秒)🐢

Fast R-CNN (2015) - "骑自行车送快递"
  ├── 整图通过CNN得到特征图(先画一张全城地图)
  ├── RoI Pooling从特征图提取候选区域特征(在地图上找位置,不用实地走)
  └── 共享卷积计算,速度提升
  问题:候选区域生成仍是瓶颈(还是要人工找"哪里可能有货")

Faster R-CNN (2015) - "智能导航送快递"
  ├── 引入Region Proposal Network (RPN)(让神经网络自己找"可能有货的地方")
  ├── 端到端训练(一条龙服务)
  └── 使用Anchor机制(用模板框作为参考)
  速度:5fps 🚀

RPN网络

class RPN(nn.Module):
    """Region Proposal Network"""
    def __init__(self, in_channels, num_anchors=9):
        super().__init__()
        self.conv = nn.Conv2d(in_channels, 256, 3, 1, 1)
        # 分类头:前景/背景
        self.cls_head = nn.Conv2d(256, num_anchors * 2, 1)
        # 回归头:边界框偏移
        self.reg_head = nn.Conv2d(256, num_anchors * 4, 1)
  
    def forward(self, x):
        x = F.relu(self.conv(x))
        cls_scores = self.cls_head(x)  # (B, num_anchors*2, H, W)
        bbox_deltas = self.reg_head(x)  # (B, num_anchors*4, H, W)
        return cls_scores, bbox_deltas

3.5.3.3 One-Stage检测器

YOLO系列

YOLOv1 (2016): 实时检测的开创性工作
YOLOv2 (2017): 引入Anchor,批归一化
YOLOv3 (2018): 多尺度预测,残差连接
YOLOv4 (2020): CSPNet,Mish激活函数
YOLOv5 (2020): PyTorch实现,工程优化
YOLOv7 (2022): E-ELAN,复合缩放
YOLOv8 (2023): Anchor-free,解耦头

使用YOLOv8

from ultralytics import YOLO

# 加载预训练模型
model = YOLO('yolov8n.pt')  # n/s/m/l/x 不同规模

# 推理
results = model('image.jpg')

# 可视化结果
for result in results:
    boxes = result.boxes  # 边界框
    masks = result.masks  # 分割掩码(如果有)
    probs = result.probs  # 分类概率
    result.show()  # 显示结果

# 训练自定义数据集
model.train(
    data='custom_dataset.yaml',
    epochs=100,
    imgsz=640,
    batch=16
)

# 导出模型
model.export(format='onnx')

自定义数据集格式

# custom_dataset.yaml
path: /path/to/dataset
train: images/train
val: images/val
test: images/test

names:
  0: person
  1: car
  2: dog
dataset/
├── images/
│   ├── train/
│   │   ├── img001.jpg
│   │   └── ...
│   └── val/
│       └── ...
└── labels/
    ├── train/
    │   ├── img001.txt  # class x_center y_center width height (归一化)
    │   └── ...
    └── val/
        └── ...

3.5.3.4 评估指标

mAP计算

def calculate_ap(recalls, precisions):
    """计算单个类别的AP(使用11点插值或所有点插值)"""
    # 11点插值法
    ap = 0
    for t in np.arange(0, 1.1, 0.1):
        if np.sum(recalls >= t) == 0:
            p = 0
        else:
            p = np.max(precisions[recalls >= t])
        ap += p / 11
    return ap

# mAP = 所有类别AP的平均值
# mAP@0.5: IoU阈值为0.5时的mAP
# mAP@0.5:0.95: IoU从0.5到0.95,步长0.05的平均mAP

学习目标

  • 理解IoU和NMS的原理
  • 手写实现IoU计算和NMS算法
  • 理解Anchor机制
  • 使用YOLOv8完成自定义目标检测任务

3.5.4 图像分割(1-2周)

3.5.4.1 分割任务类型

🎨 图像分割就是给图像"涂色"

想象你有一张照片,要给它做标注:

  • 图像分类:这张图里有什么?"有猫" ✓
  • 目标检测:猫在哪?画个框
  • 图像分割:猫的每一个像素在哪?逐像素涂色!

分割是最精细的任务——不只是画框,而是精确描绘出物体的轮廓。

类型 说明 形象比喻 输出 代表模型
语义分割 像素级分类 🎨 给每类物体涂不同颜色(所有猫涂红色,所有狗涂蓝色) 每个像素的类别 FCN, U-Net, DeepLab
实例分割 区分同类不同个体 🐱🐱 两只猫涂不同颜色(猫1涂红色,猫2涂粉色) 每个实例的掩码 Mask R-CNN
全景分割 语义+实例 🌍 背景用语义分割,前景用实例分割 背景+实例掩码 Panoptic FPN

3.5.4.2 FCN(全卷积网络)

class FCN(nn.Module):
    """全卷积网络"""
    def __init__(self, num_classes):
        super().__init__()
        # 编码器(使用预训练VGG)
        vgg = models.vgg16(pretrained=True)
        self.features = vgg.features
      
        # 解码器(1x1卷积 + 上采样)
        self.fc6 = nn.Conv2d(512, 4096, 7)
        self.fc7 = nn.Conv2d(4096, 4096, 1)
        self.score = nn.Conv2d(4096, num_classes, 1)
      
        # 上采样(双线性插值或转置卷积)
        self.upsample = nn.ConvTranspose2d(
            num_classes, num_classes, 64, stride=32, padding=16
        )
  
    def forward(self, x):
        x = self.features(x)
        x = F.relu(self.fc6(x))
        x = F.relu(self.fc7(x))
        x = self.score(x)
        x = self.upsample(x)
        return x

3.5.4.3 U-Net(重点)

直观理解

📸 U-Net就像"压缩-解压"照片,但有"小抄"

编码器(下采样):把高清图压缩成低分辨率

  • 就像把4K视频压缩成480p,丢失了很多细节
  • 但换来了"全局理解"——知道图里有什么

解码器(上采样):把低分辨率还原成高清

  • 就像把480p放大回4K,但怎么恢复丢失的细节?

Skip Connection(跳跃连接):这是U-Net的精髓!

  • 编码器每一层都把原始细节"抄一份"传给解码器
  • 解码器不用完全靠猜,有"小抄"可以参考!
  • 所以U-Net能恢复精细的边缘和细节

架构特点

编码器                              解码器
   │                                  │
Input ─────────────────────────────► Output
   │                                  ↑
   ▼                                  │
┌─────┐    Skip Connection      ┌─────┐
│Conv │ ─────────────────────► │Conv │
│Block│                         │Block│
└──┬──┘                         └──┬──┘
   │                               ↑
   ▼                               │
┌─────┐                        ┌─────┐
│Pool │                        │ Up  │
└──┬──┘                        └──┬──┘
   │                               │
   ▼                               │
┌─────┐    Skip Connection      ┌─────┐
│Conv │ ─────────────────────► │Conv │
│Block│                         │Block│
└──┬──┘                         └──┬──┘
   │                               ↑
   ▼                               │
┌─────┐                        ┌─────┐
│Pool │                        │ Up  │
└──┬──┘                        └──┬──┘
   │                               │
   └───────────► Bottleneck ───────┘

U-Net实现

class DoubleConv(nn.Module):
    """(Conv -> BN -> ReLU) × 2"""
    def __init__(self, in_channels, out_channels):
        super().__init__()
        self.double_conv = nn.Sequential(
            nn.Conv2d(in_channels, out_channels, 3, padding=1),
            nn.BatchNorm2d(out_channels),
            nn.ReLU(inplace=True),
            nn.Conv2d(out_channels, out_channels, 3, padding=1),
            nn.BatchNorm2d(out_channels),
            nn.ReLU(inplace=True)
        )
  
    def forward(self, x):
        return self.double_conv(x)


class UNet(nn.Module):
    def __init__(self, in_channels=3, num_classes=1):
        super().__init__()
      
        # 编码器
        self.enc1 = DoubleConv(in_channels, 64)
        self.enc2 = DoubleConv(64, 128)
        self.enc3 = DoubleConv(128, 256)
        self.enc4 = DoubleConv(256, 512)
      
        self.pool = nn.MaxPool2d(2)
      
        # 瓶颈层
        self.bottleneck = DoubleConv(512, 1024)
      
        # 解码器
        self.up4 = nn.ConvTranspose2d(1024, 512, 2, stride=2)
        self.dec4 = DoubleConv(1024, 512)  # 512 + 512 skip connection
      
        self.up3 = nn.ConvTranspose2d(512, 256, 2, stride=2)
        self.dec3 = DoubleConv(512, 256)
      
        self.up2 = nn.ConvTranspose2d(256, 128, 2, stride=2)
        self.dec2 = DoubleConv(256, 128)
      
        self.up1 = nn.ConvTranspose2d(128, 64, 2, stride=2)
        self.dec1 = DoubleConv(128, 64)
      
        self.out_conv = nn.Conv2d(64, num_classes, 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))
      
        # 解码(带Skip Connection)
        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_conv(d1)

3.5.4.4 DeepLab系列

空洞卷积(Atrous Convolution)

🕳️ 空洞卷积:跳着格子看

想象你在看一幅很大的画:

  • 普通卷积:眼睛贴着画看,一次只能看3×3的区域
  • 空洞卷积:眼睛离远点看,虽然还是选3×3个点,但每个点之间隔着一段距离

好处

  • 感受野变大了(能看到更大范围)
  • 分辨率没变(不像池化那样缩小图像)
  • 参数量不变(还是9个权重)

就像"站远一点看全貌,但不会漏掉细节"

# 空洞卷积:扩大感受野而不减少分辨率
atrous_conv = nn.Conv2d(256, 256, 3, padding=2, dilation=2)

# dilation=1: 普通卷积(紧挨着看)     感受野 3×3
# dilation=2: 隔1格看                  感受野 5×5
# dilation=4: 隔3格看                  感受野 9×9

# 🎯 适用场景:语义分割需要大感受野但又要保持高分辨率

ASPP模块(Atrous Spatial Pyramid Pooling)

class ASPP(nn.Module):
    """空洞空间金字塔池化"""
    def __init__(self, in_channels, out_channels=256):
        super().__init__()
      
        # 1x1卷积
        self.conv1 = nn.Conv2d(in_channels, out_channels, 1)
      
        # 多尺度空洞卷积
        self.conv2 = nn.Conv2d(in_channels, out_channels, 3, padding=6, dilation=6)
        self.conv3 = nn.Conv2d(in_channels, out_channels, 3, padding=12, dilation=12)
        self.conv4 = nn.Conv2d(in_channels, out_channels, 3, padding=18, dilation=18)
      
        # 全局平均池化
        self.pool = nn.Sequential(
            nn.AdaptiveAvgPool2d(1),
            nn.Conv2d(in_channels, out_channels, 1)
        )
      
        # 融合
        self.project = nn.Conv2d(out_channels * 5, out_channels, 1)
  
    def forward(self, x):
        size = x.shape[2:]
      
        feat1 = self.conv1(x)
        feat2 = self.conv2(x)
        feat3 = self.conv3(x)
        feat4 = self.conv4(x)
        feat5 = F.interpolate(self.pool(x), size=size, mode='bilinear')
      
        out = torch.cat([feat1, feat2, feat3, feat4, feat5], dim=1)
        return self.project(out)

3.5.4.5 分割损失函数

🎯 为什么需要Dice Loss?

想象你要分割医学图像中的肿瘤,但肿瘤只占图像的1%:

  • 用普通的交叉熵损失,模型可能学会"全预测成背景",准确率99%但完全没用!
  • Dice Loss 专门衡量"预测区域"和"真实区域"的重合程度

Dice系数的直观理解

  • 分子 = 2 × 重叠面积(预测对了的部分)
  • 分母 = 预测面积 + 真实面积
  • Dice = 1 表示完美重合,Dice = 0 表示完全不重合

这个指标不受类别不平衡影响,因为它只关注"目标区域预测得准不准"

# Dice Loss(处理类别不平衡)
class DiceLoss(nn.Module):
    def __init__(self, smooth=1.0):
        super().__init__()
        self.smooth = smooth
  
    def forward(self, pred, target):
        pred = torch.sigmoid(pred)
      
        intersection = (pred * target).sum()
        union = pred.sum() + target.sum()
      
        dice = (2 * intersection + self.smooth) / (union + self.smooth)
        return 1 - dice

# 组合损失
class CombinedLoss(nn.Module):
    def __init__(self, alpha=0.5):
        super().__init__()
        self.bce = nn.BCEWithLogitsLoss()
        self.dice = DiceLoss()
        self.alpha = alpha
  
    def forward(self, pred, target):
        return self.alpha * self.bce(pred, target) + (1 - self.alpha) * self.dice(pred, target)

学习目标

  • 理解语义分割和实例分割的区别
  • 手写实现U-Net网络
  • 理解Skip Connection的作用
  • 完成医学图像分割任务

3.5.5 数据增强与迁移学习(1周)

3.5.5.1 传统数据增强

📸 数据增强:让一张照片变出"一百张"

问题:深度学习需要大量数据,但收集和标注很贵!

解决方案:对已有图片做各种变换,"变"出更多训练数据

  • 翻转:猫朝左和朝右都是猫
  • 旋转:斜着的猫还是猫
  • 缩放:远处的猫和近处的猫都是猫
  • 颜色变化:不同光线下的猫还是猫

本质:教会模型"不变性"——无论图片怎么变,它还是那只猫!

import torchvision.transforms as T

# 训练时的数据增强
train_transform = T.Compose([
    T.RandomResizedCrop(224),          # 随机裁剪并缩放
    T.RandomHorizontalFlip(),          # 水平翻转
    T.RandomRotation(15),              # 随机旋转
    T.ColorJitter(                     # 颜色抖动
        brightness=0.2,
        contrast=0.2,
        saturation=0.2,
        hue=0.1
    ),
    T.ToTensor(),
    T.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225])
])

# 验证/测试时
val_transform = T.Compose([
    T.Resize(256),
    T.CenterCrop(224),
    T.ToTensor(),
    T.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225])
])

3.5.5.2 高级数据增强

🍹 高级数据增强:混合调酒师

Mixup(像调鸡尾酒)

  • 把两张图片"按比例混合":0.7×猫图 + 0.3×狗图 = 一张"半透明重叠"的图
  • 标签也混合:[0.7猫, 0.3狗]
  • 教模型:"这个看起来像猫但有点像狗的东西,70%是猫"

CutMix(像拼贴画)

  • 从狗图上裁一块,贴到猫图上
  • 标签按面积比例设置
  • 比Mixup更"真实",因为不是半透明叠加

效果:模型更健壮,不会因为"从没见过这种组合"就崩溃

Mixup

def mixup(x, y, alpha=1.0):
    """Mixup数据增强:两张图按比例混合"""
    lam = np.random.beta(alpha, alpha)
    batch_size = x.size(0)
    index = torch.randperm(batch_size)
  
    mixed_x = lam * x + (1 - lam) * x[index, :]
    y_a, y_b = y, y[index]
  
    return mixed_x, y_a, y_b, lam

# 训练时
for x, y in dataloader:
    x, y_a, y_b, lam = mixup(x, y)
    output = model(x)
    loss = lam * criterion(output, y_a) + (1 - lam) * criterion(output, y_b)

CutMix

def cutmix(x, y, alpha=1.0):
    """CutMix数据增强"""
    lam = np.random.beta(alpha, alpha)
    batch_size = x.size(0)
    index = torch.randperm(batch_size)
  
    # 计算裁剪区域
    W, H = x.size(3), x.size(2)
    cut_rat = np.sqrt(1 - lam)
    cut_w = int(W * cut_rat)
    cut_h = int(H * cut_rat)
  
    cx = np.random.randint(W)
    cy = np.random.randint(H)
  
    bbx1 = np.clip(cx - cut_w // 2, 0, W)
    bby1 = np.clip(cy - cut_h // 2, 0, H)
    bbx2 = np.clip(cx + cut_w // 2, 0, W)
    bby2 = np.clip(cy + cut_h // 2, 0, H)
  
    # 裁剪并粘贴
    x[:, :, bby1:bby2, bbx1:bbx2] = x[index, :, bby1:bby2, bbx1:bbx2]
  
    # 调整lambda
    lam = 1 - ((bbx2 - bbx1) * (bby2 - bby1) / (W * H))
  
    return x, y, y[index], lam

3.5.5.3 使用Albumentations

import albumentations as A
from albumentations.pytorch import ToTensorV2

# 强大的数据增强库
transform = A.Compose([
    A.RandomResizedCrop(224, 224),
    A.HorizontalFlip(p=0.5),
    A.ShiftScaleRotate(p=0.5),
    A.OneOf([
        A.GaussNoise(),
        A.GaussianBlur(),
        A.MotionBlur(),
    ], p=0.3),
    A.OneOf([
        A.RandomBrightnessContrast(),
        A.HueSaturationValue(),
    ], p=0.3),
    A.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]),
    ToTensorV2()
])

# 使用
augmented = transform(image=image)
image = augmented['image']

3.5.5.4 迁移学习策略

🎓 迁移学习:站在巨人的肩膀上

问题:从零训练一个好模型需要百万张图片,但你只有1000张?

解决方案:借用别人在大数据集上训练好的模型!

三种策略

策略 做法 适用场景 比喻
特征提取 冻结预训练层,只训练最后一层 数据很少(<1000) 🎯 用别人的望远镜,只换目镜
微调 解冻部分层,用小学习率训练 数据中等(1000-10000) 🔧 在别人作品基础上修改
分层学习率 底层小学习率,顶层大学习率 数据较多 🎨 底层保留,顶层重画

为什么有效? CNN底层学习的是"边缘、纹理、颜色"等通用特征,这些在任何图像任务中都有用!

import torchvision.models as models

# 策略1: 特征提取(冻结预训练层)🎯
def create_feature_extractor(num_classes):
    model = models.resnet50(pretrained=True)
  
    # 冻结所有参数(不更新预训练的权重)
    for param in model.parameters():
        param.requires_grad = False
  
    # 替换最后一层
    model.fc = nn.Linear(model.fc.in_features, num_classes)
  
    return model

# 策略2: 微调(解冻部分层)
def create_finetuned_model(num_classes, unfreeze_from='layer4'):
    model = models.resnet50(pretrained=True)
  
    # 冻结所有参数
    for param in model.parameters():
        param.requires_grad = False
  
    # 解冻指定层
    unfreeze = False
    for name, child in model.named_children():
        if name == unfreeze_from:
            unfreeze = True
        if unfreeze:
            for param in child.parameters():
                param.requires_grad = True
  
    # 替换最后一层
    model.fc = nn.Linear(model.fc.in_features, num_classes)
  
    return model

# 策略3: 使用不同学习率
def get_optimizer(model, lr=0.001, pretrained_lr=0.0001):
    pretrained_params = []
    new_params = []
  
    for name, param in model.named_parameters():
        if 'fc' in name:  # 新添加的层
            new_params.append(param)
        else:
            pretrained_params.append(param)
  
    optimizer = torch.optim.Adam([
        {'params': pretrained_params, 'lr': pretrained_lr},
        {'params': new_params, 'lr': lr}
    ])
  
    return optimizer

学习目标

  • 掌握常用数据增强技术
  • 理解Mixup和CutMix的原理
  • 掌握迁移学习的几种策略
  • 使用预训练模型完成分类任务

3.5.6 实践项目

项目1:CIFAR-10图像分类

目标:使用ResNet在CIFAR-10上达到90%+准确率

要求

  1. 实现ResNet-18网络
  2. 使用数据增强
  3. 训练并调优模型
  4. 分析错误案例

项目2:自定义目标检测

目标:使用YOLOv8训练自定义目标检测模型

要求

  1. 收集并标注自定义数据集(如口罩检测)
  2. 配置YOLOv8训练
  3. 评估模型性能
  4. 部署推理服务

项目3:医学图像分割

目标:使用U-Net完成医学图像分割

要求

  1. 实现U-Net网络
  2. 在Kaggle数据集上训练
  3. 使用Dice Loss
  4. 可视化分割结果

项目4:迁移学习实战

目标:使用ImageNet预训练模型完成小样本分类

要求

  1. 使用预训练ResNet/EfficientNet
  2. 在小数据集(<1000张)上微调
  3. 对比不同微调策略效果
  4. 分析特征可视化

阶段3.5 Checklist

完成以下任务后,进入阶段3.6(生成模型)或阶段四(NLP基础):

  • CNN深入

    • 理解卷积运算和感受野
    • 手写实现2D卷积
    • 理解BatchNorm作用
  • 经典架构

    • 理解ResNet残差连接
    • 能使用预训练模型
    • 了解轻量化网络设计
  • 目标检测

    • 理解IoU和NMS
    • 理解Anchor机制
    • 使用YOLOv8完成检测任务
  • 图像分割

    • 理解语义分割原理
    • 实现U-Net网络
    • 理解Dice Loss
  • 实践项目

    • 完成CIFAR-10分类(准确率90%+)
    • 完成自定义目标检测或图像分割项目

下一步