GPU Gems 2 · Chapter 40. Computer Vision on the GPU
把计算机视觉反向映射到纹理与片元流水线,用过滤、归约、特征布局和多 GPU 取舍搭出实时路径。
学习目标
- 能解释图像如何经过纹理、片元程序和结果纹理组成的 GPU 视觉流水线
- 能修改 Computer Vision Lab 的阈值、归约遍数和错误模式,预测边缘、质心与读回量的变化
- 能回答:为什么把密集计算留在 GPU、只在阶段边界读回少量统计量,通常比每遍读回整张图更合适?
先把相机画面送进一条流水线
把相机想成一台不断吐出方格纸的机器。每个小格都可以同时做同一种检查,检查完的方格纸再交给下一台机器;如果每一步都把整张纸搬回桌面,机器就会频繁停下来等搬运。
本章解决的问题是:怎样把“从画面找出边缘、区域和特征”拆成一串规则的小步骤,并让每一步的结果直接成为下一步的输入?没有这条路径,中央处理器会被重复的逐像素计算和总线往返占满,留给交互逻辑的时间就会变少。
1. 视觉任务的 GPU 形状:一个位置写回一个位置
↡在每个输出位置执行同一段程序的 GPU 阶段;它从纹理读取邻域数据,再把一个结果写回当前片元对应的位置。图形硬件原本要把数值变成画面;视觉任务则把画面变成数值,正好把方向反过来。先把相机帧初始化为纹理,再把纹理映射到与图像等大的矩形上。矩形的一个片元对应一个输出位置,fragment program 就像普通 for 循环的循环体,只是所有位置可以并行执行。
一遍处理结束后,把结果放入新的纹理;下一遍把它当作输入。这个边界很重要:当前片元只写自己的结果,不需要把一份结果任意地撒到许多位置。32 位浮点纹理还能保留足够的中间精度,适合连续的视觉计算。
如果一个算法只是逐点变换,流水线很直接;真正需要设计的是“全图都做什么、最后怎样合成一个答案”。边缘检测是前者与后者的交界:每个位置先产生梯度,再用邻居比较把线变细。
2. Canny:把边缘拆成可并行的过滤接力
↡把二维卷积拆成先沿横轴、再沿纵轴的两次一维卷积;多一遍过滤,但每个位置需要读取的邻居更少。原书把 Canny 拆成三类片元程序。第一类用可分离的 Gaussian filter 平滑并求导;第二类在每个位置计算梯度强度和方向;第三类沿梯度的前后方向读取邻居,决定当前点是不是局部峰值。把 15 × 15 的二维核拆成 1 × 15 与 15 × 1,牺牲一次 pass,换来更少的纹理读取。
↡沿局部梯度方向比较前后邻居,只保留比两侧都强的像素,让宽边缘变成细线。非极大值抑制不是简单的亮度阈值:它先看梯度指向哪里,再在那条方向上找峰值。因此梯度方向量化为 3 × 3 邻域可走的八个方向;当前强度只有大于前后两个样本才留下。最后再用阈值调节环境噪声下的灵敏度。
第 1 / 4 步 · 先平滑并求导
把 Canny 拆成四个片元 pass:每一步都产生下一步所需的纹理。
// WebGL2 片元阶段:当前像素沿梯度方向比较前后样本
vec4 magDir = texture(gradientTexture, uv);
vec2 direction = normalize(magDir.xy);
vec2 forwardUv = uv + direction * texelSize;
vec2 backwardUv = uv - direction * texelSize;
float forwardMag = texture(gradientTexture, forwardUv).z;
float backwardMag = texture(gradientTexture, backwardUv).z;
float isPeak = step(forwardMag, magDir.z) * step(backwardMag, magDir.z);
float isEdge = isPeak * step(edgeThreshold, magDir.z);
outColor = vec4(vec3(isEdge), 1.0);这段代码的关键不是把所有邻域一次性展开,而是先拿到已经编码好的方向与强度,再沿方向取两个样本。对应原书的 fragment program 时,WebGL2 用 texture 和显式的 sampler2D;数据流仍然是“当前片元读取、当前片元写回”。
3. 颜色分割与矩:从整张 mask 压到一个质心
↡把每个像素及其坐标乘上像素值后求和得到的统计量;M00 是总权重,M10 与 M01 分别携带横纵坐标信息。跟踪手部可以先把 RGB 转成 HSV,再按与目标颜色的距离阈值产生 mask。mask 中的像素输出 1,其他位置输出 0;同时输出 x、y 和颜色分量,就能在后续归约中得到 M00、M10、M01。质心是 (M10 / M00, M01 / M00),所以它不需要 CPU 扫完整张图。
GPU 归约可以先把每一行压成一个值,再把这一列压成一个值。最后只读回一个像素,CPU 负责除法和跟踪状态更新;大部分乘加仍留在 GPU 上。这个“先密集算、后小结果读回”的边界同样适用于图像配准中 A x = b 的矩阵初始化。
// mask 片元阶段:为后续两遍归约准备 M00/M10/M01
vec4 hsv = rgbToHsv(texture(cameraTexture, uv));
float inside = step(distance(hsv, meanHsv), skinThreshold);
vec2 pixel = uv * imageSize;
outStats = vec4(inside, pixel.x * inside, pixel.y * inside, hsv.z * inside);这里的 outStats 不是最终画面,而是一张给归约 pass 使用的统计纹理。常见错误是把统计纹理当成普通颜色贴图去显示;它的通道各自有含义,读回时要按相同的分量协议解释。
4. 图像配准与特征:让少数点得到固定长度的描述
↡把两帧图像的对应关系写成投影参数,并通过 GPU 上的大量逐像素乘加与归约,初始化 A x = b 的线性系统。VideoOrbits 处理相机平移、倾斜、旋转或缩放后两帧的配准。每个矩阵元素都是全图许多乘法和加法的和,因此可以像矩统计一样:片元先写出每个位置对某个元素的贡献,再用归约得到矩阵项;等 8 × 8 的 A 和向量 b 准备好后才读回,CPU 再解线性系统。
特征向量是另一种输出形状:不是每个像素一份结果,而是少数特征点各有一长行。一个 16 × 16 邻域被分成 16 个 4 × 4 区域,每区把梯度方向量化到 8 个直方图 bin,连接后得到 16 × 8 = 128 个元素。先由 CPU 快速找到少量点,再让 GPU 为每个点画一行顶点并填充向量,可以同时保持灵活的纹理坐标和规则的输出布局。
// 特征点的一行输出:每个 fragment 对应一个 histogram bin
vec4 gradient = texture(gradientTexture, localUv);
float bin = quantizeDirection(gradient.y);
float weight = gradient.z * localGaussian(localUv);
vec4 histogram = vec4(0.0);
histogram = addToBin(histogram, bin, weight);
featureRow[featureIndex] = packHalf(histogram);这类工作与全图过滤不同:工作单元是“一个特征点的一行”,而不是“一个图像位置”。因此顶点布局和纹理坐标绑定本身就是算法的一部分;只要输出行长度固定,CPU 就能在帧与帧之间用欧氏距离寻找最佳匹配。
5. 并行部署:把搬运预算留给真正的状态
↡在同一台机器上把不同视觉算法放到不同图形处理器上同时运行,再用共享内存或小量状态交换结果。如果应用同时需要 Canny、手部跟踪和特征向量,可以把互不依赖的阶段分到不同 GPU。GPU 0 处理过滤与边缘,GPU 1 处理颜色分割和矩,GPU 2 处理特征行;中央处理器只编排阶段边界。原书讨论了从 PCI 到 PCI Express/SLI 的演进,但不变的判断是:只有当各任务有足够的独立工作,多个 GPU 才能抵消同步与共享状态的成本。
先猜一猜:在下面的实验中,把 edge threshold 调高会让候选边缘变多还是变少?把 reduction passes 增加,为什么质心稳定性会提高但不代表每个场景都值得增加 pass?打开 scatter 错误模式,观察规则写回为何消失。
三步验收:从片元 pass 到可部署的视觉系统
第一步:把图像拆成连续的过滤 pass
先将相机帧放入纹理,用等尺寸矩形保证一个位置对应一个片元;每一遍只做一种过滤,输出纹理直接成为下一遍输入。Canny 的高斯、梯度、抑制和阈值因此可以分别检查。
本章小结
- 视觉任务可反向映射到纹理→片元程序→结果纹理的流水线。
- 可分离滤波用多一遍 pass 换更少的二维邻域读取。
- 矩归约把整张 mask 压成少量状态,再交给 CPU 更新。
- 特征向量用“每点一行”的布局处理稀疏、长输出。
- 多 GPU 的收益取决于独立工作是否足以覆盖同步成本。
练习
问题 1|画出数据流。 请写出 Canny 的四个阶段,并说明为什么“结果纹理成为下一阶段输入”比每阶段读回 CPU 更适合实时处理。
问题 2|修改 Demo 代码。 为 Computer Vision Lab 增加一个 readbackMode 开关,比较“每遍读回整张 mask”和“行列归约后只读一个像素”两种模式,并在界面显示读回字节数。你要在哪里插入同步点?
问题 3|场景选型。 现在有一个全图 Canny、一个手部质心和几十个角点的 128 维描述。哪些工作适合逐像素过滤,哪些适合归约或按点输出?为什么不能用同一种输出布局?
名词解释
本章出现的专业名词,用大白话再讲一遍。
- fragment program
- separable filter
- nonmaximal suppression
- moment
- VideoOrbits
- feature vector
- multi-GPU