Files
maths-cs-ai-compendium-zh/chapter 08: computer vision/05. video and 3D vision.md
T
flykhan 2536c937e3 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/ 构建缓存
2026-05-03 10:23:20 +08:00

348 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 视频与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} 个采样点")
```