视频与三维视觉 视频与三维视觉(video and 3D vision)把图像理解扩展到时间域和空间域。本文件涵盖光流、视频分类(3D CNN、TimeSformer)、目标跟踪(SORT、DeepSORT)、动作识别、深度估计(单目和立体)、点云、NeRF 和三维高斯泼溅(3D Gaussian splatting)。 文件 01 到 04 把图像当作孤立的快照来处理。但视觉世界是连续的:物体会动、场景会变、深度客观存在。本文件把计算机视觉扩展到时间域(视频)和空间域(三维),讨论模型如何理解运动、跟踪物体、估计深度、重建场景。 视频(video)是随时间捕获的一串图像(帧)。30 帧每秒时,一段 10 秒的片段包含 300 帧。
视频与三维视觉(video and 3D vision)把图像理解扩展到时间域和空间域。本文件涵盖光流、视频分类(3D CNN、TimeSformer)、目标跟踪(SORT、DeepSORT)、动作识别、深度估计(单目和立体)、点云、NeRF 和三维高斯泼溅(3D Gaussian splatting)。
文件 01 到 04 把图像当作孤立的快照来处理。但视觉世界是连续的:物体会动、场景会变、深度客观存在。本文件把计算机视觉扩展到时间域(视频)和空间域(三维),讨论模型如何理解运动、跟踪物体、估计深度、重建场景。
视频(video)是随时间捕获的一串图像(帧)。30 帧每秒时,一段 10 秒的片段包含 300 帧。关键挑战在于建模时间维度:物体怎么运动、场景怎么演化、我们怎么把跨帧的信息关联起来?
**光流(optical flow)**估计两帧之间像素的表观运动。对第 t 帧的每个像素,光流产出一个二维位移向量 (u, v),指向该像素在第 t+1 帧的位置。结果是一个和图像等大的密集运动场。
其中 I_x, I_y 是空间梯度(Sobel,文件 01),I_t 是时间梯度(相邻帧之差)。这就是光流约束方程。一个方程,两个未知量(u, v),我们还需要一个额外约束。
Lucas-Kanade 假设光流在一个小窗口(比如 5x5 像素)内是常数。这给出一组超定方程组(25 个方程、2 个未知量),用最小二乘求解(第 6 章的正规方程):
\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)计算两帧所有像素对之间的四维相关体,并用一个基于 GRU 的更新算子迭代地精炼光流估计。RAFT 达到了当时的最佳精度,已成为标准的光流主干。
双流网络(two-stream networks)(Simonyan 和 Zisserman,2014)是视频理解的早期方法。一条流处理单张 RGB 帧(外观),另一条流处理一叠光流帧(运动)。两条流在最后融合(取平均或拼接)。这种架构显式地把"东西长什么样"和"它怎么动"分开了。
**三维卷积网络(3D Convolutional Networks)**把二维卷积扩展到时间维度。三维卷积用一个大小为 k \times k \times k_t、同时跨越空间和时间的滤波器,直接学习时空特征。
C3D(Tran 等,2015)堆叠了 3x3x3 的三维卷积,表明时间卷积能学到运动特征,无需显式的光流。代价很高:三维卷积比对应的二维卷积多 k_t 倍的参数和计算量。
I3D(Inflated 3D,Carreira 和 Zisserman,2017)采取了更实用的做法:从一个预训练的二维 CNN(比如 Inception 或 ResNet)出发,把所有二维滤波器沿时间维度重复权重再除以 k_t 来"膨胀"成三维。这样把 ImageNet 预训练迁移到视频,同时加上了时间建模。一个二维的 k \times k 滤波器变成 k \times k \times k_t 的滤波器,初始化为对所有时间位置 j 有 W_{\text{3D}}[:,:,j] = W_{\text{2D}} / k_t。
SlowFast 网络(Feichtenhofer 等,2019)用两条在不同时间分辨率上运行的并行通路:
这里的洞见是:空间信息和时间信息对带宽的需求不同——物体外观变化慢,但运动可能很快。SlowFast 在设计上就匹配了这种不对称。
TimeSformer(Bertasius 等,2021)把视觉 Transformer 应用到视频上。它把全时空注意力(代价高得不可接受:T 帧、每帧 N 个图像块时是 O((T \times N)^2))分解为分离注意力(divided attention):每个块在时间注意力(每个图像块在同一空间位置上跨时间注意)和空间注意力(每个图像块在同一帧内跨空间注意)之间交替。这把代价从 O(T^2 N^2) 降到 O(T^2 + N^2)。
VideoMAE(Tong 等,2022)把掩码自编码器的想法(文件 04)扩展到视频。它用极高的掩码比例(90-95%),因为视频有很大的时间冗余:相邻帧几乎一样,所以掩掉大部分图像块仍然留下足够信息做重建。VideoMAE 在无标注视频上预训练一个 ViT 主干,再迁移到下游任务。
**动作识别(action recognition)**把一段视频片段分到多个动作类别之一(比如"跑步""做饭""弹吉他")。它是图像分类的视频对应物。标准基准包括 Kinetics-400(400 个动作类、约 30 万段片段)、Something-Something(174 个需要时间推理的细粒度动作)和 ActivityNet(200 个类别、长且未剪辑的视频)。
**时序动作检测(temporal action detection)**不只是分类:给定一段长的未剪辑视频,找出每个动作的开始时间、结束时间和类别。这是目标检测的时间对应物。ActionFinder 等方法用 Transformer 处理时间特征并预测动作边界。
**视频目标跟踪(video object tracking)**指物体在第一帧被识别后,在后续帧中持续跟随它。
SORT(Simple Online and Realtime Tracking,Bewley 等,2016)把一个检测模型(在每帧独立地检测物体)和用于运动预测的卡尔曼滤波器(Kalman filter)、用于分配的**匈牙利算法(Hungarian algorithm)**结合起来。
卡尔曼滤波器为每个被跟踪物体维护一个状态估计(位置、速度、大小),并用一个线性运动模型预测它下一帧的位置。当新的检测到来时,卡尔曼滤波器把预测和观测按各自的不确定性加权融合来更新估计。这就是第 5 章贝叶斯更新在跟踪上的应用。
匈牙利算法解决这个二分分配问题:给定 M 个被跟踪物体和 N 个新检测,找到总代价最小的一对一匹配(用文件 03 的 IoU 距离)。未匹配上的检测会启动新轨迹;未匹配上的轨迹在宽限一段时间后被终止。
DeepSORT 在 SORT 上加了一个深度外观特征:每个检测到的物体过一个小型 CNN,产生一个外观嵌入(描述子向量)。匹配代价把 IoU 距离和嵌入空间里的余弦距离(第 1 章)结合起来。这能处理遮挡和重识别:即便物体在其他物体后面消失几帧,它的外观嵌入也能在它再次出现时重新匹配。
ByteTrack(Zhang 等,2022)通过利用每一个检测(包括低置信度的那些)来改进跟踪。大多数跟踪器会丢弃低于置信度阈值的检测。ByteTrack 先把高置信度检测匹配到已有轨迹,再把剩下的低置信度检测匹配到未匹配的轨迹。这样能找回那些被短暂遮挡或模糊(因此检测置信度低)的物体。
**三维视觉(3D vision)**恢复在二维图像投影中丢失的第三个空间维度(文件 01)。
**深度估计(depth estimation)**预测从相机到场景中每个点的距离。
立体深度(stereo depth)用两个间距为已知基线 b 的相机。同一个点在左右图中的水平位置不同(这个偏移叫视差 disparity d)。深度和视差成反比:
其中 f 是焦距,b 是基线距离。要算视差,需要在两幅图之间找对应点(立体匹配),这是沿水平扫描线的一维搜索(因为相机是水平对齐的,三维里同一高度的点在两幅图里都投影到同一行)。
**单目深度估计(monocular depth estimation)**从单张图像预测深度,这本质上是病态问题(无穷多个三维场景都能产生同一张二维图像)。然而人类凭相对大小、纹理梯度、遮挡、大气雾霾这些线索毫不费力就能做到。深度网络从训练数据里学到这些线索。
MiDaS 和 Depth Anything 等模型从单张图像预测相对深度图(排出哪个物体更近)。它们在多样数据集上用尺度不变损失训练,尽管理论上有歧义,仍产生了相当准确的结果。
**点云(point cloud)**是三维点 (x, y, z) 的集合,可附带颜色或其他属性,由 LiDAR 传感器或立体重建捕获。和图像不同,点云是无序且不规则分布的。
PointNet(Qi 等,2017)直接处理点云:对每个点独立地应用共享 MLP,再用最大池化聚合(最大池化对置换不变,从而解决了排序问题)。PointNet++ 加上层次化分组,在多个尺度上捕捉局部结构。
神经辐射场(Neural Radiance Fields,NeRF)(Mildenhall 等,2020)把一个三维场景表示为一个连续函数:它把三维位置 (x, y, z) 和观察方向 (\theta, \phi) 映射到颜色 (r, g, b) 和密度 \sigma。这个函数由一个 MLP 参数化:
NeRF 通过最小化渲染像素和一组带位姿的照片的真值像素之间的 MSE 来训练。训练后,NeRF 能从任意相机位置渲染出照片级真实的新视角。局限是速度:渲染需要把 MLP 求值上百万次(每个像素每个采样点一次),实时渲染很难。
三维高斯泼溅(3D Gaussian Splatting)(Kerbl 等,2023)通过把场景表示成一组三维高斯原语(而不是一个连续的体积函数)来解决 NeRF 的速度问题。每个高斯有一个三维位置(均值)、一个三维协方差矩阵(控制形状和朝向)、不透明度以及颜色(用球谐函数表示,以产生视角相关的效果)。
渲染时把每个三维高斯投影到成像平面(产生一个二维高斯"泼溅"),按深度排序,再用 alpha 混合从前向后合成。这是一种在 GPU 上实时运行(100+ FPS)的光栅化过程,比 NeRF 的射线步进快好几个数量级。高斯泼溅在质量上匹敌甚至超过 NeRF,同时支持实时渲染。
**SLAM(Simultaneous Localisation and Mapping,同时定位与建图)**是在未知环境中构建地图的同时跟踪相机在其中的位置的问题。它对机器人、自动驾驶和 AR 都至关重要。
**视觉里程计(visual odometry)通过跨图像跟踪特征来逐帧估计相机运动。特征点(文件 01 的 SIFT、ORB)在连续帧之间匹配,再从这些对应关系用本质矩阵(essential matrix)**估计相机的旋转和平移(本质矩阵编码了两视图之间的几何关系,由文件 01 的内参和外参导出)。
**基于特征的 SLAM(feature-based SLAM)**通过维护一张持久的地图来扩展视觉里程计。ORB-SLAM(Mur-Artal 等,2015)是用得最广的基于特征的 SLAM 系统。它有三个并行线程:
LiDAR SLAM 用来自 LiDAR 传感器的三维点云代替(或补充)相机图像。LiDAR 提供直接的深度测量,让几何估计更鲁棒,但硬件成本更高。LOAM(LiDAR Odometry and Mapping)等方法用迭代最近点(ICP)配准在连续扫描之间对齐点云。
**视觉-惯性 SLAM(Visual-Inertial SLAM)**把相机数据和 IMU(加速度计 + 陀螺仪)的测量融合起来。IMU 提供高频的旋转和加速度估计,填补相机帧之间的空隙,并能应对快速运动或短暂的视觉特征丢失。
VR/AR 应用是计算机视觉最苛刻的消费者之一。
**姿态估计(pose estimation)**从图像确定人体(或人脸、或手)的位置和朝向。身体姿态通常表示成一组二维或三维关键点位置(关节:肩、肘、腕、髋、膝、踝)。OpenPose 和 MediaPipe 等模型用热力图回归来预测这些关键点:对每个关节,模型输出一张热力图,峰值指示关节位置。
**自顶向下(top-down)**方法先用一个边界框检测器(文件 03)检测人,再在每个框内估计姿态。**自底向上(bottom-up)**方法先检测图像中所有的关键点,再用部位亲和场(编码相连关节之间关联的向量场)把它们分组成个体。
**场景重建(scene reconstruction)**从传感器数据构建环境的三维模型。在 AR 中,这让我们能把虚拟物体放到真实表面上、用真实物体遮挡虚拟物体、以及投射虚拟阴影。实时场景重建方法(如 ARKit 和 ARCore 中基于深度传感器的系统)构建一张随用户移动而更新的环境稀疏网格。
VR 里实时渲染的约束极其苛刻:双眼需要分别以 90+ FPS 渲染(避免晕动症),从头部运动到显示更新的延迟要低于 20 毫秒。注视点渲染(foveated rendering)(只在用户注视的地方用高分辨率渲染,借助眼动追踪)和重投影(reprojection)(根据新的头部位姿扭曲上一帧来填补下一帧渲染时的空隙)等技术对于满足这些约束至关重要。
实时神经渲染(三维高斯泼溅)、鲁棒跟踪(视觉-惯性 SLAM)和高效姿态估计的融合,正让照片级真实、可交互的 AR/VR 体验变得越来越可行。
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('Frame 1'); axes[0].axis('off') axes[1].imshow(frame2, cmap='gray'); axes[1].set_title('Frame 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('Optical Flow'); axes[2].axis('off') plt.tight_layout(); plt.show() # 检查运动区域里的平均光流 region_u = u[20:40, 15:35] print(f"Average horizontal flow in object region: {region_u[region_u != 0].mean():.2f} pixels")
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='Ground Truth') plt.scatter(obs_x, obs_y, c='#e74c3c', s=20, alpha=0.5, label='Noisy Observations') plt.plot(kalman_x, kalman_y, '#3498db', linewidth=2, label='Kalman Filter') plt.legend(); plt.grid(alpha=0.3) plt.title('Kalman Filter Tracking') 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"Observation RMSE: {obs_error:.2f}") print(f"Kalman filter RMSE: {kalman_error:.2f}") print(f"Error reduction: {(1 - kalman_error/obs_error) * 100:.1f}%")
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-style Volume Rendering\n(3 spheres)') plt.axis('off') plt.tight_layout(); plt.show() print(f"Image shape: {image.shape}") print(f"Rendered {img_h * img_w} rays with 64 samples each")