Skip to content

视频与3D视觉

视频与3D视觉将图像理解扩展到时间域和空间域。本文涵盖光流、视频分类(3D CNN、TimeSformer)、目标跟踪(SORT、DeepSORT)、动作识别、深度估计(单目与立体)、点云、NeRF以及3D高斯泼溅。

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

  • 视频是一系列按时间顺序捕获的图像(帧)。以30帧/秒为例,一段10秒的短片包含300帧。核心挑战在于建模时间维度:物体如何运动、场景如何演化、以及如何关联不同帧之间的信息?

  • 光流估计连续两帧之间像素的表观运动。对于帧 \(t\) 中的每个像素,光流产生一个2D位移向量 \((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方法假设小窗口(如5×5像素)内光流恒定。这产生一个超定方程组(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} \]
  • 该2×2矩阵即为文件01中的结构张量(与Harris角点检测所用矩阵相同)。Lucas-Kanade适用于小幅度运动,但当物体在帧间移动超过几个像素时会失效。

  • Farneback方法为每个像素邻域拟合多项式展开,并估计最能解释帧间变化的位移场。它生成稠密光流(每个像素一个向量),且能处理比Lucas-Kanade更大的运动。

  • 现代深度学习光流方法(FlowNet、RAFT)通过端到端学习从帧对预测光流。RAFT(Recurrent All-Pairs Field Transforms, Teed and Deng, 2020)计算两帧所有像素对之间的4D相关体积,并使用基于GRU的更新算子迭代优化光流估计。RAFT达到最先进的精度,已成为标准的光流骨干网络。

  • 双流网络(Simonyan and Zisserman, 2014)是视频理解的早期方法。一个流处理单帧RGB图像(外观),另一个流处理堆叠的光流帧(运动)。两个流在末端融合(通过平均或拼接)。该架构显式分离"物体看起来什么样"与"物体如何运动"。

  • 3D卷积网络将2D卷积扩展到时间维度。3D卷积应用尺寸为 \(k \times k \times k_t\) 的滤波器,同时跨越空间和时间维度,直接学习时空特征。

  • C3D(Tran et al., 2015)堆叠使用3×3×3滤波器的3D卷积,证明时间卷积无需显式光流即可学习运动特征。但代价高昂:3D卷积的参数量和计算量是2D卷积的 \(k_t\) 倍。

  • I3D(Inflated 3D, Carreira and Zisserman, 2017)采用更实用的方法:从预训练的2D CNN(如Inception或ResNet)出发,将所有2D滤波器沿时间维度重复权重并除以 \(k_t\),"膨胀"为3D滤波器。这将ImageNet预训练迁移到视频任务,同时添加时间建模能力。2D \(k \times k\) 滤波器变为 \(k \times k \times k_t\) 滤波器,初始化为 \(W_{\text{3D}}[:,:,j] = W_{\text{2D}} / k_t\)(对所有时间位置 \(j\))。

  • SlowFast网络(Feichtenhofer et al., 2019)使用两条并行通路,以不同时间分辨率运行:

    • Slow通路以低帧率(如每16帧取1帧)处理图像,保持高空间分辨率和较多通道,捕获精细空间细节。
    • Fast通路以高帧率(如每2帧取1帧)处理图像,降低空间分辨率并减少通道数(通常为Slow通路的 \(1/8\)),捕获快速时间变化。
    • 横向连接通过步长卷积将Fast通路信息融合到Slow通路。
  • 核心洞察:空间与时间信息具有不同的带宽需求——物体外观变化缓慢,但运动可能非常迅速。SlowFast通过架构设计匹配这种不对称性。

  • TimeSformer(Bertasius et al., 2021)将Vision Transformer应用于视频。它将完整的时空注意力(计算代价极高:\(O((T \times N)^2)\),其中 \(T\) 为帧数,\(N\) 为每帧patch数)分解为分离注意力:每个模块交替执行时间注意力(每个patch在同一空间位置跨时间关注)和空间注意力(每个patch在同一帧内跨空间关注)。这将计算代价从 \(O(T^2 N^2)\) 降低到 \(O(T^2 + N^2)\)

  • VideoMAE(Tong et al., 2022)将掩码自编码器思想(文件04)扩展到视频。使用极高的掩码比例(90-95%),因为视频具有高时间冗余:相邻帧几乎相同,因此即使掩码大部分patch,仍有足够信息用于重建。VideoMAE在无标签视频上预训练ViT骨干网络,并迁移到下游任务。

  • 动作识别将视频片段分类为众多动作类别之一(如"跑步"、"烹饪"、"弹吉他")。它是图像分类在视频领域的类比。标准基准包括Kinetics-400(400个动作类别,约30万片段)、Something-Something(174个需要时间推理的细粒度动作)和ActivityNet(200个类别,包含长而未剪辑的视频)。

  • 时序动作检测超越分类任务:给定一段长而未剪辑的视频,找出每个动作的起始时间、结束时间和类别。这是目标检测在时间域的类比。如ActionFormer等方法使用Transformer处理时序特征并预测动作边界。

  • 视频目标跟踪在目标于首帧被识别后,跨帧跟踪该特定目标。

  • SORT(Simple Online and Realtime Tracking, Bewley et al., 2016)结合检测模型(每帧独立检测目标)与卡尔曼滤波进行运动预测,以及匈牙利算法进行数据关联。

  • 卡尔曼滤波为每个跟踪目标维护状态估计(位置、速度、尺寸),并使用线性运动模型预测其在下一帧的位置。当新检测到达时,卡尔曼滤波通过结合预测与观测(根据各自不确定性加权)更新估计。这是贝叶斯更新(文件05)在跟踪任务中的应用。

  • 匈牙利算法解决二分图分配问题:给定 \(M\) 个跟踪目标和 \(N\) 个新检测,找到使总代价最小(使用文件03中的IoU距离)的最优一对一匹配。未匹配的检测启动新轨迹;未匹配的轨迹在宽限期后终止。

  • DeepSORT在SORT基础上添加深度外观特征:每个检测目标通过小型CNN生成外观嵌入(描述向量)。匹配代价结合IoU距离与嵌入空间中的余弦距离(文件01)。这能处理遮挡与重识别:即使目标被遮挡数帧,其外观嵌入仍可在重现时重新匹配。

  • ByteTrack(Zhang et al., 2022)通过利用所有检测(包括低置信度检测)提升跟踪性能。大多数跟踪器会丢弃低于置信度阈值的检测。ByteTrack首先将高置信度检测与现有轨迹匹配,再将剩余的低置信度检测与未匹配轨迹匹配。这能恢复因暂时遮挡或模糊(导致检测置信度低)而丢失的目标。

  • 3D视觉恢复在2D图像投影中丢失的第三空间维度(文件01)。

  • 深度估计预测相机到场景中各点的距离。

  • 立体深度使用两个相距已知基线 \(b\) 的相机。同一点在左右图像中出现在不同水平位置(该偏移称为视差 \(d\))。深度与视差成反比:

\[Z = \frac{f \cdot b}{d}\]
  • 其中 \(f\) 为焦距,\(b\) 为基线距离。计算视差需要在两幅图像间寻找对应点(立体匹配),这是一维搜索(沿水平扫描线),因为相机水平对齐时,3D中同高度的点会投影到两幅图像的同一行。

  • 单目深度估计从单张图像预测深度,这在理论上是不适定的(无限多种3D场景可产生相同的2D图像)。但人类能借助相对大小、纹理梯度、遮挡、大气雾度等线索轻松完成。深度网络从训练数据中学习这些线索。

  • MiDaSDepth Anything等模型从单张图像预测相对深度图(排序哪些物体更近)。它们在多样化数据集上使用尺度不变损失训练,尽管存在理论歧义,仍能产生相当准确的结果。

  • 点云是由3D点 \((x, y, z)\) 组成的集合,可选包含颜色或其他属性,由激光雷达传感器或立体重建捕获。与图像不同,点云是无序且不规则分布的。

  • PointNet(Qi et al., 2017)直接处理点云:对每个点独立应用共享MLP,然后通过最大池化聚合(具有排列不变性,解决排序问题)。PointNet++添加分层分组以捕获多尺度局部结构。

  • 神经辐射场(NeRF)(Mildenhall et al., 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 et al., 2023)通过将场景表示为3D高斯基元集合而非连续体函数,解决NeRF的速度限制。每个高斯具有3D位置(均值)、3D协方差矩阵(控制形状与方向)、不透明度和颜色(用球谐函数表示视角相关效果)。

  • 渲染时将每个3D高斯投影到图像平面(产生2D高斯"泼溅"),按深度排序,并使用alpha混合从前向后合成。这是可在GPU上实时运行(100+ FPS)的光栅化过程,比NeRF的射线步进快数个数量级。高斯泼溅在匹配或超越NeRF质量的同时,实现了实时渲染。

  • SLAM(Simultaneous Localisation and Mapping,同步定位与建图)是在未知环境中构建地图的同时跟踪相机位置的问题。它是机器人、自动驾驶和AR的基础。

  • 视觉里程计通过跨帧跟踪特征估计相机运动。特征点(文件01中的SIFT、ORB)在连续帧间匹配,并使用本质矩阵(编码两视图间的几何关系,由文件01的内参和外参推导)从对应点估计相机的旋转和平移。

  • 基于特征的SLAM通过维护持久地图扩展视觉里程计。ORB-SLAM(Mur-Artal et al., 2015)是最广泛使用的基于特征的SLAM系统。它包含三个并行线程:

    1. 跟踪:将新帧中的ORB特征与地图匹配,使用PnP(Perspective-n-Point)和RANSAC估计相机位姿
    2. 局部建图:从匹配特征三角化新地图点,使用光束法平差(最小化每个点在所有可见视图中的重投影误差)优化其位置
    3. 闭环检测:检测相机是否重访先前建图区域(使用视觉词袋),然后通过全局优化地图校正累积漂移
  • 激光雷达SLAM使用激光雷达传感器的3D点云而非(或结合)相机图像。激光雷达提供直接深度测量,使几何估计更鲁棒,但硬件成本更高。如LOAM(LiDAR Odometry and Mapping)等方法使用迭代最近点(ICP)配准连续扫描间的点云。

  • 视觉-惯性SLAM融合相机数据与IMU(加速度计+陀螺仪)测量。IMU提供高频旋转和加速度估计,桥接相机帧之间的间隙,并处理快速运动或暂时视觉特征丢失。

  • VR/AR应用是计算机视觉最苛刻的消费者之一。

  • 姿态估计从图像确定人体(或面部、手部)的位置与朝向。人体姿态通常表示为一组2D或3D关键点位置(关节:肩、肘、腕、髋、膝、踝)。如OpenPoseMediaPipe等模型使用热图回归预测这些关键点:对每个关节,模型输出热图,峰值指示关节位置。

  • 自上而下方法先用边界框检测器(文件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个采样点")