跳转至

# 视频与3D视觉

视频与3D视觉将图像理解扩展到时间域和空间域。本文涵盖光流、视频分类（3D卷积网络、TimeSformer）、目标跟踪（SORT、DeepSORT）、动作识别、深度估计（单目与立体）、点云、神经辐射场（NeRF）和3D高斯泼溅。

- 文件01-04将图像视为孤立快照。但视觉世界是连续的：物体在运动，场景在变化，深度真实存在。本文将计算机视觉扩展到时间域（视频）和空间域（3D），涵盖模型如何理解运动、跟踪目标、估计深度和重建场景。

- 视频是一系列随时间捕获的图像（帧）。以30帧/秒计算，一段10秒的片段包含300帧。关键挑战在于建模时间维度：物体如何运动，场景如何演变，以及如何跨帧关联信息。

- 光流估计两帧连续图像之间像素的表观运动。对于帧\(t\)中的每个像素，光流产生一个二维位移向量\((u, v)\)，指向该像素在帧\(t+1\)中的位置。结果是一个与图像大小相同的稠密运动场。

- 光流在亮度恒常性假设下计算：像素的强度在其移动时不变。如果帧\(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章的正规方程）：

\[ \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 Transforms，Teed和Deng，2020）计算两帧中所有像素对之间的4D相关体，并使用基于GRU的更新算子迭代优化光流估计。RAFT达到了最先进的精度，并已成为标准的光流骨干网络。

- 双流网络（Simonyan和Zisserman，2014）是视频理解的早期方法。一个流处理单帧RGB图像（外观），另一个流处理光流帧的堆叠（运动）。两个流在末端融合（通过平均或拼接）。这种架构明确区分了"事物看起来像什么"与"它们如何运动"。

- 3D卷积网络将2D卷积扩展到时间维度。3D卷积使用大小为\(k \times k \times k_t\)的滤波器，同时跨越空间和时间维度，直接学习时空特征。

- C3D（Tran等人，2015）堆叠了3x3x3滤波器的3D卷积，展示了时间卷积可以在没有显式光流的情况下学习运动特征。代价是高昂的：3D卷积的参数和计算量是其2D对应物的\(k_t\)倍。

- I3D（Inflated 3D，Carreira和Zisserman，2017）采用了一种更实用的方法：从预训练的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-Something（174个需要时间推理的细粒度动作）和ActivityNet（200个类别，包含长时未裁剪视频）。

- 时间动作检测超越了分类：给定一个长段未裁剪的视频，找到每个动作的开始时间、结束时间和类别。这是目标检测的时间对应任务。ActionFormer等方法使用Transformer处理时间特征并预测动作边界。

- 视频目标跟踪在第一帧识别出特定目标后，跨帧跟踪该目标。

- SORT（Simple Online and Realtime Tracking，Bewley等人，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特征与地图匹配，使用PnP（Perspective-n-Point）和RANSAC估计相机位姿。
  2. 局部建图：从匹配的特征三角化新的地图点，通过光束法平差（最小化所有观察到每个点的视图的重投影误差）优化其位置。
  3. 闭环检测：检测相机何时重新访问先前建图的区域（使用视觉词袋），然后通过全局优化地图来校正累积漂移。

- LiDAR SLAM使用来自LiDAR传感器的3D点云替代（或补充）相机图像。LiDAR提供直接的深度测量，使几何估计更鲁棒，但硬件成本更高。LOAM（LiDAR 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光流算法。计算一个方块向右移动的两帧合成图像之间的光流。

       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目标跟踪的简单卡尔曼滤波器。模拟一个带噪声的轨迹，并展示卡尔曼滤波器如何平滑估计。

       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场景（已知颜色和密度的球体）投射射线，并沿每条射线积分来渲染图像。

       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} 个采样点")