feat: 完整中文翻译 maths-cs-ai-compendium(数学·计算机科学·AI 知识大全)

翻译自英文原版 maths-cs-ai-compendium,共 20 章全部完成。

第01章 向量 | 第02章 矩阵 | 第03章 微积分
第04章 统计学 | 第05章 概率论 | 第06章 机器学习
第07章 计算语言学 | 第08章 计算机视觉 | 第09章 音频与语音
第10章 多模态学习 | 第11章 自主系统 | 第12章 图神经网络
第13章 计算与操作系统 | 第14章 数据结构与算法
第15章 生产级软件工程 | 第16章 SIMD与GPU编程
第17章 AI推理 | 第18章 ML系统设计
第19章 应用人工智能 | 第20章 前沿人工智能

翻译说明:
- 所有数学公式 $...$ / $$...$$、代码块、图片引用完整保留
- mkdocs.yml 配置中文导航 + language: zh
- README.md 已翻译为中文(兼 docs/index.md)
- docs/ 目录包含指向各章文件的 symlink
- 约 29,000 行中文内容,排除 .cache/ 构建缓存
This commit is contained in:
2026-05-03 10:23:20 +08:00
commit 2536c937e3
400 changed files with 49040 additions and 0 deletions
@@ -0,0 +1,363 @@
# 图像基础
*图像基础解释数字图像在被任何模型处理之前如何表示、形成和预处理。本文涵盖像素、色彩空间(RGB、HSV、YCbCr、LAB)、针孔相机模型、卷积、边缘检测(Sobel、Canny)、直方图以及特征描述子(SIFT、ORB),是底层视觉的工具包。*
- **数字图像**是一个二维数字网格。网格中的每个单元格是一个**像素**(图像元素),其值表示强度或颜色。灰度图像是一个单一的二维矩阵,其中每个像素包含一个亮度值,对于 8 位图像,通常范围从 0(黑色)到 255(白色)。
- 彩色图像将此扩展到三个通道。在 **RGB** 色彩空间中,每个像素存储三个值:红色、绿色和蓝色的强度。
- 彩色图像是一个形状为 (高度, 宽度, 3) 的三维张量(矩阵)。以不同强度混合这三个通道可以产生完整的可见光谱。
![彩色图像分解为红、绿、蓝三个通道,每个通道显示为灰度强度图](../images/rgb_channels.svg)
- **位深度**决定每个通道可以表示的离散强度级别数量。
- 8 位图像每个通道有 $2^8 = 256$ 个级别,总共 $256^3 \approx 1670$ 万种可能的颜色。16 位图像每个通道有 65,536 个级别,用于医学成像和高动态范围摄影等对精细强度差异敏感的场景。
- RGB 便于显示,但其他色彩空间更适合不同的任务。
- **HSV**(色调、饱和度、明度)将颜色信息与亮度分离。色调是纯色(在色环上 0-360 度),饱和度是颜色的鲜艳程度(0 = 灰色,1 = 纯色),明度是亮度。HSV 适合基于颜色的分割,因为你可以仅根据色调设定阈值,而无需考虑光照条件。在 HSV 中检测"红色物体"比在 RGB 中容易得多。
- **YCbCr** 将亮度(Y,感知亮度)与色度(Cb、Cr,颜色差异信号)分离。这是 JPEG 压缩和视频编解码器中使用的色彩空间。人眼对亮度比对颜色更敏感,因此色度可以以较低分辨率存储(色度子采样)而几乎不产生感知损失。
- **LAB**(CIELAB)的设计目标是使两种颜色之间的数值距离对应于感知差异。在 LAB 空间中相等的步长对人眼观察者来说看起来也是相等的。L 通道是明度,A 从绿色到红色,B 从蓝色到黄色。当需要感知均匀的颜色比较时,使用 LAB。
- **图像形成**描述三维场景如何变成二维图像。最简单的模型是**针孔相机**:来自场景的光线通过一个小孔投射到其后的传感器平面上。世界坐标系中的点 $(X, Y, Z)$ 投影到像素坐标 $(u, v)$
```math
\begin{bmatrix} u \\ v \\ 1 \end{bmatrix} = \frac{1}{Z} \begin{bmatrix} f_x & 0 & c_x \\ 0 & f_y & c_y \\ 0 & 0 & 1 \end{bmatrix} \begin{bmatrix} X \\ Y \\ Z \end{bmatrix}
```
- 这个 3x3 矩阵是**内参矩阵** $K$。它编码了相机的内部属性:焦距 $f_x, f_y$(透镜会聚光线的强度)和主点 $(c_x, c_y)$(光轴与传感器的交点,通常靠近图像中心)。对于给定的相机和镜头组合,这些参数是固定的。
![针孔相机模型:三维点通过光学中心投影到图像平面上,标注了焦距和主点](../images/pinhole_camera.svg)
- **外参**描述相机在世界中的位置:一个旋转矩阵 $R$(3x3,来自第 02 章)和一个平移向量 $t$(3x1)。它们共同将世界坐标转换为相机坐标。完整的投影是:
$$\mathbf{p} = K [R \mid t] \mathbf{P}$$
- 其中 $\mathbf{P} = [X, Y, Z, 1]^T$ 是齐次坐标下的三维点,$\mathbf{p} = [u, v, 1]^T$ 是投影后的像素。$[R \mid t]$ 矩阵是 3x4,将旋转和平移并排放置。这全是第 02 章中的线性代数。
- 真实镜头会引入**畸变**。
- **径向畸变**使直线弯曲成曲线(桶形畸变使图像向外凸出;枕形畸变使其向内收缩)。
**切向畸变**源于镜头未与传感器完全平行。
- 相机标定通过拍摄已知图案(如棋盘格)的图像来估计内参和畸变系数,然后校正(去畸变)图像。
- **空间滤波**是经典图像处理的基础。一个**滤波器**(或卷积核)是一个小矩阵(通常为 3x3 或 5x5),它在图像上滑动。在每个位置,滤波器的值与重叠的图像块逐元素相乘并求和,产生一个输出像素。这就是**二维卷积**,与驱动 CNN(文件 02)的运算相同,但这里的滤波器权重是手工设计而非学习得到的。
$$(\text{图像} * K)[i,j] = \sum_{m} \sum_{n} \text{图像}[i+m, j+n] \cdot K[m, n]$$
- 这是第 06 章中一维卷积的二维扩展。滤波器决定了该运算检测的内容:不同的滤波器检测不同的特征。
- **模糊**通过对相邻像素取平均来平滑图像。**盒式滤波器**对所有相邻像素赋予相同的权重。
- **高斯滤波器**通过二维高斯函数(第 05 章)对相邻像素加权,给相邻像素更大的权重,给远处的像素更小的权重。高斯模糊是最常见的平滑操作,由 $\sigma$ 参数化:$\sigma$ 越大,平滑程度越高。
- **中值滤波**用邻域的中值代替每个像素,而非加权平均。它在去除椒盐噪声(随机的黑白像素)方面特别有效,同时保留边缘,因为中值对异常值具有鲁棒性(如第 04 章所讨论的)。
- **边缘检测**识别像素强度急剧变化的边界。边缘承载了图像中的大部分结构信息;仅凭边缘就可以识别物体。
- **Sobel 算子**使用两个 3x3 滤波器来估计水平方向和垂直方向的梯度:
```math
G_x = \begin{bmatrix} -1 & 0 & 1 \\ -2 & 0 & 2 \\ -1 & 0 & 1 \end{bmatrix}, \quad G_y = \begin{bmatrix} -1 & -2 & -1 \\ 0 & 0 & 0 \\ 1 & 2 & 1 \end{bmatrix}
```
- 将图像与 $G_x$ 卷积得到水平梯度(对垂直边缘响应强烈),与 $G_y$ 卷积得到垂直梯度(对水平边缘响应强烈)。
- 梯度幅值 $\sqrt{G_x^2 + G_y^2}$ 和方向 $\arctan(G_y / G_x)$ 共同描述每个像素处的边缘强度和方向。这是第 03 章中梯度在图像域的对应概念。
![原始图像、Sobel 水平梯度、Sobel 垂直梯度和组合边缘幅值](../images/sobel_edges.svg)
- **Canny 边缘检测器**是边缘检测的黄金标准。它包含四个步骤:
1. 使用高斯滤波器平滑图像以减少噪声
2. 计算梯度幅值和方向(使用 Sobel)
3. **非极大值抑制**:仅保留沿梯度方向为局部最大值的像素,细化边缘
4. **滞后阈值处理**:使用两个阈值(高阈值和低阈值)。高于高阈值的像素是确定边缘。介于两个阈值之间的像素仅当连接到确定边缘时才被视为边缘。低于低阈值的像素被舍弃。
- Canny 中的双阈值使其比单阈值更鲁棒:强边缘始终被保留,弱边缘仅当属于连续边缘结构时才被保留。
- **频域**分析揭示了在空间域难以看到的模式。**二维傅里叶变换**(扩展自第 03 章的一维版本)将图像分解为不同频率和方向的正弦模式之和:
$$F(u, v) = \sum_{x=0}^{M-1} \sum_{y=0}^{N-1} f(x, y) \cdot e^{-j2\pi(ux/M + vy/N)}$$
- 低频对应平滑、缓慢变化的区域(天空、墙壁)。高频对应锐利变化(边缘、纹理、噪声)。**幅度谱**显示每个频率上存在多少能量,**相位谱**编码了空间排列信息。
- **低通滤波**去除高频,从而平滑图像(相当于空间域的高斯模糊)。**高通滤波**去除低频,从而强调边缘和细节。**带通滤波**只保留一定范围的频率,用于纹理分析。
- 在实践中,对于大尺寸滤波器,频域滤波可能比空间卷积更快,因为空间域中的卷积等价于频域中的逐元素乘法(**卷积定理**)。这直接联系到第 03 章中的傅里叶变换性质。
- **直方图**总结像素强度的分布。直方图统计每个强度值有多少像素(对于 8 位图像为 0-255)。这是第 04 章中的频率分布应用于像素值。
![图像及其强度直方图:暗图像的直方图偏左,亮图像的直方图偏右](../images/image_histogram.svg)
- 暗图像的直方图集中在左侧(低值)。亮图像的直方图集中在右侧。低对比度图像的直方图狭窄。高对比度图像的直方图宽而分散。
- **直方图均衡化**将直方图拉伸以覆盖整个强度范围,从而改善对比度。其思路是找到一个映射,使像素强度的累积分布函数(CDF)近似为线性。这是第 04 章中 CDF 概念的直接应用。
- **Otsu 方法**自动找到将图像分割为前景和背景的最佳阈值。它尝试每个可能的阈值,并选择使类内方差最小(或等价地,使类间方差最大)的阈值。这是第 04 章中方差概念应用于像素强度群体的体现。
- **特征提取**识别图像中可用于匹配、识别和三维重建的独特点或区域。好的特征应具有可重复性(在不同视角下能被再次找到)、独特性(可与其他特征区分)和计算高效性。
- **角点检测**寻找图像强度在多个方向上显著变化的点。平滑区域在任何方向上的变化都很小。边缘在一个方向上有变化。角点在至少两个方向上都有变化,使其在局部是唯一的,因此是可靠的标志点。
- **Harris 角点检测器**分析每个像素处的**结构张量**(也称为二阶矩矩阵):
```math
M = \sum_{(x,y) \in W} w(x,y) \begin{bmatrix} I_x^2 & I_x I_y \\ I_x I_y & I_y^2 \end{bmatrix}
```
- 其中 $I_x$ 和 $I_y$ 是图像梯度(使用 Sobel 计算),$W$ 是局部窗口,$w$ 是高斯加权函数。$M$ 的特征值(来自第 02 章)告诉你特征的类型:
- 两个特征值都很小:平坦区域(无特征)
- 一个很大,一个很小:边缘
- 两个都很大:角点
- Harris 不显式计算特征值,而是使用角点响应函数:$R = \det(M) - k \cdot (\text{tr}(M))^2$,其中 $\det(M) = \lambda_1 \lambda_2$ 且 $\text{tr}(M) = \lambda_1 + \lambda_2$(均来自第 02 章)。$R$ 为正且较大时表示角点。常数 $k$ 通常为 0.04-0.06。
- **Shi-Tomasi** 检测器将其简化为 $R = \min(\lambda_1, \lambda_2)$,直接检查较小的特征值是否足够大。这在实际中稍微更稳定。
- **斑点检测**寻找与周围环境不同的区域。与角点(属于点特征)不同,斑点具有特征尺寸。
- **SIFT**(尺度不变特征变换,Lowe,2004)在多个尺度上检测斑点,并构建对旋转、尺度具有不变性,对光照变化具有部分不变性的描述子。它的工作原理是:
1. 使用逐渐增大 $\sigma$ 的高斯模糊构建**尺度空间**(见下文)
2. 在尺度间的 Gaussian 差分(DoG)中寻找极值点
3. 精炼关键点位置,去除低对比度点和边缘响应
4. 基于局部梯度方向分配主方向
5. 从关键点周围 16x16 块中的梯度直方图构建 128 维描述子
- **SURF**(加速稳健特征)使用盒式滤波器和积分图像近似 SIFT 以实现更快的计算。**ORB**(定向 FAST 和旋转 BRIEF)是一个快速、开源的替代方案,它将 FAST 角点检测器与 BRIEF 二进制描述子结合,并增加了旋转不变性。
- **HOG**(方向梯度直方图)描述子将图像划分为小单元格,计算每个单元格内梯度方向的直方图,并在单元格块间进行归一化。HOG 捕捉边缘方向的分布,这对物体形状具有高度信息量。在深度学习之前,HOG + SVM(第 06 章)是行人检测和物体识别的主流方法。
- **图像金字塔**以多种分辨率表示图像。
- **高斯金字塔**通过重复模糊和下采样(分辨率减半)构建。每一层都是原始图像的粗略版本。
- **拉普拉斯金字塔**存储连续高斯层之间的差异,捕捉每一步下采样丢失的细节。拉普拉斯金字塔是可逆的:你可以从中重建原始图像。
![高斯金字塔:原始图像为全分辨率,然后每层逐步缩小为一半分辨率](../images/image_pyramid.svg)
- **尺度空间**形式化了物体存在于不同尺度这一概念。一棵树是一个大斑点;树上的一片叶子是一个小斑点。要同时检测两者,你需要跨尺度搜索。图像的尺度空间是通过将图像与逐渐增大 $\sigma$ 的高斯函数卷积得到的图像族:
$$L(x, y, \sigma) = G(x, y, \sigma) * I(x, y)$$
- 其中 $G$ 是标准差为 $\sigma$ 的二维高斯函数。跨多个尺度持续存在的特征更有可能是有意义的结构而非噪声。尺度空间是 SIFT 的理论基础,也是贯穿现代计算机视觉的多尺度处理的基础,包括目标检测中的特征金字塔网络(文件 03)。
## 编码任务(使用 CoLab 或 notebook
1. 加载图像,将其转换为不同的色彩空间(RGB、HSV、LAB),并可视化各个通道。观察颜色信息在不同空间中的分布差异。
```python
import jax.numpy as jnp
import matplotlib.pyplot as plt
from PIL import Image
import numpy as np
# Create a synthetic test image with distinct colours
H, W = 128, 256
img = np.zeros((H, W, 3), dtype=np.uint8)
img[:, :64] = [255, 50, 50] # red
img[:, 64:128] = [50, 255, 50] # green
img[:, 128:192] = [50, 50, 255] # blue
img[:, 192:] = [255, 255, 50] # yellow
# Add a brightness gradient
for y in range(H):
scale = 0.3 + 0.7 * y / H
img[y] = (img[y] * scale).astype(np.uint8)
img_jnp = jnp.array(img, dtype=jnp.float32) / 255.0
# Manual RGB to HSV conversion
def rgb_to_hsv(rgb):
r, g, b = rgb[..., 0], rgb[..., 1], rgb[..., 2]
maxc = jnp.max(rgb, axis=-1)
minc = jnp.min(rgb, axis=-1)
diff = maxc - minc + 1e-7
# Hue
h = jnp.where(maxc == minc, 0.0,
jnp.where(maxc == r, 60 * ((g - b) / diff % 6),
jnp.where(maxc == g, 60 * ((b - r) / diff + 2),
60 * ((r - g) / diff + 4))))
s = jnp.where(maxc < 1e-7, 0.0, diff / maxc)
v = maxc
return jnp.stack([h / 360, s, v], axis=-1)
hsv = rgb_to_hsv(img_jnp)
fig, axes = plt.subplots(2, 3, figsize=(14, 8))
for i, (ch, name) in enumerate(zip([img_jnp[...,0], img_jnp[...,1], img_jnp[...,2]],
['Red', 'Green', 'Blue'])):
axes[0, i].imshow(ch, cmap='gray', vmin=0, vmax=1)
axes[0, i].set_title(f'RGB: {name}'); axes[0, i].axis('off')
for i, (ch, name) in enumerate(zip([hsv[...,0], hsv[...,1], hsv[...,2]],
['Hue', 'Saturation', 'Value'])):
axes[1, i].imshow(ch, cmap='gray', vmin=0, vmax=1)
axes[1, i].set_title(f'HSV: {name}'); axes[1, i].axis('off')
plt.suptitle('RGB vs HSV Channels')
plt.tight_layout(); plt.show()
```
2. 使用二维卷积从头实现 Sobel 边缘检测和高斯模糊。将其应用于图像并比较结果。
```python
import jax
import jax.numpy as jnp
import matplotlib.pyplot as plt
def conv2d(image, kernel):
"""2D convolution (valid mode) from scratch."""
H, W = image.shape
kH, kW = kernel.shape
out_h, out_w = H - kH + 1, W - kW + 1
output = jnp.zeros((out_h, out_w))
for i in range(out_h):
for j in range(out_w):
patch = image[i:i+kH, j:j+kW]
output = output.at[i, j].set(jnp.sum(patch * kernel))
return output
# Create a test image: white rectangle on dark background
img = jnp.zeros((64, 64))
img = img.at[15:50, 20:45].set(1.0)
# Add some noise
key = jax.random.PRNGKey(42)
img = img + jax.random.normal(key, img.shape) * 0.05
# Sobel filters
sobel_x = jnp.array([[-1, 0, 1], [-2, 0, 2], [-1, 0, 1]], dtype=jnp.float32)
sobel_y = jnp.array([[-1, -2, -1], [0, 0, 0], [1, 2, 1]], dtype=jnp.float32)
# Gaussian blur kernel (5x5, sigma=1)
ax = jnp.arange(-2, 3, dtype=jnp.float32)
xx, yy = jnp.meshgrid(ax, ax)
gaussian = jnp.exp(-(xx**2 + yy**2) / (2 * 1.0**2))
gaussian = gaussian / gaussian.sum()
# Apply filters
gx = conv2d(img, sobel_x)
gy = conv2d(img, sobel_y)
edges = jnp.sqrt(gx**2 + gy**2)
blurred = conv2d(img, gaussian)
fig, axes = plt.subplots(1, 4, figsize=(16, 4))
for ax, data, title in zip(axes,
[img, edges, blurred, gx],
['Original', 'Edge Magnitude', 'Gaussian Blur', 'Horizontal Gradient']):
ax.imshow(data, cmap='gray')
ax.set_title(title); ax.axis('off')
plt.tight_layout(); plt.show()
```
3. 从头实现直方图均衡化,并将其应用于低对比度灰度图像。比较均衡前后的直方图。
```python
import jax.numpy as jnp
import matplotlib.pyplot as plt
# Create a low-contrast image (values clustered in a narrow range)
key = __import__('jax').random.PRNGKey(42)
img = __import__('jax').random.uniform(key, (128, 128)) * 0.3 + 0.3 # values in [0.3, 0.6]
def histogram_equalise(img, n_bins=256):
"""Histogram equalisation for a grayscale image."""
# Quantise to bins
bins = jnp.linspace(0, 1, n_bins + 1)
hist = jnp.histogram(img, bins=bins)[0]
# Compute CDF
cdf = jnp.cumsum(hist)
cdf_normalised = (cdf - cdf.min()) / (cdf.max() - cdf.min())
# Map each pixel through the CDF
indices = jnp.clip((img * n_bins).astype(jnp.int32), 0, n_bins - 1)
equalised = cdf_normalised[indices]
return equalised
eq_img = histogram_equalise(img)
fig, axes = plt.subplots(2, 2, figsize=(12, 10))
axes[0, 0].imshow(img, cmap='gray', vmin=0, vmax=1)
axes[0, 0].set_title('Original (Low Contrast)'); axes[0, 0].axis('off')
axes[0, 1].imshow(eq_img, cmap='gray', vmin=0, vmax=1)
axes[0, 1].set_title('After Histogram Equalisation'); axes[0, 1].axis('off')
axes[1, 0].hist(img.ravel(), bins=64, color='#3498db', alpha=0.8)
axes[1, 0].set_title('Histogram Before'); axes[1, 0].set_xlim(0, 1)
axes[1, 1].hist(eq_img.ravel(), bins=64, color='#e74c3c', alpha=0.8)
axes[1, 1].set_title('Histogram After'); axes[1, 1].set_xlim(0, 1)
plt.tight_layout(); plt.show()
```
4. 从头实现 Harris 角点检测器。在简单图像中检测角点并可视化。
```python
import jax
import jax.numpy as jnp
import matplotlib.pyplot as plt
def harris_corners(img, k=0.05, threshold=0.01):
"""Harris corner detection from scratch."""
# Compute gradients with Sobel
sobel_x = jnp.array([[-1, 0, 1], [-2, 0, 2], [-1, 0, 1]], dtype=jnp.float32)
sobel_y = jnp.array([[-1, -2, -1], [0, 0, 0], [1, 2, 1]], dtype=jnp.float32)
# Pad image for valid convolution to preserve size
img_pad = jnp.pad(img, 1, mode='edge')
H, W = img.shape
Ix = jnp.zeros_like(img)
Iy = jnp.zeros_like(img)
for i in range(H):
for j in range(W):
patch = img_pad[i:i+3, j:j+3]
Ix = Ix.at[i, j].set(jnp.sum(patch * sobel_x))
Iy = Iy.at[i, j].set(jnp.sum(patch * sobel_y))
# Structure tensor components
Ixx = Ix * Ix
Iyy = Iy * Iy
Ixy = Ix * Iy
# Gaussian smoothing of structure tensor (approximate with window sum)
w = 3 # window half-size
R = jnp.zeros_like(img)
pad_xx = jnp.pad(Ixx, w, mode='constant')
pad_yy = jnp.pad(Iyy, w, mode='constant')
pad_xy = jnp.pad(Ixy, w, mode='constant')
for i in range(H):
for j in range(W):
sxx = jnp.sum(pad_xx[i:i+2*w+1, j:j+2*w+1])
syy = jnp.sum(pad_yy[i:i+2*w+1, j:j+2*w+1])
sxy = jnp.sum(pad_xy[i:i+2*w+1, j:j+2*w+1])
det = sxx * syy - sxy * sxy
trace = sxx + syy
R = R.at[i, j].set(det - k * trace * trace)
# Threshold
corners = R > threshold * R.max()
return R, corners
# Test image: checkerboard pattern (lots of corners)
block = 16
n = 4
checker = jnp.zeros((block * n, block * n))
for i in range(n):
for j in range(n):
if (i + j) % 2 == 0:
checker = checker.at[i*block:(i+1)*block, j*block:(j+1)*block].set(1.0)
R, corners = harris_corners(checker)
cy, cx = jnp.where(corners)
fig, axes = plt.subplots(1, 3, figsize=(14, 4))
axes[0].imshow(checker, cmap='gray')
axes[0].set_title('Checkerboard'); axes[0].axis('off')
axes[1].imshow(R, cmap='hot')
axes[1].set_title('Harris Response'); axes[1].axis('off')
axes[2].imshow(checker, cmap='gray')
axes[2].scatter(cx, cy, c='#e74c3c', s=15, marker='x')
axes[2].set_title(f'Detected Corners ({len(cx)})'); axes[2].axis('off')
plt.tight_layout(); plt.show()
```
@@ -0,0 +1,382 @@
# 卷积网络
*卷积神经网络直接从像素数据中学习空间特征层级,用梯度优化的滤波器取代人工设计的滤波器。本文涵盖卷积机制、池化、步长、空洞卷积、感受野,以及定义了图像分类的标志性架构(LeNet、AlexNet、VGG、ResNet、Inception、EfficientNet)。*
- 在文件 01 中,我们手工设计了用于边缘检测、模糊和角点检测的滤波器。一个自然而然的问题是:我们能否从数据中学习最优的滤波器?这正是卷积神经网络(CNN)所做的。
- CNN 不是手动选择滤波器权重,而是通过梯度下降(第 06 章)学习它们,发现对当前任务直接有用的特征。
- 在第 06 章中,我们介绍了卷积操作、CNN 基础以及滤波器学习的思想。在这里,我们深入探讨使 CNN 在十多年来成为计算机视觉主导范式的架构创新。
- 回顾核心的**卷积操作**:一个大小为 $k \times k$ 的滤波器 $K$ 在输入特征图上滑动,在每个位置计算点积(第 06 章)。输出大小由三个超参数控制:
- **步长**:滤波器在位置之间移动的像素数。步长 1 意味着滤波器每次移动一个像素。步长 2 意味着每次移动两个像素,空间维度减半。步长卷积是下采样时池化的一种替代方案。
- **填充**:在输入边界周围添加零。"Same"填充($p = \lfloor k/2 \rfloor$)保持空间维度不变。"Valid"填充($p = 0$)会减小空间维度。
- **空洞卷积**:在滤波器元素之间插入间隙。一个 3x3 的滤波器以空洞率 2 工作,仅用 9 个参数就覆盖了 5x5 的感受野。空洞卷积扩大了感受野而不增加计算量。
- 卷积后的输出空间大小:
$$\text{out} = \left\lfloor \frac{\text{in} - k + 2p}{s} \right\rfloor + 1$$
- 其中 $\text{in}$ 是输入大小,$k$ 是卷积核大小,$p$ 是填充,$s$ 是步长。该公式独立地适用于高度和宽度。
- **感受野**是指能够影响某个神经元值的原始输入区域。
- 早期层的感受野较小(它们看到的是边缘等局部模式)。
- 更深层的感受野较大(它们看到的是物体部件等更大的结构)。
- 感受野随着每一层增长:大致每层卷积增加 $k - 1$ 个像素(加入步长或空洞卷积时增长更多)。
![感受野逐层增长:第 1 层神经元看到 3x3 的补丁,第 2 层神经元看到 5x5 的补丁,第 3 层神经元看到原始输入中 7x7 的补丁](../images/receptive_field.svg)
- **池化**层在保留最重要信息的同时降低空间维度。
- **最大池化**取每个窗口中的最大值,保留最强的激活(最突出的特征)。
- **平均池化**取均值,平滑特征图。一个 2x2 的池化窗口配合步长 2 会使两个空间维度都减半。
- **全局平均池化(GAP)** 将每个通道的整个空间范围平均为单个数值,生成一个长度等于通道数的向量。GAP 取代了许多现代架构末尾的全连接层,大幅减少了参数量,并起到了结构正则化的作用。
- **批归一化(BatchNorm)** 将每个小批量内的激活值归一化为零均值和单位方差,然后应用可学习的缩放和平移(第 06 章)。在 CNN 中,批归一化按通道应用:统计量在跨批次和空间维度上为每个通道独立计算。它稳定了训练,允许使用更高的学习率,并起到轻度正则化的作用。
- **丢弃法**(第 06 章)在训练期间随机将神经元置零。
- 在 CNN 中,**空间丢弃法(Dropout2D)** 丢弃整个特征图通道而非单个像素,这更为有效,因为特征图中相邻像素高度相关。
- **数据增广**通过在训练期间对每张图像应用随机变换来人为地扩展训练集:水平翻转、随机裁剪、旋转、颜色抖动(调整亮度、对比度、饱和度、色调)以及 cutout(遮挡随机矩形区域)。网络以多种不同形式看到每张图像,迫使其学习变换不变的特征,而非记忆特定的像素模式。
- 高级增广策略包括 **Mixup**(混合两张图像及其标签:$\tilde{x} = \lambda x_i + (1-\lambda) x_j$$\tilde{y} = \lambda y_i + (1-\lambda) y_j$)、**CutMix**(将一张图像的矩形区域粘贴到另一张图像上,并按面积比例混合标签)以及 **RandAugment**(从一个固定集合中随机采样一系列增广操作,使用单一的强度参数)。
- CNN 架构的历史是一个逐步走向更深、更高效设计的故事,每一步都解决了限制前代架构的问题。
- **LeNet-5**LeCun 等人,1998 年)是最早的 CNN,专为手写数字识别设计。两个卷积层后接三个全连接层,使用平均池化和 tanh 激活函数。它证明了学习到的滤波器优于手工设计的特征,但按现代标准来看很小(6 万个参数)。
- **AlexNet**Krizhevsky 等人,2012 年)以巨大优势赢得了 ImageNet 竞赛,引发了深度学习革命。关键创新:ReLU 激活函数(取代了存在梯度消失问题的 tanh)、用于正则化的丢弃法、数据增广以及在 GPU 上训练。五个卷积层,三个全连接层,6000 万个参数。
- **VGG**Simonyan 和 Zisserman2014 年)证明,仅使用 3x3 滤波器并深层堆叠效果优于更大的滤波器。两个堆叠的 3x3 滤波器具有与一个 5x5 滤波器相同的感受野,但参数更少($2 \times 3^2 = 18$ 对比 $5^2 = 25$)且多了一个非线性层。VGG-1616 层)和 VGG-19(19 层)至今仍被广泛用作特征提取器。架构非常简单:卷积块通道数递增(64、128、256、512),每个块后接最大池化。
![VGG 架构:堆叠的 3x3 卷积块,通道深度递增(64→128→256→512),块之间是最大池化,末端是全连接层](../images/vgg_architecture.svg)
- **GoogLeNet/Inception**Szegedy 等人,2014 年)引入了 **Inception 模块**:不是选择单一的滤波器大小,而是并行使用 1x1、3x3 和 5x5 卷积,将它们的输出拼接起来,让网络决定哪个尺度最有用。1x1 卷积在较大滤波器之前用作瓶颈以减少计算量。GoogLeNet 以比 VGG 少 12 倍的参数(680 万对比 1.38 亿)实现了更高的准确率。
![Inception 模块:四个并行分支(1×1、3×3、5×5 和池化),带 1×1 瓶颈,沿通道维度拼接](../images/inception_module.svg)
- Inception 模块同时捕获多个尺度的特征。1x1 滤波器捕获逐点模式,3x3 捕获局部纹理,5x5 捕获更大的结构。拼接将所有视角组合成丰富的表示。
- **ResNet**He 等人,2016 年)解决了**退化问题**:更深的网络表现反而不如较浅的网络,这不是因为过拟合,而是因为更深的网络更难优化。解决方案是**跳跃连接**(残差连接):
$$\text{output} = F(x) + x$$
- 该层学习残差 $F(x) = \text{output} - x$。如果最优变换接近恒等映射(这在深层网络中很常见),学习一个接近零的残差比学习完整的映射要容易得多。跳跃连接还提供了直接的梯度通道,减少了梯度消失问题。ResNet 训练了 152 层的网络,远超此前任何架构。
![ResNet 块:输入 x 经过两个卷积层得到 F(x),然后跳跃连接将 x 加回,得到输出 F(x) + x](../images/resnet_block.svg)
- 当输入和输出维度不同时(由于步长或通道数变化),**投影捷径**会应用一个 1x1 卷积来匹配 $x$ 的维度:$\text{output} = F(x) + W_s x$。
- **瓶颈块**(用于 ResNet-50 及更深版本)使用三个卷积:1x1 降通道,3x3 进行空间处理,1x1 再将通道数恢复。这比两个 3x3 卷积计算量更小,允许构建更深的网络。
- **DenseNet**Huang 等人,2017 年)将跳跃连接的思想进一步推进:在一个密集块内,每一层都与所有后续层相连。第 $l$ 层接收前面所有层的特征图作为输入:$x_l = H_l([x_0, x_1, \ldots, x_{l-1}])$,其中 $[\cdot]$ 表示沿通道维度的拼接。这促进了特征复用,增强了梯度流动,并减少了总参数量。
![DenseNet 密集块:每一层通过拼接接收前面所有层的特征图,形成密集连接以实现最大程度的特征复用](../images/densenet_block.svg)
- **高效架构**面向移动设备和边缘硬件上的部署,这些场景下计算、内存和能耗都受到限制。
- **MobileNet**Howard 等人,2017 年)用**深度可分离卷积**取代了标准卷积,将操作分解为两个步骤:
1. **深度卷积**:每个输入通道应用一个独立的 $k \times k$ 滤波器(不跨通道交互)
2. **逐点卷积**:应用 1x1 卷积来组合跨通道的信息
- 一个标准 $k \times k$ 卷积,输入通道数为 $C_{\text{in}}$,输出通道数为 $C_{\text{out}}$,每个空间位置需要 $k^2 \cdot C_{\text{in}} \cdot C_{\text{out}}$ 次乘法。深度可分离卷积需要 $k^2 \cdot C_{\text{in}} + C_{\text{in}} \cdot C_{\text{out}}$ 次,减少了大约 $k^2$ 倍。对于 3x3 滤波器,这大约便宜 9 倍。
![深度可分离卷积:深度步骤对每个通道应用一个 k×k 滤波器,然后逐点 1×1 卷积混合通道——输出形状相同,操作量约减少 9×](../images/depthwise_separable_conv.svg)
- **MobileNet-V2** 引入了**逆残差块**:先用 1x1 卷积扩展通道,在扩展空间中应用深度卷积,再用 1x1 卷积投影回低维。跳跃连接放置在窄(瓶颈)层上,与 ResNet 的模式相反。扩展率通常为 6。
- **EfficientNet**Tan 和 Le2019 年)引入了**复合缩放**:不是独立地仅缩放深度、或仅缩放宽度、或仅缩放分辨率,而是使用固定比例同时缩放所有三个维度。给定缩放系数 $\phi$:
$$\text{depth}: d = \alpha^\phi, \quad \text{width}: w = \beta^\phi, \quad \text{resolution}: r = \gamma^\phi$$
- 约束条件为 $\alpha \cdot \beta^2 \cdot \gamma^2 \approx 2$(这样 $\phi$ 每增加一个单位,总计算量大约翻倍)。通过网格搜索得到基线比例 $\alpha = 1.2$$\beta = 1.1$$\gamma = 1.15$。EfficientNet-B0 到 B7 逐步放大,以远少于之前模型的参数和 FLOPs 达到了最先进的准确率。
![EfficientNet 复合缩放:单独缩放宽度、深度或分辨率,与使用单一系数 φ 同时缩放三者](../images/efficientnet_scaling.svg)
- **ShuffleNet** 通过使用**分组卷积**后接**通道混洗**来降低 1x1 卷积(在 MobileNet 风格的架构中占主导)的成本。分组卷积将通道分成多个组,在每个组内独立进行卷积,但这阻止了跨组的信息流动。混洗操作在组之间重新排列通道,以可忽略不计的成本恢复了信息混合。
- **迁移学习**是将在一个任务上训练好的模型适配到不同任务的实践。在计算机视觉中,这几乎总是意味着从一个在 ImageNet(140 万张图像,1000 个类别)上预训练的模型开始,适配到特定领域的数据集(医学图像、卫星图像、制造缺陷检测)。
- **特征提取**:冻结所有卷积层,移除最终的分类头,仅在上面训练一个新的分类头。冻结的层充当通用特征提取器。当目标域与 ImageNet 相似且目标数据集较小时,这种方法效果很好。
- **微调**:解冻部分或全部卷积层,以较小的学习率进行训练。预训练的权重作为起点而非固定特征。微调通常先解冻后面的层(这些层捕获高级的、任务特定的特征),再根据需要解冻更早的层。
- 迁移学习之所以有效,是因为 CNN 的早期层学习通用特征(边缘、纹理、颜色),这些特征对各种任务都有用,而后面层学习任务特定的特征。一个用于分类动物的网络,其边缘检测器对分类建筑物仍然有用。
- **可视化 CNN** 可以揭示网络学到了什么,并帮助调试意外行为。
- **激活图**(特征图)展示了给定输入图像下每个滤波器的输出。早期层的激活图看起来像边缘图;更深层的激活图则越来越抽象,空间上越来越粗糙。
- **Grad-CAM**(梯度加权类别激活映射,Selvaraju 等人,2017 年)高亮了输入图像中对模型预测最重要的区域。其工作原理是:
1. 计算目标类别分数相对于最后一个卷积层特征图的梯度(使用第 03 章的链式法则)
2. 对这些梯度进行全局平均池化,得到每个通道的重要性权重
3. 计算特征图的加权组合并应用 ReLU
$$L_{\text{Grad-CAM}} = \text{ReLU}\!\left(\sum_k \alpha_k A^k\right), \quad \alpha_k = \frac{1}{Z} \sum_i \sum_j \frac{\partial y^c}{\partial A^k_{ij}}$$
- 其中 $A^k$ 是第 $k$ 个特征图,$\alpha_k$ 是通道 $k$ 的重要性权重,$y^c$ 是类别 $c$ 的分数。结果是一个粗糙的热力图,显示哪些区域驱动了分类。应用 ReLU 是因为我们只对具有正影响分类的特征感兴趣。
![Grad-CAM:一张狗的输入图像,最后一个卷积层的特征图,梯度加权组合,以及叠加在原始图像上的热力图,高亮了狗的脸部](../images/grad_cam.svg)
- **特征反演**通过优化一张随机图像使其匹配目标特征(对像素值进行梯度下降),从特征表示中重建输入图像。这揭示了网络在各层保留了哪些信息。浅层几乎能完美重建图像;深层产生的图像可识别但有所扭曲,这表明精细的空间细节丢失了,而语义内容得以保留。
- **Deep Dream** 和**神经风格迁移**是特征可视化的创意应用。Deep Dream 最大化选定层中神经元的激活,产生超现实的、放大模式的图像。神经风格迁移优化目标图像,使其同时匹配一张图像的内容特征(来自深层)和另一张图像的风格特征(滤波器激活的 Gram 矩阵,捕获纹理统计信息)。
## 编程任务(使用 CoLab 或 notebook
1. 用 JAX 从头实现一个简单的 CNN,包含两个卷积层、最大池化和一个分类头。在一个合成的二维模式分类任务上训练它。
```python
import jax
import jax.numpy as jnp
import jax.lax as lax
import matplotlib.pyplot as plt
def conv2d(x, kernel, stride=1):
"""简单 2D 卷积,单输入,单滤波器。"""
return lax.conv(x[None, None], kernel[None, None], (stride, stride), 'SAME')[0, 0]
def max_pool(x, size=2):
"""2x2 最大池化。"""
H, W = x.shape
x = x[:H//size*size, :W//size*size]
return x.reshape(H//size, size, W//size, size).max(axis=(1, 3))
def init_cnn(key):
k1, k2, k3 = jax.random.split(key, 3)
return {
'conv1': jax.random.normal(k1, (5, 5)) * 0.3,
'conv2': jax.random.normal(k2, (3, 3)) * 0.3,
'fc_w': jax.random.normal(k3, (64, 1)) * 0.1,
'fc_b': jnp.zeros(1),
}
def forward_cnn(params, img):
# Conv1 -> ReLU -> Pool
h = jnp.maximum(0, conv2d(img, params['conv1']))
h = max_pool(h)
# Conv2 -> ReLU -> Pool
h = jnp.maximum(0, conv2d(h, params['conv2']))
h = max_pool(h)
# Flatten and classify
flat = h.ravel()
# Pad or truncate to fixed size
flat = jnp.pad(flat, (0, max(0, 64 - len(flat))))[:64]
logit = (flat @ params['fc_w'] + params['fc_b']).squeeze()
return jax.nn.sigmoid(logit)
# Generate synthetic data: class 0 = low-freq pattern, class 1 = high-freq
def make_data(key, n=200):
images, labels = [], []
for i in range(n):
k1, key = jax.random.split(key)
x, y = jnp.meshgrid(jnp.linspace(0, 4*jnp.pi, 32), jnp.linspace(0, 4*jnp.pi, 32))
if i < n // 2:
img = jnp.sin(x) + jax.random.normal(k1, (32, 32)) * 0.1
labels.append(0)
else:
img = jnp.sin(4 * x) * jnp.sin(4 * y) + jax.random.normal(k1, (32, 32)) * 0.1
labels.append(1)
images.append(img)
return images, jnp.array(labels, dtype=jnp.float32)
key = jax.random.PRNGKey(42)
images, labels = make_data(key)
params = init_cnn(jax.random.PRNGKey(0))
def loss_fn(params, img, label):
pred = forward_cnn(params, img)
return -(label * jnp.log(pred + 1e-7) + (1 - label) * jnp.log(1 - pred + 1e-7))
grad_fn = jax.grad(loss_fn)
lr = 0.01
for epoch in range(5):
total_loss = 0.0
for img, label in zip(images, labels):
grads = grad_fn(params, img, label)
params = {k: params[k] - lr * grads[k] for k in params}
total_loss += loss_fn(params, img, label)
print(f"Epoch {epoch}: loss = {total_loss / len(images):.4f}")
# Test accuracy
preds = jnp.array([forward_cnn(params, img) > 0.5 for img in images])
acc = jnp.mean(preds == labels)
print(f"Accuracy: {acc:.2%}")
```
2. 可视化不同滤波器大小如何影响感受野。展示两个堆叠的 3x3 滤波器与一个 5x5 滤波器覆盖相同的感受野,但参数更少。
```python
import jax.numpy as jnp
import matplotlib.pyplot as plt
def compute_receptive_field(layers):
"""从一组 (kernel_size, stride) 元组计算感受野大小。"""
rf = 1 # 从 1 个像素开始
stride_product = 1
for k, s in layers:
rf += (k - 1) * stride_product
stride_product *= s
return rf
# Compare architectures
configs = {
'Single 5x5': [(5, 1)],
'Two 3x3': [(3, 1), (3, 1)],
'Three 3x3': [(3, 1), (3, 1), (3, 1)],
'Single 7x7': [(7, 1)],
'3x3 stride 2 + 3x3': [(3, 2), (3, 1)],
}
print(f"{'Config':<25} {'RF':>4} {'Params (per channel)':>20}")
print('-' * 55)
for name, layers in configs.items():
rf = compute_receptive_field(layers)
# Parameters: sum of k^2 for each layer (per input-output channel pair)
params = sum(k * k for k, s in layers)
print(f"{name:<25} {rf:>4} {params:>20}")
# Visualise receptive fields
fig, axes = plt.subplots(1, 3, figsize=(14, 4))
for ax, (name, rf_size) in zip(axes, [('5x5 filter', 5), ('Two 3x3 filters', 5), ('Three 3x3 filters', 7)]):
grid = jnp.zeros((9, 9))
c = 4 # centre
half = rf_size // 2
grid = grid.at[c-half:c+half+1, c-half:c+half+1].set(1.0)
ax.imshow(grid, cmap='Blues', vmin=0, vmax=1)
ax.set_title(f'{name}\nRF = {rf_size}x{rf_size}')
ax.set_xticks(range(9)); ax.set_yticks(range(9))
ax.grid(True, alpha=0.3)
plt.suptitle('Receptive Field Comparison')
plt.tight_layout(); plt.show()
```
3. 从头实现 Grad-CAM。给定一个预构建的简单 CNN,计算针对特定类别的梯度加权激活图,并将其可视化为热力图。
```python
import jax
import jax.numpy as jnp
import matplotlib.pyplot as plt
def simple_cnn(params, img):
"""返回预测和最后一个卷积层激活的简单 CNN。"""
# Conv layer (our "last conv layer" for Grad-CAM)
H, W = img.shape
k = params['conv'].shape[0]
pad = k // 2
img_pad = jnp.pad(img, pad, mode='edge')
activation_map = jnp.zeros((H, W))
for i in range(H):
for j in range(W):
activation_map = activation_map.at[i, j].set(
jnp.sum(img_pad[i:i+k, j:j+k] * params['conv'])
)
activation_map = jnp.maximum(0, activation_map) # ReLU
# Global average pool -> dense -> output
pooled = activation_map.mean()
logit = pooled * params['w'] + params['b']
return jax.nn.sigmoid(logit), activation_map
# Create test image: bright region on the left (class indicator)
img = jnp.zeros((32, 32))
img = img.at[8:24, 4:16].set(1.0)
img = img.at[5:10, 20:28].set(0.3)
key = jax.random.PRNGKey(42)
params = {
'conv': jax.random.normal(key, (5, 5)) * 0.3,
'w': jnp.array(2.0),
'b': jnp.array(-0.5),
}
# Compute Grad-CAM
def class_score(params, img):
pred, _ = simple_cnn(params, img)
return pred
# Get activation map and gradients
pred, act_map = simple_cnn(params, img)
grad_fn = jax.grad(lambda img: simple_cnn(params, img)[0])
img_grad = grad_fn(img)
# Weight = global average of gradients (simplified 1-channel Grad-CAM)
alpha = img_grad.mean()
grad_cam = jnp.maximum(0, alpha * act_map) # ReLU
grad_cam = (grad_cam - grad_cam.min()) / (grad_cam.max() - grad_cam.min() + 1e-8)
fig, axes = plt.subplots(1, 3, figsize=(14, 4))
axes[0].imshow(img, cmap='gray'); axes[0].set_title('Input Image'); axes[0].axis('off')
axes[1].imshow(act_map, cmap='viridis'); axes[1].set_title('Activation Map'); axes[1].axis('off')
axes[2].imshow(img, cmap='gray', alpha=0.6)
axes[2].imshow(grad_cam, cmap='jet', alpha=0.4)
axes[2].set_title(f'Grad-CAM (pred={pred:.2f})'); axes[2].axis('off')
plt.tight_layout(); plt.show()
```
4. 比较深度可分离卷积与标准卷积。统计两者的参数和 FLOPs,并展示它们在计算量少得多的情况下产生相似的输出。
```python
import jax
import jax.numpy as jnp
def standard_conv(x, kernel):
"""标准卷积:(H, W, C_in) * (k, k, C_in, C_out) -> (H, W, C_out)。"""
H, W, C_in = x.shape
k, _, _, C_out = kernel.shape
pad = k // 2
x_pad = jnp.pad(x, ((pad, pad), (pad, pad), (0, 0)), mode='constant')
out = jnp.zeros((H, W, C_out))
for i in range(H):
for j in range(W):
patch = x_pad[i:i+k, j:j+k, :] # (k, k, C_in)
for c in range(C_out):
out = out.at[i, j, c].set(jnp.sum(patch * kernel[:, :, :, c]))
return out
def depthwise_separable_conv(x, dw_kernel, pw_kernel):
"""深度可分离:深度卷积 (k,k,C_in) 然后逐点卷积 (C_in, C_out)。"""
H, W, C_in = x.shape
k = dw_kernel.shape[0]
pad = k // 2
x_pad = jnp.pad(x, ((pad, pad), (pad, pad), (0, 0)), mode='constant')
# Depthwise: one filter per channel
dw_out = jnp.zeros((H, W, C_in))
for i in range(H):
for j in range(W):
for c in range(C_in):
patch = x_pad[i:i+k, j:j+k, c]
dw_out = dw_out.at[i, j, c].set(jnp.sum(patch * dw_kernel[:, :, c]))
# Pointwise: 1x1 conv across channels
out = dw_out @ pw_kernel
return out
# Setup
H, W, C_in, C_out, k = 8, 8, 16, 32, 3
key = jax.random.PRNGKey(42)
k1, k2, k3, k4 = jax.random.split(key, 4)
x = jax.random.normal(k1, (H, W, C_in))
std_kernel = jax.random.normal(k2, (k, k, C_in, C_out)) * 0.1
dw_kernel = jax.random.normal(k3, (k, k, C_in)) * 0.1
pw_kernel = jax.random.normal(k4, (C_in, C_out)) * 0.1
# Compare
std_params = k * k * C_in * C_out
dw_params = k * k * C_in + C_in * C_out
std_flops = H * W * k * k * C_in * C_out
dw_flops = H * W * (k * k * C_in + C_in * C_out)
print(f"Standard conv: {std_params:>8,} params, {std_flops:>10,} FLOPs")
print(f"Depthwise separable conv: {dw_params:>8,} params, {dw_flops:>10,} FLOPs")
print(f"Parameter reduction: {std_params / dw_params:.1f}x")
print(f"FLOP reduction: {std_flops / dw_flops:.1f}x")
std_out = standard_conv(x, std_kernel)
ds_out = depthwise_separable_conv(x, dw_kernel, pw_kernel)
print(f"\nStandard output shape: {std_out.shape}")
print(f"Depthwise sep output shape: {ds_out.shape}")
```
@@ -0,0 +1,376 @@
# 目标检测与分割
*目标检测定位并分类图像中的每个物体;分割为每个像素分配一个标签。本文件涵盖交并比(IoU)、平均精度均值(mAP)、锚框、R-CNN系列、YOLO、SSD、特征金字塔网络(FPN)、语义/实例/全景分割(U-Net、Mask R-CNN、SAM)以及用于基准测试的评估指标。*
- 图像分类(文件02)回答了"这张图像里有什么?"目标检测提出了一个更难的问题:"这张图像里有哪些物体,它们在哪里?"
- 分割则更进一步:"哪些像素属于哪个物体或类别?"这些任务形成了一个空间理解精度逐步提高的层次结构。
- **目标检测**模型输出一组**边界框**,每个边界框由四个坐标(左上角 $x, y$、宽度、高度)以及一个带有置信度分数的类别标签定义。一张图像可能包含零个、一个或数百个来自多个类别的物体。
![输入图像中包含多个物体,每个物体由一个彩色边界框和带有置信度分数的类别标签包围](../images/detection_boxes.svg)
- **交并比(IoU)**衡量预测边界框与真实标注的匹配程度。它是重叠面积除以并集面积:
$$\text{IoU} = \frac{\text{交集面积}}{\text{并集面积}}$$
- IoU为1表示完全重叠,IoU为0表示完全不重叠。"正确"检测的标准阈值为IoU $\geq 0.5$,但也使用更严格的阈值(0.75、0.9)。
- 如果预测框与真实框的IoU超过阈值且类别正确,则检测结果为**真正例(TP)**。
- **假正例(FP)**是未匹配到任何真实标注的预测框。
- **假负例(FN)**是没有任何预测框匹配到的真实物体。这些与第06章中的精确率和召回率概念相同。
- **平均精度(AP)**总结单个类别的检测质量。对于每个类别,按置信度分数对所有检测结果排序,计算每个排序位置的精确率和召回率,然后计算精确率-召回率曲线下的面积:
$$\text{AP} = \int_0^1 p(r) \, dr$$
- 在实践中,曲线是插值处理的:在每个召回率水平上,精确率被设置为所有召回率 $\geq r$ 处的最大精确率。这使曲线平滑并使其单调递减。
- **平均精度均值(mAP)**对所有类别的AP进行平均。"mAP@0.5"使用IoU阈值0.5。"mAP@[.5:.95]"COCO标准)在从0.5到0.95的十个IoU阈值上(步长0.05)对mAP进行平均,同时奖励检测能力和精确的定位能力。
- **非极大值抑制(NMS)**移除重复的检测结果。当模型为同一个物体预测出多个重叠的边界框时,NMS保留置信度最高的框,并移除所有与其重叠超过IoU阈值的其他框。这是在模型生成原始预测之后,按每个类别分别进行的。
- **两阶段检测器**首先提出候选区域,然后对每个提案进行分类和精细化调整。
- **R-CNN**Girshick 等人,2014年)是第一个成功的深度学习检测器。它使用选择性搜索(一种经典算法)提出约2,000个候选区域,将每个区域变形为固定尺寸,独立通过CNN运行,并使用SVM(第06章)进行分类。R-CNN准确但极其缓慢:每张图像需要运行CNN 2,000次。
- **Fast R-CNN**Girshick,2015年)解决了冗余问题:它在整张图像上运行一次CNN以生成共享特征图,然后使用**RoI池化**(感兴趣区域池化)从该共享特征图中为每个提案提取特征。
- RoI池化从特征图中取出一个可变大小的区域,通过将该区域划分为一个网格并在每个单元格内进行最大池化,生成固定大小的输出。这种方法快得多,因为昂贵的CNN计算只进行一次。
- **Faster R-CNN**(Ren 等人,2015年)引入了**区域提议网络(RPN)**,从而消除了外部区域提议算法。RPN是一个小型CNN,运行在共享特征图之上,直接预测提案。RPN在特征图上滑动一个小窗口,在每个位置上预测 $k$ 个提案(每个**锚框**对应一个提案)。
![Faster R-CNN流程:输入图像 → 骨干CNN → 共享特征图 → RPN生成提案 → RoI池化 → 分类和边界框回归头](../images/faster_rcnn.svg)
- **锚框**是特征图上每个空间位置处预定义的边界框,覆盖不同的尺度和长宽比(例如,三个尺度 $\times$ 三个比例 = 每个位置9个锚框)。RPN为每个锚框预测两样东西:物体性分数(物体vs背景)以及用于将锚框精炼为更紧凑提案的坐标偏移量。这种参数化使回归问题更容易:网络不需要预测绝对坐标,只需预测对合理初始框的小幅调整。
- 锚框偏移量的参数化公式为:
$$t_x = \frac{x - x_a}{w_a}, \quad t_y = \frac{y - y_a}{h_a}, \quad t_w = \log\frac{w}{w_a}, \quad t_h = \log\frac{h}{h_a}$$
- 其中 $(x, y, w, h)$ 是预测框的中心和尺寸,$(x_a, y_a, w_a, h_a)$ 是锚框。宽度和高度的对数变换确保预测框始终为正数,并使回归具有尺度不变性。
- Faster R-CNN使用多任务损失进行训练:类别标签的分类损失(第05章的交叉熵),以及用于边界框回归的**平滑L1损失**。平滑L1对异常值不如L2敏感:
```math
\text{smooth}_{L1}(x) = \begin{cases} 0.5x^2 & \text{if } |x| < 1 \\ |x| - 0.5 & \text{otherwise} \end{cases}
```
- **特征金字塔网络(FPN**(Lin 等人,2017年)通过构建一个带有侧边连接的自顶向下路径来解决多尺度问题,该路径将高层语义信息与低层空间细节融合。骨干网络生成多个尺度的特征图(每个池化层将分辨率减半)。FPN添加了一个自顶向下的路径,其中每个层级接收来自上一层级的上采样特征,并通过侧边1x1卷积与对应的自底向上层级合并。结果是一个特征图金字塔,每个层级的特征图既具有强语义信息又具有良好的空间分辨率。
- 小物体从金字塔的高分辨率层级检测;大物体从低分辨率层级检测。FPN现在已成为大多数现代检测架构的标准组件。
- **单阶段检测器**完全跳过了提案步骤,在一次前向传播中直接预测类别标签和边界框。这种方法更快,但在历史上准确率低于两阶段检测器,直到焦点损失(focal loss)缩小了这一差距。
- **YOLO**You Only Look OnceRedmon 等人,2016年)将图像划分为一个 $S \times S$ 的网格。每个网格单元预测 $B$ 个边界框和 $C$ 个类别概率。如果一个物体的中心落在一个网格单元内,该单元负责检测该物体。YOLO极其快速,因为整个检测过程只有一次前向传播,没有提案阶段。
- **YOLOv2**添加了锚框、批归一化和多尺度训练。**YOLOv3**使用了特征金字塔网络并在三个尺度上进行预测。**YOLOv4-v8**继续改进,采用了更好的骨干网络、路径聚合网络和马赛克数据增强(在训练中将四张图像拼接在一起以增加上下文多样性)。
- **SSD**Single Shot MultiBox DetectorLiu 等人,2016年)在骨干网络内的多个特征图尺度上进行预测,在每个尺度上使用锚框。早期(高分辨率)特征图检测小物体;后期(低分辨率)特征图检测大物体。SSD比Faster R-CNN更快,且具有竞争力的准确率。
- **RetinaNet**(Lin 等人,2017年)指出了单阶段检测器的核心问题:类别不平衡。绝大多数锚框对应的是背景,这产生了大量容易的负样本,它们主导了损失函数并压倒了来自稀有正样本的梯度。
- **焦点损失(Focal Loss)**通过降低容易样本的权重来解决这个问题:
$$\text{FL}(p_t) = -\alpha_t (1 - p_t)^\gamma \log(p_t)$$
- 其中 $p_t$ 是正确类别的预测概率。当模型自信且正确时($p_t$ 很高),$(1 - p_t)^\gamma$ 很小,从而减少了容易负样本对损失的贡献。超参数 $\gamma$ (通常为2)控制降权的强度。当 $\gamma = 0$ 时,焦点损失退化为标准交叉熵。凭借焦点损失,RetinaNet以单阶段的速度实现了与两阶段检测器相当的准确率。
- **无锚框检测**完全消除了锚框,减少了超参数调优并简化了流程。
- **FCOS**(全卷积单阶段检测器,Tian 等人,2019年)在特征图的每个空间位置预测从该位置到最近边界框四条边(左、上、右、下)的距离以及一个类别标签。**中心性(centerness)**分数降低了远离物体中心的预测的权重,从而提高了质量。FCOS使用FPN来处理多尺度问题。
- **CenterNet**(Zhou 等人,2019年)将物体检测为点:它预测一个热力图,其中的峰值对应物体中心,然后在每个峰值处回归宽度和高度。检测变成了关键点估计。这种方法优雅且无需锚框,但需要仔细的热力图后处理。
- **CornerNet**将物体检测为一对角点(左上角和右下角)。它预测两个热力图(每个角类型一个),并使用**关联嵌入(associative embedding**将对应的角点匹配成边界框。这避免了对锚框的需求,并处理了任意形状的物体。
- **语义分割**为图像中的每个像素分配一个类别标签。与检测(输出边界框)不同,分割生成密集的像素级映射。一条街景可能会将每个像素标记为道路、人行道、汽车、行人、建筑、天空等。
![语义分割:输入街景及其像素级标签图,每种颜色代表一个类别](../images/semantic_segmentation.svg)
- **全卷积网络(FCN**(Long 等人,2015年)通过将全连接层替换为卷积层,使分类CNN适用于分割任务,从而使网络能够输出空间映射而非单个类别。上采样(通过转置卷积或双线性插值)将输出恢复到输入分辨率。来自早期层的跳跃连接添加了在下采样过程中丢失的空间细节。
- **转置卷积**(有时称为"反卷积")是卷积的上采样对应操作。步幅卷积减少空间维度,而转置卷积增加空间维度。它在输入元素之间插入零,然后应用标准卷积,从而有效地学习如何上采样。
- **U-Net**Ronneberger 等人,2015年)引入了一种对称的编码器-解码器架构,在每一层都有跳跃连接。编码器(收缩路径)在增加通道数的同时降低空间分辨率,与分类CNN完全相同。解码器(扩展路径)将结果上采样回全分辨率。跳跃连接在每一层将编码器特征图与解码器特征图拼接起来,为解码器提供精细的空间细节。这种高层语义与低层细节的结合产生了清晰、准确的分割边界。
![U-Net架构:左侧为带下采样的编码器路径,右侧为带下采样的解码器路径,以及连接对应层级的跳跃连接](../images/unet_architecture.svg)
- U-Net最初是为生物医学图像分割设计的(其中训练数据稀缺),其架构已成为许多后续模型的基础,包括潜在扩散模型中的U-Net(文件04)。
- **DeepLab**Chen 等人,2014-2018年)为分割引入了两个关键创新:
- **空洞(扩张)卷积**:在滤波器元素之间插入间隙的标准卷积,由扩张率 $r$ 控制。一个扩张率为 $r$ 的3x3滤波器的感受野为 $(2r + 1) \times (2r + 1)$,而仅使用9个参数。这在不进行下采样的情况下捕获多尺度上下文,同时保持空间分辨率。
- **空洞空间金字塔池化(ASPP**:并行应用多个具有不同扩张率的空洞卷积(例如,扩张率1、6、12、18),拼接结果,并通过1x1卷积融合。ASPP同时捕获多个尺度的上下文,其精神类似于Inception模块(文件02),但使用扩张而非不同大小的卷积核。
- DeepLab还使用**条件随机场(CRF)**(第05章)作为后处理步骤,通过鼓励空间上相邻且颜色相似的像素共享相同的标签来优化分割边界。
- **实例分割**结合了检测和分割:它识别每个单独的物体实例,并为每个实例生成像素级掩码。场景中的两辆车会得到两个独立的掩码,而不仅仅是"车"。
- **Mask R-CNN**(He 等人,2017年)通过添加一个小型分割头来扩展Faster R-CNN,该分割头为每个检测到的物体预测一个二值掩码。其架构为Faster R-CNN加上一个掩码分支:掩码分支接收RoI池化后的特征,并为每个类别输出一个 $m \times m$ 的二值掩码。它使用**RoIAlign**代替RoI池化:在精确定位的采样点处进行双线性插值,而非在量化的网格单元格内进行,这避免了量化引起的空间错位。这一小改动显著提高了掩码质量。
- Mask R-CNN使用多任务损失进行训练:分类损失 + 边界框回归损失 + 掩码损失(逐像素二值交叉熵)。掩码分支独立地为每个类别预测一个掩码;仅使用与预测类别对应的掩码,这使掩码预测与分类解耦,并同时改进了两者。
- **全景分割**将语义分割和实例分割统一为单个任务。每个像素同时获得一个类别标签(语义)和一个实例ID(用于"物体"类别,如汽车和人)。"背景"类别(天空、道路、草地)只获得语义标签,因为它们是无形区域,没有可计数的实例。
- 全景质量(PQ)指标通过分解为分割质量(匹配片段的平均IoU)和识别质量(匹配片段的F1分数)来评估:
$$\text{PQ} = \underbrace{\frac{\sum_{(p,g) \in \text{TP}} \text{IoU}(p,g)}{|\text{TP}|}}_{\text{SQ}} \times \underbrace{\frac{|\text{TP}|}{|\text{TP}| + \frac{1}{2}|\text{FP}| + \frac{1}{2}|\text{FN}|}}_{\text{RQ}}$$
- **实时分割**对于自动驾驶和增强现实等应用至关重要,这些应用对延迟预算要求严格(通常每帧不超过30毫秒)。
- **BiSeNet**(双边分割网络,Yu 等人,2018年)使用两条并行路径:一条**空间路径**,具有宽而浅的层以保留空间细节;一条**上下文路径**,具有深而窄的层以捕获语义信息。输出被融合,兼顾速度和准确率。
- **DDRNet**(深度双分辨率网络,Hong 等人,2021年)在整个网络中以不同分辨率维持两个分支,并在它们之间反复交换信息。高分辨率分支保留空间细节,而低分辨率分支捕获全局上下文。多个双边融合模块在两个方向上合并信息。
- 实时分割的总体趋势是避免沉重的编码器-解码器模式,而是通过网络全程维持足够的空间分辨率,以一定的准确率为代价换取显著更低的延迟。
## 编程练习(使用CoLab或notebook
1. 从头实现IoU计算和非极大值抑制。对一组重叠的边界框应用NMS并可视化结果。
```python
import jax.numpy as jnp
import matplotlib.pyplot as plt
import matplotlib.patches as patches
def compute_iou(box1, box2):
"""计算两个框[x1, y1, x2, y2]之间的IoU。"""
x1 = jnp.maximum(box1[0], box2[0])
y1 = jnp.maximum(box1[1], box2[1])
x2 = jnp.minimum(box1[2], box2[2])
y2 = jnp.minimum(box1[3], box2[3])
intersection = jnp.maximum(0, x2 - x1) * jnp.maximum(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 + 1e-6)
def nms(boxes, scores, iou_threshold=0.5):
"""非极大值抑制。"""
order = jnp.argsort(-scores) # 按置信度降序排列
keep = []
remaining = list(range(len(scores)))
order_list = order.tolist()
while order_list:
idx = order_list[0]
keep.append(idx)
order_list = order_list[1:]
new_order = []
for j in order_list:
iou = compute_iou(boxes[idx], boxes[j])
if iou < iou_threshold:
new_order.append(j)
order_list = new_order
return keep
# 示例:同一物体的重叠检测
boxes = jnp.array([
[50, 60, 150, 160], # 高置信度
[55, 65, 155, 165], # 重叠的重复框
[52, 58, 148, 158], # 重叠的重复框
[200, 100, 300, 200], # 不同物体
[205, 105, 305, 205], # 重叠的重复框
])
scores = jnp.array([0.95, 0.80, 0.70, 0.90, 0.60])
keep = nms(boxes, scores, iou_threshold=0.5)
fig, axes = plt.subplots(1, 2, figsize=(14, 5))
colors = ['#3498db', '#e74c3c', '#27ae60', '#9b59b6', '#f39c12']
for ax, title, indices in zip(axes, ['NMS之前', 'NMS之后'],
[range(len(boxes)), keep]):
ax.set_xlim(0, 400); ax.set_ylim(0, 300)
ax.set_aspect('equal'); ax.invert_yaxis()
ax.set_title(title)
for i in indices:
b = boxes[i]
rect = patches.Rectangle((b[0], b[1]), b[2]-b[0], b[3]-b[1],
linewidth=2, edgecolor=colors[i],
facecolor='none')
ax.add_patch(rect)
ax.text(b[0], b[1]-5, f'{scores[i]:.2f}', color=colors[i], fontsize=10)
plt.tight_layout(); plt.show()
print(f"NMS后保留了{len(keep)}个框,共{len(boxes)}个")
```
2. 实现一个简化的区域提议网络(RPN)。给定一个特征图,生成具有多种尺度和长宽比的锚框,并预测物体性分数和边界框偏移量。
```python
import jax
import jax.numpy as jnp
import matplotlib.pyplot as plt
import matplotlib.patches as patches
def generate_anchors(feature_h, feature_w, stride, scales, ratios):
"""为特征图上的每个位置生成锚框。"""
anchors = []
for y in range(feature_h):
for x in range(feature_w):
cx = (x + 0.5) * stride
cy = (y + 0.5) * stride
for s in scales:
for r in ratios:
w = s * jnp.sqrt(r)
h = s / jnp.sqrt(r)
anchors.append([cx - w/2, cy - h/2, cx + w/2, cy + h/2])
return jnp.array(anchors)
def rpn_forward(feature_map, params):
"""简化版RPN:预测每个锚框的物体性和框偏移量。"""
H, W, C = feature_map.shape
n_anchors = params['cls_w'].shape[1]
# 在特征图上滑动1x1卷积(简化版)
cls_scores = feature_map.reshape(-1, C) @ params['cls_w'] # (H*W, n_anchors)
box_offsets = feature_map.reshape(-1, C) @ params['reg_w'] # (H*W, n_anchors*4)
cls_scores = jax.nn.sigmoid(cls_scores)
return cls_scores.ravel(), box_offsets.reshape(-1, 4)
# 设置
feature_h, feature_w, channels = 4, 4, 16
stride = 16 # 每个特征图单元格覆盖16x16像素
scales = [32, 64, 128]
ratios = [0.5, 1.0, 2.0]
n_anchors_per_pos = len(scales) * len(ratios)
key = jax.random.PRNGKey(42)
k1, k2, k3 = jax.random.split(key, 3)
feature_map = jax.random.normal(k1, (feature_h, feature_w, channels))
params = {
'cls_w': jax.random.normal(k2, (channels, n_anchors_per_pos)) * 0.01,
'reg_w': jax.random.normal(k3, (channels, n_anchors_per_pos * 4)) * 0.01,
}
anchors = generate_anchors(feature_h, feature_w, stride, scales, ratios)
scores, offsets = rpn_forward(feature_map, params)
print(f"特征图:{feature_h}x{feature_w},步幅={stride}")
print(f"每个位置的锚框数:{n_anchors_per_pos}")
print(f"锚框总数:{len(anchors)}")
print(f"物体性分数形状:{scores.shape}")
print(f"边界框偏移量形状:{offsets.shape}")
# 可视化一个位置的锚框
fig, ax = plt.subplots(figsize=(6, 6))
img_size = feature_h * stride
ax.set_xlim(0, img_size); ax.set_ylim(0, img_size)
ax.invert_yaxis(); ax.set_aspect('equal')
pos_idx = feature_h // 2 * feature_w + feature_w // 2 # 中心位置
colors = ['#3498db', '#e74c3c', '#27ae60']
for i, s in enumerate(scales):
for j, r in enumerate(ratios):
idx = pos_idx * n_anchors_per_pos + i * len(ratios) + j
a = anchors[idx]
rect = patches.Rectangle((a[0], a[1]), a[2]-a[0], a[3]-a[1],
linewidth=1.5, edgecolor=colors[i],
facecolor='none', linestyle=['--', '-', ':'][j])
ax.add_patch(rect)
ax.scatter([img_size/2], [img_size/2], c='red', s=50, zorder=5)
ax.set_title(f'中心位置的锚框\n3个尺度 × 3个比例 = {n_anchors_per_pos}')
ax.grid(True, alpha=0.3)
plt.tight_layout(); plt.show()
```
3. 实现一个简化版的一维U-Net编码器-解码器,带有跳跃连接,用于一维分割(一维信号的二值标注)。
```python
import jax
import jax.numpy as jnp
import matplotlib.pyplot as plt
def conv1d_same(x, kernel):
"""具有相同填充的一维卷积。"""
k = len(kernel)
pad = k // 2
x_pad = jnp.pad(x, pad, mode='edge')
n = len(x)
out = jnp.zeros(n)
for i in range(n):
out = out.at[i].set(jnp.sum(x_pad[i:i+k] * kernel))
return out
def downsample(x):
return x[::2]
def upsample(x, target_len):
return jnp.interp(jnp.linspace(0, 1, target_len), jnp.linspace(0, 1, len(x)), x)
def unet_1d(x, params):
"""简化版一维U-Net,包含2个编码器/解码器层级。"""
# 编码器
e1 = jnp.maximum(0, conv1d_same(x, params['enc1']))
e1_down = downsample(e1)
e2 = jnp.maximum(0, conv1d_same(e1_down, params['enc2']))
e2_down = downsample(e2)
# 瓶颈层
bottleneck = jnp.maximum(0, conv1d_same(e2_down, params['bottleneck']))
# 带跳跃连接的解码器
d2_up = upsample(bottleneck, len(e2))
d2 = jnp.maximum(0, conv1d_same(d2_up + e2, params['dec2'])) # 跳跃连接
d1_up = upsample(d2, len(e1))
d1 = conv1d_same(d1_up + e1, params['dec1']) # 跳跃连接
return jax.nn.sigmoid(d1)
# 创建带有标注区域的信号
n = 128
t = jnp.linspace(0, 4 * jnp.pi, n)
signal = jnp.sin(t) + 0.5 * jnp.sin(3 * t)
labels = (signal > 0.5).astype(jnp.float32) # 二值分割目标
key = jax.random.PRNGKey(42)
keys = jax.random.split(key, 5)
params = {
'enc1': jax.random.normal(keys[0], (5,)) * 0.3,
'enc2': jax.random.normal(keys[1], (5,)) * 0.3,
'bottleneck': jax.random.normal(keys[2], (3,)) * 0.3,
'dec2': jax.random.normal(keys[3], (5,)) * 0.3,
'dec1': jax.random.normal(keys[4], (5,)) * 0.3,
}
def loss_fn(params, signal, labels):
pred = unet_1d(signal, params)
return -jnp.mean(labels * jnp.log(pred + 1e-7) + (1 - labels) * jnp.log(1 - pred + 1e-7))
grad_fn = jax.jit(jax.grad(loss_fn))
lr = 0.05
for step in range(500):
grads = grad_fn(params, signal, labels)
params = {k: params[k] - lr * grads[k] for k in params}
pred = unet_1d(signal, params)
fig, axes = plt.subplots(3, 1, figsize=(12, 7), sharex=True)
axes[0].plot(t, signal, color='#3498db', linewidth=1.5)
axes[0].set_title('输入信号'); axes[0].set_ylabel('值')
axes[1].fill_between(t, 0, labels, alpha=0.3, color='#27ae60')
axes[1].set_title('真实标注'); axes[1].set_ylabel('标签')
axes[2].plot(t, pred, color='#e74c3c', linewidth=1.5)
axes[2].fill_between(t, 0, (pred > 0.5).astype(float), alpha=0.2, color='#e74c3c')
axes[2].set_title('U-Net预测'); axes[2].set_ylabel('概率')
axes[2].set_xlabel('t')
plt.tight_layout(); plt.show()
print(f"最终损失:{loss_fn(params, signal, labels):.4f}")
print(f"像素准确率:{jnp.mean((pred > 0.5) == labels):.2%}")
```
@@ -0,0 +1,360 @@
# 视觉Transformer与生成模型
*视觉Transformer将自注意力应用于图像块,通过数据驱动的空间学习挑战了CNN的主导地位。本文涵盖ViT、DeiT、Swin Transformer、基于GAN的图像生成(StyleGAN)、VAE和扩散模型(DDPM、Stable Diffusion),以及超分辨率和神经风格迁移。*
- CNN(文件02)内置了很强的空间归纳偏置:局部连接、权重共享和平移等变性。视觉Transformer(ViT)提出了一个启发性的问题:如果我们完全抛弃这些偏置,仅使用第06章中的注意力机制,让模型从数据中学习空间结构,结果会怎样?
- **ViT**Vision TransformerDosovitskiy等人,2021)将标准的Transformer编码器直接应用于图像。其核心思想是将图像视为一个图像块序列,就像NLP将文本视为一个词元序列一样。
- 其处理流程如下:
1. 将图像(高度$H$,宽度$W$,通道数$C$)分割成$P \times P$大小的不重叠图像块网格。得到$N = HW / P^2$个图像块。
2. 将每个图像块展平成长度为$P^2 \cdot C$的向量,并通过一个可学习的线性嵌入(单个矩阵乘法,第02章)将其投影到模型维度$D$。
3. 在前面添加一个可学习的**[CLS]标记**嵌入(类似于BERT的[CLS],第07章)。该标记会关注所有图像块,其最终表示用于分类。
4. 添加**位置嵌入**(每个位置一个可学习向量)以提供空间信息,因为注意力是置换等变的。
5. 将$(N + 1)$个标记嵌入序列通过标准的Transformer编码器(多头自注意力 + FFN,第06章)。
6. [CLS]标记的最终表示通过一个分类头(小型MLP)进行分类。
![ViT流程:将图像分割为16x16图像块,每个块展平并线性投影,添加[CLS]标记,加上位置嵌入,然后由Transformer编码器块处理](../images/vit_pipeline.svg)
- **图像块嵌入**等价于一个卷积核大小为$P$、步长为$P$(不重叠)的卷积操作。ViT将2D图像字面地转换为1D序列,然后用与处理语言相同的架构来处理它。
- ViT的归纳偏置比CNN少:它不强制局部连接或平移等变性。这意味着它需要更多的训练数据才能从头学习空间结构。在小型数据集上,CNN优于ViT。但在非常大的数据集(JFT-300M,3亿张图像)上训练时,ViT达到或超过了最佳CNN的性能,这表明CNN的归纳偏置有助于数据效率,但对于最终性能并非必需。
- ViT自注意力的复杂度为$O(N^2)$,其中N是图像块数量。对于224x224的图像和16x16的图像块,$N = 196$,这在可控范围内。但对于更高分辨率的图像或更小的图像块,二次成本变得难以承受。
- **DeiT**(数据高效的图像TransformerTouvron等人,2021)表明,仅使用ImageNet(无需庞大的JFT数据集)并借助强数据增强、正则化(随机深度、标签平滑、dropout)和**知识蒸馏**,就可以有效训练ViT:一个预训练的CNN教师提供软标签,ViT学生学习匹配这些标签。DeiT在[CLS]标记旁边添加了一个**蒸馏标记**,训练用于预测教师的输出。
- **Swin Transformer**(Liu等人,2021)解决了ViT的两个主要局限:随图像大小呈二次增长的计算成本,以及缺少层次化特征图(检测和分割需要层次化特征)。
- Swin引入了**移动窗口**:不再对所有图像块进行全局自注意力,而是在局部窗口内(例如7x7个图像块)计算注意力。这使得计算成本与图像大小呈线性关系:$O(N)$而非$O(N^2)$。但仅靠局部窗口会阻止区域之间的信息流动。
- **窗口移动**解决了这个问题:在交替层中,窗口划分会偏移半个窗口大小。这创建了跨窗口连接,使得信息可以在所有图像部分之间流动,而无需全局注意力的成本。
![Swin Transformer:第l层在常规窗口内计算注意力,第l+1层将窗口划分偏移一半,创建跨窗口连接](../images/swin_shifted_windows.svg)
- Swin还通过跨阶段合并图像块来构建**层次化表示**。每个阶段之后,相邻的2x2图像块被拼接并投影,使通道维度加倍、空间分辨率减半。这产生了多尺度特征图,类似于CNN和FPN(文件03)中的特征图,使得Swin可以直接兼容Faster R-CNN等检测头和U-Net等分割头。
- **PVT**(金字塔视觉Transformer)采用了类似的层次化方法,具有空间缩减注意力:在每个阶段,键和值在计算注意力之前先进行空间下采样,从而在保持全局感受野的同时降低二次成本。
- **自监督视觉学习**从未标注的图像中训练表示。标注成本高,但图像资源丰富。目标是在没有任何人工标注的情况下,学习能很好地迁移到下游任务的特征。
- **对比学习**训练模型识别:同一张图像的两个增广视图("正样本对")应具有相似的表示,而不同图像的视图("负样本对")应具有不相似的表示。
- **SimCLR**(Chen等人,2020)对一个批次中的每张图像创建两个增广视图,用共享主干网络+投影头对两者进行编码,并应用**NT-Xent损失**(归一化温度标度交叉熵):
$$\ell_{i,j} = -\log \frac{\exp(\text{sim}(z_i, z_j) / \tau)}{\sum_{k \neq i} \exp(\text{sim}(z_i, z_k) / \tau)}$$
- 其中$\text{sim}$是余弦相似度(第01章),$\tau$是温度参数。分子将正样本对拉近;分母将负样本对推远。SimCLR需要大批量大小(4,096+)来提供足够的负样本。
- **MoCo**(动量对比,He等人,2020)通过维护一个**动量更新的负嵌入队列**来解决大批量需求。查询编码器通过梯度下降更新;键编码器作为查询编码器的指数移动平均(EMA,第04章)进行更新:$\theta_k \leftarrow m \theta_k + (1 - m) \theta_q$,其中$m = 0.999$。队列存储最近的键嵌入,提供了大量且一致的负样本集,无需巨大的批次。
- **BYOL**(自举你自己的隐空间,Grill等人,2020)完全消除了负样本对。它使用两个网络:"在线"网络和"目标"网络(在线的EMA)。在线网络预测目标网络对另一增广视图的表示。无需负样本,BYOL通过预测头的不对称性和EMA目标避免了坍塌问题(模型对所有输入输出相同向量)。
- **DINO**(无标签自蒸馏,Caron等人,2021)将自蒸馏应用于ViT。学生网络预测教师网络(学生的EMA)在不同增广视图下的输出。教师使用更大的裁剪区域;学生使用更小的裁剪区域。DINO产生的特征包含关于场景布局的显式信息:DINO训练的ViT的自注意力图自然地对物体进行分割,无需任何分割监督。
- **掩码图像建模**是BERT掩码语言建模(第07章)在视觉领域的类比。输入图像块的一大部分被掩码,模型学习重建它们。
- **MAE**(掩码自编码器,He等人,2022)掩码了75%的图像块,并训练一个ViT编码器-解码器来重建缺失的像素值。只有未掩码的图像块由编码器处理(在预训练期间节省4倍计算量),轻量级解码器从编码后的可见图像块加上可学习的掩码标记重建完整图像。
- **BEiT**(图像Transformer的BERT预训练,Bao等人,2022)掩码图像块并预测离散的视觉标记(从预训练的dVAE分词器获得),而不是原始像素。这类似于BERT预测离散词标记,避免了像素重建的低层细节。
- **图像生成**旨在生成训练集中不存在的新颖、逼真的图像。核心挑战是对自然图像的高维概率分布进行建模。
- **生成对抗网络(GAN**Goodfellow等人,2014)使用两个相互竞争的网络:一个**生成器**$G$从随机噪声中创建假图像,和一个**判别器**$D$试图区分真实图像和假图像。它们通过对抗性训练:$G$试图欺骗$D$,而$D$试图抓住$G$。
$$\min_G \max_D \; \mathbb{E}_{x \sim p_{\text{data}}}[\log D(x)] + \mathbb{E}_{z \sim p(z)}[\log(1 - D(G(z)))]$$
- 生成器接收随机隐向量$z$(从高斯分布等简单分布中采样),通过一系列转置卷积将其映射生成图像。判别器是一个标准的CNN分类器。在均衡状态下,$G$生成的图像与真实数据无法区分,$D$对所有输入输出0.5。
- **模式坍塌**是GAN的主要失败模式:生成器学会只生成少数几种能欺骗判别器的图像,忽略了训练数据的多样性。生成器找到一小部分"安全"输出,而不是覆盖完整的数据分布。
- 稳定GAN的训练技巧包括:谱归一化(约束判别器的Lipschitz常数)、渐进式增长(先在低分辨率训练,然后逐步提高)、特征匹配(匹配中间判别器特征的统计量而非最终输出),以及使用Wasserstein距离替代原始的JS散度目标。
- **StyleGAN**(Karras等人,2019)是最具影响力的高质量图像合成GAN架构。其关键创新是**基于风格的生成器**:不是将隐向量$z$直接输入生成器,而是先通过一个**映射网络**(8层MLP)生成风格向量$w$。该风格向量通过**自适应实例归一化(AdaIN)**注入到生成器的每一层,调节特征图的统计量:
$$\text{AdaIN}(x, y) = y_{s} \cdot \frac{x - \mu(x)}{\sigma(x)} + y_{b}$$
- 其中$y_s$和$y_b$是从$w$推导出的缩放和偏置。不同层控制不同方面:早期层控制粗粒度特征(姿态、脸型),中间层控制中粒度特征(发型、眼睛),后期层控制细粒度细节(雀斑、发质纹理)。StyleGAN能以1024x1024分辨率生成照片级逼真的人脸。
- **变分自编码器(VAE**(第06章)提供了另一种生成方法。与GAN不同,VAE有一个原则性的概率框架,具有清晰的训练目标(ELBO)。它们生成的图像通常比GAN模糊,但提供了更平滑、更结构化的隐空间。VAE是隐扩散模型中用于将图像压缩到隐空间和从隐空间重建的编码器-解码器对。
- **扩散模型**已成为图像生成的主导范式,在质量和多样性上都超越了GAN。其思想概念上很简单:逐步向数据添加噪声直到变成纯高斯噪声(**前向过程**),然后学习逐步逆转这一过程(**反向过程**)。
- **前向过程**在$T$个时间步中添加高斯噪声:
$$q(x_t | x_{t-1}) = \mathcal{N}(x_t; \sqrt{1 - \beta_t} \, x_{t-1}, \beta_t I)$$
- 其中$\beta_t$是一个随时间递增的噪声调度。经过足够多的步骤后,无论原始图像$x_0$如何,$x_T$都近似于纯高斯噪声。利用重参数化技巧(第06章),设$\alpha_t = 1 - \beta_t$$\bar{\alpha}_t = \prod_{s=1}^{t} \alpha_s$,我们可以直接从$x_0$采样$x_t$:
$$x_t = \sqrt{\bar{\alpha}_t} \, x_0 + \sqrt{1 - \bar{\alpha}_t} \, \epsilon, \quad \epsilon \sim \mathcal{N}(0, I)$$
- **反向过程**学习去噪:从纯噪声$x_T$开始,模型预测每一步添加的噪声$\epsilon$并将其减去以恢复$x_{t-1}$。这由一个神经网络$\epsilon_\theta$(通常是U-Net,来自文件03)参数化,使用简单的MSE损失训练:
$$\mathcal{L} = \mathbb{E}_{t, x_0, \epsilon}\left[\|\epsilon - \epsilon_\theta(x_t, t)\|^2\right]$$
![扩散前向和反向过程:干净图像在T步中逐渐被噪声破坏(前向),神经网络学习逆转每一步(反向),从纯噪声开始生成干净图像](../images/diffusion_process.svg)
- **DDPM**(去噪扩散概率模型,Ho等人,2020)建立了这个框架。采样需要迭代所有$T$步(通常为1,000步),这很慢。**DDIM**(去噪扩散隐式模型,Song等人,2021)将采样过程重新表述为确定性映射,允许大跨度跳过(例如50步代替1,000步)且质量损失极小。
- **基于分数的模型**Song和Ermon,2019)提供了另一种视角。该模型不是预测噪声$\epsilon$,而是估计**分数函数**$\nabla_{x_t} \log p(x_t)$,即对数概率相对于含噪图像的梯度。该梯度指向数据分布中更高概率(更干净)的区域。采样使用Langevin动力学沿着该梯度进行。基于分数的模型和DDPM在**随机微分方程(SDE)**的框架下被统一:前向过程是添加噪声的SDE,反向过程是时间反转的SDE。
- **无分类器引导**Ho和Salimans,2022)控制样本质量和多样性之间的权衡。模型同时进行条件训练(使用文本提示或类别标签)和无条件训练(条件随机丢弃)。在采样时,预测是加权组合:
$$\hat{\epsilon} = \epsilon_\theta(x_t, \varnothing) + s \cdot (\epsilon_\theta(x_t, c) - \epsilon_\theta(x_t, \varnothing))$$
- 其中$c$是条件,$\varnothing$是空条件,$s > 1$是引导尺度。$s$越高,生成的图像越符合条件,但多样性越低。$s = 1$是无引导模型;$s = 7.5$是常见的默认值。
- **隐扩散**Rombach等人,2022Stable Diffusion)将扩散过程从像素空间转移到学习的隐空间中。一个预训练的VAE编码器将图像压缩为较低维度的隐空间表示(通常空间下采样4倍或8倍),扩散在这个压缩空间中进行,VAE解码器从去噪后的隐变量重建像素。这大大提高了效率:在像素空间扩散512x512图像需要处理$512 \times 512 \times 3$的张量,但在隐空间中仅需处理$64 \times 64 \times 4$的张量。
- 隐扩散中的去噪U-Net接收含噪隐变量、时间步(编码为正弦嵌入,类似于Transformer中的位置编码)和条件信号(来自冻结的CLIP或T5文本编码器的文本嵌入)。文本条件通过U-Net内的交叉注意力层进入:文本嵌入作为键和值,图像特征作为查询。这使得模型在每个空间位置都能关注文本提示的相关部分。
- **流匹配**是扩散模型的一个新兴替代方案,它学习噪声和数据之间的直接传输路径,而不是DDPM的迭代去噪。
- **连续归一化流(CNF)**定义了一个时间相关的速度场$v_\theta(x, t)$,沿着平滑轨迹将样本从简单分布$p_0$(噪声)推送到数据分布$p_1$。该变换遵循一个常微分方程(ODE):
$$\frac{dx}{dt} = v_\theta(x, t), \quad t \in [0, 1]$$
- 从$x_0 \sim \mathcal{N}(0, I)$开始,将ODE向前积分到$t = 1$即可得到数据分布中的样本。速度场由神经网络参数化,训练目标是匹配目标条件流。
- **最优传输(OT)流匹配**(Lipman等人,2023)使用噪声和数据之间的直线路径作为目标流:从噪声样本$x_0$到数据样本$x_1$的条件路径简单地是$x_t = (1 - t) x_0 + t x_1$,目标速度为$v = x_1 - x_0$。训练损失变为:
$$\mathcal{L} = \mathbb{E}_{t, x_0, x_1} \left[\|v_\theta(x_t, t) - (x_1 - x_0)\|^2\right]$$
- **整流流**(Liu等人,2022)通过迭代方式拉直学习到的流路径。在初始训练后,模型通过模拟ODE生成(噪声,数据)对。这些比随机配对更紧密对齐的对用于重新训练模型。重复此过程会产生越来越直的路径,可以通过更少的ODE步骤(甚至单步)来遍历,从而实现极快速的生成。
- 流匹配相比扩散有几个优势:训练目标更简单(直接的速度回归,无需噪声调度),采样ODE更平滑(需要的积分步骤更少),与最优传输的联系提供了理论依据。Stable Diffusion 3和Flux使用流匹配替代了传统的DDPM。
## 编程练习(使用CoLab或notebook
1. 从头实现ViT图像块嵌入。将图像分割成图像块,展平,投影到模型维度,添加位置嵌入,并前置[CLS]标记。
```python
import jax
import jax.numpy as jnp
import matplotlib.pyplot as plt
def create_patch_embedding(image, patch_size, d_model, params):
"""将图像转换为图像块嵌入序列。"""
H, W, C = image.shape
n_patches_h = H // patch_size
n_patches_w = W // patch_size
n_patches = n_patches_h * n_patches_w
# 提取图像块
patches = []
for i in range(n_patches_h):
for j in range(n_patches_w):
patch = image[i*patch_size:(i+1)*patch_size,
j*patch_size:(j+1)*patch_size, :]
patches.append(patch.ravel())
patches = jnp.stack(patches) # (N, P*P*C)
# 线性投影到d_model
embeddings = patches @ params['proj_w'] + params['proj_b'] # (N, d_model)
# 前置CLS标记
cls_token = params['cls_token'] # (1, d_model)
embeddings = jnp.concatenate([cls_token, embeddings], axis=0) # (N+1, d_model)
# 添加位置嵌入
embeddings = embeddings + params['pos_embed'] # (N+1, d_model)
return embeddings, patches
# 设置
H, W, C = 32, 32, 3
patch_size = 8
d_model = 64
n_patches = (H // patch_size) * (W // patch_size) # 16
key = jax.random.PRNGKey(42)
keys = jax.random.split(key, 5)
# 创建具有不同象限的合成图像
image = jnp.zeros((H, W, C))
image = image.at[:16, :16, 0].set(1.0) # 红色 左上
image = image.at[:16, 16:, 1].set(1.0) # 绿色 右上
image = image.at[16:, :16, 2].set(1.0) # 蓝色 左下
image = image.at[16:, 16:, :2].set(1.0) # 黄色 右下
params = {
'proj_w': jax.random.normal(keys[0], (patch_size**2 * C, d_model)) * 0.02,
'proj_b': jnp.zeros(d_model),
'cls_token': jax.random.normal(keys[1], (1, d_model)) * 0.02,
'pos_embed': jax.random.normal(keys[2], (n_patches + 1, d_model)) * 0.02,
}
embeddings, patches = create_patch_embedding(image, patch_size, d_model, params)
print(f"图像形状: {image.shape}")
print(f"图像块大小: {patch_size}x{patch_size}")
print(f"图像块数量: {n_patches}")
print(f"图像块向量长度: {patch_size**2 * C}")
print(f"嵌入形状: {embeddings.shape} (CLS + {n_patches} 个图像块)")
# 可视化图像块
fig, axes = plt.subplots(2, 5, figsize=(14, 6))
axes[0, 0].imshow(image); axes[0, 0].set_title('完整图像'); axes[0, 0].axis('off')
for idx in range(min(9, n_patches)):
ax = axes[(idx+1) // 5, (idx+1) % 5]
patch_img = patches[idx].reshape(patch_size, patch_size, C)
ax.imshow(patch_img); ax.set_title(f'图像块 {idx}'); ax.axis('off')
plt.suptitle('ViT 图像块分解')
plt.tight_layout(); plt.show()
```
2. 实现一个简单的GAN训练循环。在二维数据上训练生成器和判别器,并可视化生成分布逐渐收敛到真实分布。
```python
import jax
import jax.numpy as jnp
import matplotlib.pyplot as plt
def generator(z, params):
h = jnp.tanh(z @ params['g_w1'] + params['g_b1'])
h = jnp.tanh(h @ params['g_w2'] + params['g_b2'])
return h @ params['g_w3'] + params['g_b3']
def discriminator(x, params):
h = jax.nn.leaky_relu(x @ params['d_w1'] + params['d_b1'], 0.2)
h = jax.nn.leaky_relu(h @ params['d_w2'] + params['d_b2'], 0.2)
return jax.nn.sigmoid(h @ params['d_w3'] + params['d_b3'])
def init_params(key):
keys = jax.random.split(key, 6)
z_dim, h_dim, data_dim = 2, 32, 2
scale = 0.1
return {
'g_w1': jax.random.normal(keys[0], (z_dim, h_dim)) * scale,
'g_b1': jnp.zeros(h_dim),
'g_w2': jax.random.normal(keys[1], (h_dim, h_dim)) * scale,
'g_b2': jnp.zeros(h_dim),
'g_w3': jax.random.normal(keys[2], (h_dim, data_dim)) * scale,
'g_b3': jnp.zeros(data_dim),
'd_w1': jax.random.normal(keys[3], (data_dim, h_dim)) * scale,
'd_b1': jnp.zeros(h_dim),
'd_w2': jax.random.normal(keys[4], (h_dim, h_dim)) * scale,
'd_b2': jnp.zeros(h_dim),
'd_w3': jax.random.normal(keys[5], (h_dim, 1)) * scale,
'd_b3': jnp.zeros(1),
}
def d_loss(params, real_data, fake_data):
real_score = discriminator(real_data, params)
fake_score = discriminator(fake_data, params)
return -jnp.mean(jnp.log(real_score + 1e-7) + jnp.log(1 - fake_score + 1e-7))
def g_loss(params, fake_data):
fake_score = discriminator(fake_data, params)
return -jnp.mean(jnp.log(fake_score + 1e-7))
# 真实数据:环形分布
key = jax.random.PRNGKey(42)
theta = jax.random.uniform(key, (512,)) * 2 * jnp.pi
real_data = jnp.stack([jnp.cos(theta), jnp.sin(theta)], axis=1)
real_data = real_data + jax.random.normal(key, real_data.shape) * 0.05
params = init_params(jax.random.PRNGKey(0))
d_grad = jax.grad(d_loss)
g_grad = jax.grad(g_loss)
lr = 0.001
snapshots = []
for step in range(3000):
key, k1 = jax.random.split(key)
z = jax.random.normal(k1, (512, 2))
fake_data = generator(z, params)
# 更新判别器
grads = d_grad(params, real_data, fake_data)
for k in ['d_w1', 'd_b1', 'd_w2', 'd_b2', 'd_w3', 'd_b3']:
params[k] = params[k] - lr * grads[k]
# 更新生成器
fake_data = generator(z, params)
grads = g_grad(params, fake_data)
for k in ['g_w1', 'g_b1', 'g_w2', 'g_b2', 'g_w3', 'g_b3']:
params[k] = params[k] - lr * grads[k]
if step in [0, 500, 1500, 2999]:
snapshots.append((step, fake_data.copy()))
fig, axes = plt.subplots(1, 4, figsize=(16, 4))
for ax, (step, fake) in zip(axes, snapshots):
ax.scatter(real_data[:, 0], real_data[:, 1], s=5, alpha=0.3, c='#3498db', label='真实')
ax.scatter(fake[:, 0], fake[:, 1], s=5, alpha=0.3, c='#e74c3c', label='生成')
ax.set_title(f'步骤 {step}'); ax.set_xlim(-2, 2); ax.set_ylim(-2, 2)
ax.set_aspect('equal'); ax.legend(markerscale=3)
plt.suptitle('GAN训练:生成器学习环形分布')
plt.tight_layout(); plt.show()
```
3. 实现扩散前向过程:在不同时间步向图像添加噪声,并可视化逐步破坏过程。然后实现单步去噪。
```python
import jax
import jax.numpy as jnp
import matplotlib.pyplot as plt
def noise_schedule(T, beta_start=0.0001, beta_end=0.02):
"""线性噪声调度。"""
betas = jnp.linspace(beta_start, beta_end, T)
alphas = 1.0 - betas
alpha_bars = jnp.cumprod(alphas)
return betas, alphas, alpha_bars
def forward_diffusion(x0, t, alpha_bars, key):
"""在时间步t向x0添加噪声。"""
alpha_bar_t = alpha_bars[t]
noise = jax.random.normal(key, x0.shape)
xt = jnp.sqrt(alpha_bar_t) * x0 + jnp.sqrt(1 - alpha_bar_t) * noise
return xt, noise
# 创建简单的2D"图像"(棋盘格)
img = jnp.zeros((32, 32))
for i in range(4):
for j in range(4):
if (i + j) % 2 == 0:
img = img.at[i*8:(i+1)*8, j*8:(j+1)*8].set(1.0)
T = 1000
betas, alphas, alpha_bars = noise_schedule(T)
# 可视化前向过程
timesteps = [0, 50, 200, 500, 999]
key = jax.random.PRNGKey(42)
fig, axes = plt.subplots(1, len(timesteps), figsize=(16, 3.5))
for ax, t in zip(axes, timesteps):
key, subkey = jax.random.split(key)
xt, noise = forward_diffusion(img, t, alpha_bars, subkey)
ax.imshow(xt, cmap='gray', vmin=-2, vmax=2)
ax.set_title(f't={t}\n$\\bar{{\\alpha}}$={alpha_bars[t]:.3f}')
ax.axis('off')
plt.suptitle('扩散前向过程:逐步添加噪声')
plt.tight_layout(); plt.show()
# 简单去噪:训练小型网络在t=200时预测噪声
t_denoise = 200
key, k1 = jax.random.split(key)
xt, true_noise = forward_diffusion(img, t_denoise, alpha_bars, k1)
# 小型"去噪器":仅学习恒定的噪声估计(用于演示)
noise_estimate = jnp.zeros_like(img)
lr = 0.01
for step in range(100):
residual = noise_estimate - true_noise
noise_estimate = noise_estimate - lr * residual
# 反向一步
alpha_bar_t = alpha_bars[t_denoise]
x_denoised = (xt - jnp.sqrt(1 - alpha_bar_t) * noise_estimate) / jnp.sqrt(alpha_bar_t)
fig, axes = plt.subplots(1, 3, figsize=(12, 4))
axes[0].imshow(img, cmap='gray'); axes[0].set_title('原始 $x_0$'); axes[0].axis('off')
axes[1].imshow(xt, cmap='gray', vmin=-2, vmax=2)
axes[1].set_title(f'含噪 $x_{{200}}$'); axes[1].axis('off')
axes[2].imshow(x_denoised, cmap='gray')
axes[2].set_title('去噪后(单步)'); axes[2].axis('off')
plt.tight_layout(); plt.show()
mse = jnp.mean((x_denoised - img)**2)
print(f"去噪MSE: {mse:.4f}")
```
@@ -0,0 +1,347 @@
# 视频与3D视觉
*视频与3D视觉将图像理解扩展到时间域和空间域。本文涵盖光流、视频分类(3D卷积网络、TimeSformer)、目标跟踪(SORT、DeepSORT)、动作识别、深度估计(单目与立体)、点云、神经辐射场(NeRF)和3D高斯泼溅。*
- 文件01-04将图像视为孤立快照。但视觉世界是连续的:物体在运动,场景在变化,深度真实存在。本文将计算机视觉扩展到时间域(视频)和空间域(3D),涵盖模型如何理解运动、跟踪目标、估计深度和重建场景。
- **视频**是一系列随时间捕获的图像(帧)。以30帧/秒计算,一段10秒的片段包含300帧。关键挑战在于建模**时间维度**:物体如何运动,场景如何演变,以及如何跨帧关联信息。
- **光流**估计两帧连续图像之间像素的表观运动。对于帧$t$中的每个像素,光流产生一个二维位移向量$(u, v)$,指向该像素在帧$t+1$中的位置。结果是一个与图像大小相同的稠密运动场。
![两帧连续视频帧及其之间的光流场,以彩色箭头可视化显示像素运动方向和大小](../images/optical_flow.svg)
- 光流在**亮度恒常性假设**下计算:像素的强度在其移动时不变。如果帧$t$中位置$(x, y)$处的像素强度为$I(x, y, t)$,并在小时间间隔$\delta t$内移动了$(u, v)$
$$I(x + u\delta t, \, y + v\delta t, \, t + \delta t) = I(x, y, t)$$
- 进行一阶泰勒展开(见第03章)并除以$\delta t$
$$I_x u + I_y v + I_t = 0$$
- 其中$I_x, I_y$是空间梯度(Sobel算子,见文件01),$I_t$是时间梯度(相邻帧的差值)。这就是**光流约束方程**。一个方程,两个未知数$(u, v)$:我们需要额外的约束条件。
- **Lucas-Kanade**假设光流在一个小窗口内(例如5x5像素)是恒定的。这给出了一个超定系统(25个方程,2个未知数),通过最小二乘法求解(第06章的正规方程):
```math
\begin{bmatrix} u \\ v \end{bmatrix} = \begin{bmatrix} \sum I_x^2 & \sum I_x I_y \\ \sum I_x I_y & \sum I_y^2 \end{bmatrix}^{-1} \begin{bmatrix} -\sum I_x I_t \\ -\sum I_y I_t \end{bmatrix}
```
- 这个2x2矩阵就是文件01中的结构张量(与Harris角点检测中使用的矩阵相同)。Lucas-Kanade适用于小运动,但当物体在帧间移动超过几个像素时会失效。
- **Farneback方法**对每个像素邻域进行多项式展开,并估计最能解释帧间变化的位移场。它产生稠密光流(每个像素一个向量),能处理比Lucas-Kanade更大的运动。
- 现代**深度学习光流**方法(FlowNet、RAFT)学习从帧对端到端预测光流。**RAFT**Recurrent All-Pairs Field TransformsTeed和Deng2020)计算两帧中所有像素对之间的4D相关体,并使用基于GRU的更新算子迭代优化光流估计。RAFT达到了最先进的精度,并已成为标准的光流骨干网络。
- **双流网络**Simonyan和Zisserman2014)是视频理解的早期方法。一个流处理单帧RGB图像(外观),另一个流处理光流帧的堆叠(运动)。两个流在末端融合(通过平均或拼接)。这种架构明确区分了"事物看起来像什么"与"它们如何运动"。
- **3D卷积网络**将2D卷积扩展到时间维度。3D卷积使用大小为$k \times k \times k_t$的滤波器,同时跨越空间和时间维度,直接学习时空特征。
- **C3D**Tran等人,2015)堆叠了3x3x3滤波器的3D卷积,展示了时间卷积可以在没有显式光流的情况下学习运动特征。代价是高昂的:3D卷积的参数和计算量是其2D对应物的$k_t$倍。
- **I3D**Inflated 3DCarreira和Zisserman2017)采用了一种更实用的方法:从预训练的2D CNN(如Inception或ResNet)开始,将所有2D滤波器沿时间维度"膨胀"为3D,重复权重并除以$k_t$。这将ImageNet预训练迁移到视频,同时增加了时间建模能力。一个2D的$k \times k$滤波器变为$k \times k \times k_t$的滤波器,初始化为$W_{\text{3D}}[:,:,j] = W_{\text{2D}} / k_t$,对所有时间位置$j$。
- **SlowFast网络**Feichtenhofer等人,2019)使用两条并行的路径,以不同的时间分辨率运行:
- **Slow路径**以低帧率(例如每16帧)处理帧,具有高空间分辨率和更多通道,捕获精细的空间细节。
- **Fast路径**以高帧率(每2帧)处理帧,空间分辨率降低且通道数较少(通常为Slow路径的$1/8$),捕获快速的时间变化。
- 侧向连接通过步长卷积将信息从Fast融合到Slow。
- 其核心洞见是:空间和时间信息具有不同的带宽需求——物体外观变化缓慢,但运动可以很迅速。SlowFast通过设计匹配这种非对称性。
- **TimeSformer**Bertasius等人,2021)将Vision Transformer应用于视频。它将完整的时空注意力(代价过高:$O((T \times N)^2)$,其中$T$为帧数,$N$为每帧的块数)分解为**分块注意力**:每个块在时间注意(每个块在相同空间位置跨时间进行注意力)和空间注意(每个块在同一帧内跨空间进行注意力)之间交替。这使代价从$O(T^2 N^2)$降低到$O(T^2 + N^2)$。
- **VideoMAE**(Tong等人,2022)将掩码自编码器思想(见文件04)扩展到视频。使用极高的掩码比例(90-95%),因为视频具有高度的时间冗余性:相邻帧看起来几乎相同,因此掩码大部分块后仍然留有足够的信息进行重建。VideoMAE在无标签视频上预训练ViT骨干网络,并迁移到下游任务。
- **动作识别**将视频片段分类为多种动作类别之一(例如"跑步"、"烹饪"、"弹吉他")。它是图像分类的视频对应任务。标准基准数据集包括Kinetics-400(400个动作类别,约30万个片段)、Something-Something174个需要时间推理的细粒度动作)和ActivityNet(200个类别,包含长时未裁剪视频)。
- **时间动作检测**超越了分类:给定一个长段未裁剪的视频,找到每个动作的开始时间、结束时间和类别。这是目标检测的时间对应任务。ActionFormer等方法使用Transformer处理时间特征并预测动作边界。
- **视频目标跟踪**在第一帧识别出特定目标后,跨帧跟踪该目标。
- **SORT**Simple Online and Realtime TrackingBewley等人,2016)将检测模型(独立检测每帧中的目标)与**卡尔曼滤波器**(用于运动预测)和**匈牙利算法**(用于分配)相结合。
- **卡尔曼滤波器**为每个跟踪的目标维护一个状态估计(位置、速度、大小),并使用线性运动模型预测它在下一帧中的位置。当新的检测结果到达时,卡尔曼滤波器通过结合预测值和观测值(按各自的不确定性加权)来更新其估计。这是贝叶斯更新(第05章)在跟踪中的应用。
- **匈牙利算法**解决双线性分配问题:给定$M$个已跟踪目标和$N$个新检测结果,找到使总代价最小化的最优一对一匹配(使用文件03中的IoU距离)。未匹配的检测结果开始新的轨迹;未匹配的轨迹在宽限期后被终止。
- **DeepSORT**通过添加**深度外观特征**扩展了SORT:每个检测到的目标经过一个小型CNN,产生一个外观嵌入(描述子向量)。匹配代价结合了IoU距离和嵌入空间中的余弦距离(第01章)。这处理了遮挡和重识别:即使一个目标在其他目标后消失数帧,其外观嵌入允许在重新出现时重新匹配。
- **ByteTrack**(Zhang等人,2022)通过使用所有检测结果(包括低置信度的)来改进跟踪。大多数跟踪器会丢弃低于置信度阈值的检测结果。ByteTrack首先将高置信度检测结果与现有轨迹匹配,然后将剩余的低置信度检测结果与未匹配的轨迹匹配。这恢复了暂时被遮挡或模糊(因此检测置信度低)的目标。
- **3D视觉**恢复在2D图像投影中丢失的第三个空间维度(见文件01)。
- **深度估计**预测从相机到场景中每个点的距离。
- **立体深度**使用两个相距基线距离$b$的相机。同一个点在左右图像中出现在不同的水平位置(这个偏移称为**视差**$d$)。深度与视差成反比:
$$Z = \frac{f \cdot b}{d}$$
- 其中$f$是焦距,$b$是基线距离。计算视差需要找到两个图像之间的对应点(立体匹配),这是沿水平扫描线的一维搜索(因为相机水平对齐,3D中同一高度的点投影到两幅图像的同一行)。
- **单目深度估计**从单张图像预测深度,这本质上是病态问题(无限多个3D场景可以产生相同的2D图像)。然而,人类利用相对大小、纹理梯度、遮挡和大气雾霾等线索毫不费力地做到这一点。深度学习网络从训练数据中学习这些线索。
- **MiDaS**和**Depth Anything**等模型从单张图像预测相对深度图(排序哪些物体更近)。它们使用尺度不变损失在各种数据集上训练,尽管理论上存在歧义,但仍能产生非常准确的结果。
- **点云**是3D点$(x, y, z)$的集合,可选地带有颜色或其他属性,由LiDAR传感器或立体重建捕获。与图像不同,点云是无序且不规则间隔的。
- **PointNet**(Qi等人,2017)通过独立地对每个点应用共享MLP,然后使用最大池化聚合(这是置换不变的,解决了排序问题),直接处理点云。**PointNet++**增加了层次化分组,以捕获多尺度的局部结构。
- **神经辐射场(NeRF**Mildenhall等人,2020)将3D场景表示为一个连续函数,将3D位置$(x, y, z)$和视角方向$(\theta, \phi)$映射到颜色$(r, g, b)$和密度$\sigma$。该函数由一个MLP参数化:
$$F_\theta: (x, y, z, \theta, \phi) \to (r, g, b, \sigma)$$
- 为了渲染一个像素,从相机穿过该像素向场景投射一条射线。沿射线采样点,MLP预测每个点的颜色和密度。像素颜色通过**体渲染**计算:沿射线按密度加权积分颜色:
$$C(\mathbf{r}) = \int_{t_n}^{t_f} T(t) \cdot \sigma(\mathbf{r}(t)) \cdot \mathbf{c}(\mathbf{r}(t), \mathbf{d}) \, dt$$
- 其中$T(t) = \exp(-\int_{t_n}^{t} \sigma(\mathbf{r}(s)) \, ds)$是累积透射率(已吸收的光总量)。在实际中,该积分通过沿射线采样$N$个点并求和来近似:
$$\hat{C} = \sum_{i=1}^{N} T_i \cdot (1 - \exp(-\sigma_i \delta_i)) \cdot c_i$$
- NeRF通过最小化渲染像素与一组带位姿照片的真实像素之间的MSE来训练。训练完成后,NeRF可以从任何相机位置渲染逼真的新视角。其局限性在于速度:渲染需要对MLP进行数百万次评估(每个像素每个采样点一次),这使得实时渲染变得困难。
- **3D高斯泼溅**(Kerbl等人,2023)通过将场景表示为3D高斯原语的集合(而非连续的体积函数)来解决NeRF的速度限制。每个高斯原语有一个3D位置(均值)、一个3D协方差矩阵(控制形状和朝向)、不透明度及颜色(表示为球谐函数以实现视角相关效果)。
- 渲染将每个3D高斯投影到图像平面(产生一个2D高斯"泼溅"),按深度排序,并使用alpha混合从前往后合成。这是一个在GPU上实时运行的栅格化过程(100+ FPS),比NeRF的射线步进快几个数量级。高斯泼溅达到或超过NeRF的质量,同时实现实时渲染。
- **SLAM**(同时定位与地图构建)是在未知环境中构建地图同时跟踪相机自身位置的问题。这是机器人、自动驾驶和AR的基础。
- **视觉里程计**通过跨图像跟踪特征来估计相机从一帧到另一帧的运动。特征点(SIFT、ORB,见文件01)在连续帧之间匹配,并利用这些匹配关系通过**本质矩阵**(编码两视图之间的几何关系,由文件01的内参和外参推导)估计相机的旋转和平移。
- **基于特征的SLAM**通过维护持久地图来扩展视觉里程计。**ORB-SLAM**Mur-Artal等人,2015)是使用最广泛的基于特征的SLAM系统。它有三个并行线程:
1. **跟踪**:将每帧中的ORB特征与地图匹配,使用PnPPerspective-n-Point)和RANSAC估计相机位姿。
2. **局部建图**:从匹配的特征三角化新的地图点,通过光束法平差(最小化所有观察到每个点的视图的重投影误差)优化其位置。
3. **闭环检测**:检测相机何时重新访问先前建图的区域(使用视觉词袋),然后通过全局优化地图来校正累积漂移。
- **LiDAR SLAM**使用来自LiDAR传感器的3D点云替代(或补充)相机图像。LiDAR提供直接的深度测量,使几何估计更鲁棒,但硬件成本更高。LOAMLiDAR Odometry and Mapping)等方法使用迭代最近点(ICP)配准来对齐连续扫描之间的点云。
- **视觉-惯性SLAM**融合相机数据与IMU(加速度计+陀螺仪)的测量结果。IMU提供高频的旋转和加速度估计,弥补相机帧之间的间隙,并处理快速运动或临时视觉特征丢失的情况。
- **VR/AR**应用是计算机视觉最苛刻的消费者之一。
- **姿态估计**从图像中确定人体(或面部、手部)的位置和朝向。**身体姿态**通常表示为一组2D或3D关键点位置(关节点:肩膀、肘部、手腕、髋部、膝盖、脚踝)。**OpenPose**和**MediaPipe**等模型使用热图回归预测这些关键点:对于每个关节点,模型输出一个热图,其中峰值指示关节点的位置。
- **自上而下**的方法首先使用边界框检测器(见文件03)检测人物,然后在每个框内估计姿态。**自下而上**的方法首先检测图像中的所有关键点,然后使用部位亲和场(编码连接关节点之间关联的向量场)将它们分组为个体。
- **场景重建**从传感器数据构建环境的3D模型。在AR中,这使得可以将虚拟物体放置在真实表面上、遮挡真实物体后面的虚拟物体以及投射虚拟阴影。实时场景重建方法(如ARKit和ARCore中基于深度传感器的系统)构建环境的稀疏网格,并随着用户移动而更新。
- **VR中的实时渲染**约束极为苛刻:双眼需要独立渲染90+ FPS(以避免晕动症),从头部位移到显示更新的延迟需低于20毫秒。**注视点渲染**(仅渲染用户注视位置的高分辨率,使用眼动追踪)和**重投影**(基于新头部位姿扭曲上一帧以填补下一帧渲染间隙)等技术对于满足这些约束至关重要。
- 实时神经渲染(3D高斯泼溅)、鲁棒跟踪(视觉-惯性SLAM)和高效姿态估计的融合,正使逼真的交互式AR/VR体验变得越来越可行。
## 编程任务(使用CoLab或Notebook
1. 从头实现Lucas-Kanade光流算法。计算一个方块向右移动的两帧合成图像之间的光流。
```python
import jax.numpy as jnp
import matplotlib.pyplot as plt
def lucas_kanade(frame1, frame2, window_size=5):
"""Lucas-Kanade光流。"""
# 计算梯度
Ix = jnp.zeros_like(frame1)
Iy = jnp.zeros_like(frame1)
It = frame2 - frame1
# Sobel风格梯度
Ix = Ix.at[1:-1, :].set((frame1[2:, :] - frame1[:-2, :]) / 2)
Iy = Iy.at[:, 1:-1].set((frame1[:, 2:] - frame1[:, :-2]) / 2)
H, W = frame1.shape
half_w = window_size // 2
u = jnp.zeros_like(frame1)
v = jnp.zeros_like(frame1)
for i in range(half_w, H - half_w):
for j in range(half_w, W - half_w):
Ix_win = Ix[i-half_w:i+half_w+1, j-half_w:j+half_w+1].ravel()
Iy_win = Iy[i-half_w:i+half_w+1, j-half_w:j+half_w+1].ravel()
It_win = It[i-half_w:i+half_w+1, j-half_w:j+half_w+1].ravel()
A = jnp.stack([Ix_win, Iy_win], axis=1)
ATA = A.T @ A
ATb = -A.T @ It_win
# 检查系统是否良态
det = ATA[0,0] * ATA[1,1] - ATA[0,1] * ATA[1,0]
if jnp.abs(det) > 1e-6:
flow = jnp.linalg.solve(ATA, ATb)
u = u.at[i, j].set(flow[0])
v = v.at[i, j].set(flow[1])
return u, v
# 创建两帧:一个向右移动的白色方块
frame1 = jnp.zeros((64, 64))
frame1 = frame1.at[20:40, 15:35].set(1.0)
frame2 = jnp.zeros((64, 64))
frame2 = frame2.at[20:40, 20:40].set(1.0) # 向右移动5个像素
u, v = lucas_kanade(frame1, frame2, window_size=7)
# 可视化
fig, axes = plt.subplots(1, 3, figsize=(14, 4))
axes[0].imshow(frame1, cmap='gray'); axes[0].set_title('帧1'); axes[0].axis('off')
axes[1].imshow(frame2, cmap='gray'); axes[1].set_title('帧2'); axes[1].axis('off')
# 光流的箭矢图(为清晰起见降采样)
step = 4
Y, X = jnp.mgrid[0:64:step, 0:64:step]
axes[2].imshow(frame1, cmap='gray', alpha=0.5)
axes[2].quiver(X, Y, u[::step, ::step], v[::step, ::step],
color='#e74c3c', scale=50, width=0.005)
axes[2].set_title('光流'); axes[2].axis('off')
plt.tight_layout(); plt.show()
# 检查运动区域的平均光流
region_u = u[20:40, 15:35]
print(f"物体区域的平均水平光流: {region_u[region_u != 0].mean():.2f} 像素")
```
2. 实现一个用于2D目标跟踪的简单卡尔曼滤波器。模拟一个带噪声的轨迹,并展示卡尔曼滤波器如何平滑估计。
```python
import jax
import jax.numpy as jnp
import matplotlib.pyplot as plt
def kalman_predict(x, P, F, Q):
"""卡尔曼滤波器预测步骤。"""
x_pred = F @ x
P_pred = F @ P @ F.T + Q
return x_pred, P_pred
def kalman_update(x_pred, P_pred, z, H, R):
"""卡尔曼滤波器更新步骤。"""
y = z - H @ x_pred # 创新
S = H @ P_pred @ H.T + R # 创新协方差
K = P_pred @ H.T @ jnp.linalg.inv(S) # 卡尔曼增益
x_updated = x_pred + K @ y
P_updated = (jnp.eye(len(x_pred)) - K @ H) @ P_pred
return x_updated, P_updated
# 状态: [x, y, vx, vy]
dt = 1.0
F = jnp.array([[1, 0, dt, 0], # 状态转移
[0, 1, 0, dt],
[0, 0, 1, 0],
[0, 0, 0, 1]])
H = jnp.array([[1, 0, 0, 0], # 观测:测量 x, y
[0, 1, 0, 0]])
Q = jnp.eye(4) * 0.01 # 过程噪声
R = jnp.eye(2) * 4.0 # 测量噪声(有噪声的检测器)
# 模拟真实轨迹:圆周运动
n_steps = 50
t = jnp.linspace(0, 2 * jnp.pi, n_steps)
true_x = 10 * jnp.cos(t) + 20
true_y = 10 * jnp.sin(t) + 20
# 带噪声的观测
key = jax.random.PRNGKey(42)
noise = jax.random.normal(key, (n_steps, 2)) * 2.0
obs_x = true_x + noise[:, 0]
obs_y = true_y + noise[:, 1]
# 运行卡尔曼滤波器
x = jnp.array([obs_x[0], obs_y[0], 0.0, 0.0]) # 初始状态
P = jnp.eye(4) * 10.0 # 初始不确定性
kalman_x, kalman_y = [], []
for i in range(n_steps):
x, P = kalman_predict(x, P, F, Q)
z = jnp.array([obs_x[i], obs_y[i]])
x, P = kalman_update(x, P, z, H, R)
kalman_x.append(x[0])
kalman_y.append(x[1])
kalman_x = jnp.array(kalman_x)
kalman_y = jnp.array(kalman_y)
# 可视化
plt.figure(figsize=(8, 8))
plt.plot(true_x, true_y, 'k-', linewidth=2, label='真实轨迹')
plt.scatter(obs_x, obs_y, c='#e74c3c', s=20, alpha=0.5, label='带噪声的观测')
plt.plot(kalman_x, kalman_y, '#3498db', linewidth=2, label='卡尔曼滤波')
plt.legend(); plt.grid(alpha=0.3)
plt.title('卡尔曼滤波跟踪')
plt.xlabel('x'); plt.ylabel('y')
plt.axis('equal'); plt.show()
obs_error = jnp.mean(jnp.sqrt((obs_x - true_x)**2 + (obs_y - true_y)**2))
kalman_error = jnp.mean(jnp.sqrt((kalman_x - true_x)**2 + (kalman_y - true_y)**2))
print(f"观测RMSE: {obs_error:.2f}")
print(f"卡尔曼滤波RMSE: {kalman_error:.2f}")
print(f"误差降低: {(1 - kalman_error/obs_error) * 100:.1f}%")
```
3. 实现一个简化的NeRF风格体渲染管线。通过一个简单的3D场景(已知颜色和密度的球体)投射射线,并沿每条射线积分来渲染图像。
```python
import jax
import jax.numpy as jnp
import matplotlib.pyplot as plt
def render_ray(origin, direction, spheres, n_samples=64, t_near=1.0, t_far=6.0):
"""穿过球体场景对单条射线进行体渲染。"""
t_vals = jnp.linspace(t_near, t_far, n_samples)
deltas = jnp.concatenate([jnp.diff(t_vals), jnp.array([1e-3])])
colour = jnp.zeros(3)
transmittance = 1.0
for i in range(n_samples):
point = origin + t_vals[i] * direction
# 计算该点的密度和颜色
density = 0.0
point_colour = jnp.zeros(3)
for center, radius, col, sigma in spheres:
dist = jnp.linalg.norm(point - center)
# 软球体:密度随距表面的距离指数衰减
d = jnp.exp(-jnp.maximum(0, dist - radius) * sigma) * sigma
density += d
point_colour += d * jnp.array(col)
# 按总密度归一化颜色
point_colour = jnp.where(density > 1e-6, point_colour / density, point_colour)
# 体渲染方程
alpha = 1.0 - jnp.exp(-density * deltas[i])
colour += transmittance * alpha * point_colour
transmittance *= (1.0 - alpha)
return colour
# 场景:三个彩色球体
spheres = [
(jnp.array([0.0, 0.0, 4.0]), 0.8, [1.0, 0.2, 0.2], 5.0), # 红色
(jnp.array([1.5, 0.5, 5.0]), 0.6, [0.2, 1.0, 0.2], 5.0), # 绿色
(jnp.array([-1.0, -0.5, 3.5]), 0.5, [0.2, 0.2, 1.0], 5.0), # 蓝色
]
# 相机设置
img_h, img_w = 64, 64
focal = 60.0
origin = jnp.array([0.0, 0.0, 0.0])
image = jnp.zeros((img_h, img_w, 3))
for i in range(img_h):
for j in range(img_w):
# 计算射线方向
px = (j - img_w / 2) / focal
py = -(i - img_h / 2) / focal
direction = jnp.array([px, py, 1.0])
direction = direction / jnp.linalg.norm(direction)
colour = render_ray(origin, direction, spheres)
image = image.at[i, j].set(jnp.clip(colour, 0, 1))
plt.figure(figsize=(6, 6))
plt.imshow(image)
plt.title('NeRF风格体渲染\n(3个球体)')
plt.axis('off')
plt.tight_layout(); plt.show()
print(f"图像形状: {image.shape}")
print(f"渲染了 {img_h * img_w} 条射线,每条 {64} 个采样点")
```