# 视频与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 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光流算法。计算一个方块向右移动的两帧合成图像之间的光流。
```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} 个采样点")
```
