【图像处理】vImage/Accelerate——SIMD 让 CPU 也能飞

0 阅读8分钟

GPU 是并行之王,但它不是唯一的选择。 CPU 的 SIMD 单元在正确的场景下,可以让代码快 8–16 倍——而且不需要离开 Swift。


一、SIMD 原理:单指令多数据

传统 CPU 标量运算一次处理一个数:

标量加法(每次 1 个字节):
  ADD r1, r2    → 结果:1 个字节

SIMD(Single Instruction Multiple Data)一次处理多个数:

SIMD 加法(ARM NEON,每次 16 个字节):
  VADD v1.16b, v2.16b, v3.16b    → 结果:16 个字节(16 个像素的单通道值)

Apple Silicon(ARM 架构)的 NEON 单元:

寄存器宽度支持类型一次处理量典型用途
128 位uint8×1616 个字节像素通道运算
128 位float32×44 个浮点数滤波器权重计算
128 位int16×88 个 16 位整数卷积中间值

图像处理中的意义:一张 4000×3000 RGBA 图有 4800 万字节。SIMD 每次处理 16 字节,理论上比标量快 16 倍;考虑内存带宽和流水线效率,实测约快 8–12 倍。

Apple 的 Accelerate 框架把这些 SIMD 指令封装成高层 API,开发者无需写汇编。


二、vImage_Buffer:核心数据结构

vImage 的所有操作围绕一个结构体展开:

// vImage_Buffer 的 Swift 定义(来自 Accelerate 框架)
struct vImage_Buffer {
    var data: UnsafeMutableRawPointer?  // 指向像素数据的指针
    var height: vImagePixelCount        // 图像高度(行数)
    var width: vImagePixelCount         // 图像宽度(列数)
    var rowBytes: Int                   // 每行的字节数(可能比 width*4 大,用于内存对齐)
}

rowBytes 不等于 width × 4 的原因

为了满足 SIMD 的内存对齐要求(通常 64 字节对齐),每行末尾可能有 padding 字节。vImage 会自动处理这些 padding。

从 MLBitmap 构建 vImage_Buffer

extension MLBitmap {

    func withVImageBuffer<T>(_ body: (inout vImage_Buffer) throws -> T) rethrows -> T {
        // MLBitmap.pixels 是连续的 [UInt8],直接用指针包装,零拷贝
        return try pixels.withUnsafeMutableBytes { ptr in
            var buffer = vImage_Buffer(
                data: ptr.baseAddress!,
                height: vImagePixelCount(height),
                width: vImagePixelCount(width),
                rowBytes: width * 4          // MLBitmap 保证紧凑布局
            )
            return try body(&buffer)
        }
    }
}

三、vImageBoxConvolve_ARGB8888:Box Filter 的 SIMD 实现

Box Filter(均值模糊)是最简单的平滑操作:把每个像素替换为邻域内所有像素的均值。

3×3 Box Filter 核:
  ┌ 1/9  1/9  1/9 ┐
  │ 1/9  1/9  1/9 │
  └ 1/9  1/9  1/9

vImage 的实现利用积分图(Summed-Area Table),使得 Box Filter 的复杂度与核的大小无关:

朴素实现(Swift 嵌套循环):
  每个像素 → 访问 k×k 邻域 → O(N × k²)
  5×5 核:每像素 25 次加法
  21×21 核:每像素 441 次加法

积分图实现(vImage 内部):
  每个像素 → 4 次查表 → O(N × 1),与 k 无关
  5×5 核:每像素 4 次操作
  21×21 核:每像素 4 次操作(相同!)

调用方式:

func applyBoxBlur(to bitmap: inout MLBitmap, kernelSize: UInt32) {
    // kernelSize 必须是奇数
    let k = kernelSize % 2 == 0 ? kernelSize + 1 : kernelSize

    bitmap.withVImageBuffer { src in
        // 需要一块同等大小的临时缓冲区作为输出
        var tempPixels = [UInt8](repeating: 0, count: bitmap.pixels.count)
        tempPixels.withUnsafeMutableBytes { dstPtr in
            var dst = vImage_Buffer(
                data: dstPtr.baseAddress!,
                height: vImagePixelCount(bitmap.height),
                width: vImagePixelCount(bitmap.width),
                rowBytes: bitmap.width * 4
            )
            // ARGB8888:4 通道,每通道 8 位;边缘用 kvImageEdgeExtend 镜像填充
            vImageBoxConvolve_ARGB8888(&src, &dst, nil, 0, 0, k, k,
                                       nil, vImage_Flags(kvImageEdgeExtend))
        }
        bitmap.pixels = tempPixels
    }
}

四、3× Box Filter 近似高斯——中心极限定理的实际应用

对同一图像连续做 3 次 Box Filter,等效于卷积了一个近似高斯核:

数学原理(中心极限定理)

均匀分布的 n 次卷积 → 趋近于正态(高斯)分布

Box Filter = 均匀分布卷积
Box × Box × Box = 3 次均匀分布卷积 → 接近高斯

近似误差 < 3%(与相同 σ 的真高斯核相比)

计算复杂度对比

方法复杂度radius=10(21px核)radius=20(41px核)
朴素高斯(嵌套循环)O(N × k²)441N 次操作1681N 次操作
可分离高斯(两次一维)O(N × 2k)42N 次操作82N 次操作
3× Box Filter(积分图)O(N × 3)3N 次操作(与 k 无关!)3N 次操作(与 k 无关!)

对于大半径模糊,3× Box Filter 是理论上最优的实现。


五、Ping-Pong 缓冲区:避免读写竞争

vImage 操作要求 src 和 dst 指向不同的内存区域(不支持原地修改)。连续做 3 次 Box Filter 时,需要两块缓冲区交替使用:

func applyTripleBoxBlur(bitmap: MLBitmap, kernelSize: UInt32) -> MLBitmap {
    let byteCount = bitmap.pixels.count
    let k = kernelSize

    // 准备两块缓冲区,A 存原始数据,B 是临时
    var bufA = bitmap.pixels                              // Ping
    var bufB = [UInt8](repeating: 0, count: byteCount)  // Pong

    let h = vImagePixelCount(bitmap.height)
    let w = vImagePixelCount(bitmap.width)
    let rb = bitmap.width * 4

    // 循环 3 次,每次 A → B,然后交换
    for _ in 0..<3 {
        bufA.withUnsafeMutableBytes { aBuf in
            bufB.withUnsafeMutableBytes { bBuf in
                var src = vImage_Buffer(data: aBuf.baseAddress!, height: h, width: w, rowBytes: rb)
                var dst = vImage_Buffer(data: bBuf.baseAddress!, height: h, width: w, rowBytes: rb)
                vImageBoxConvolve_ARGB8888(&src, &dst, nil, 0, 0, k, k,
                                           nil, vImage_Flags(kvImageEdgeExtend))
            }
        }
        swap(&bufA, &bufB)   // Ping-Pong:交换指针,避免内存拷贝
    }

    var result = bitmap
    result.pixels = bufA     // 3 次后,最终结果在 bufA
    return result
}

swap 的作用:Swift 的 swap 只交换两个变量持有的引用(O(1) 操作),不复制底层字节数组。每次迭代结束后,上一次的 dst 变成下一次的 src。


六、Lanczos 重采样 vs 双线性插值

图像缩放时,需要在原始像素之间做插值。vImage 提供多种质量等级:

双线性插值(Bilinear)

在频域看,双线性插值等效于一个三角形频率响应核:

频率响应(双线性):
  ▲
  │ ████
  │     ███
  │        ██
  │──────────── → 频率
  0            Nyquist

特点:平滑,但会引入模糊(高频衰减)
     对锯齿的抑制差(边缘有混叠)
     计算量小:每像素 4 个原始像素参与

Lanczos 重采样

Lanczos 核是对 sinc 函数加窗的结果:

Lanczos-3 核(a=3):
  L(x) = sinc(x) × sinc(x/3)    其中 sinc(x) = sin(πx)/(πx)

频率响应(Lanczos-3):
  ▲
  │ ███████████
  │            ▌
  │            ▌(截止更陡峭)
  │──────────── → 频率
  0            Nyquist

特点:高频保留更好,缩小后细节更锐利
     轻微振铃(ringing),边缘有轻微光晕
     计算量大:每像素 36 个原始像素参与(3×3 正负权重)
特性双线性Lanczos-3
图像质量中等,偏软高质量,锐利
计算量低(4 个采样点)高(36 个采样点)
振铃效果轻微
适合场景实时预览、小尺寸最终导出、缩图

vImage 中调用 Lanczos:

func lanczosScale(bitmap: MLBitmap, targetWidth: Int, targetHeight: Int) -> MLBitmap {
    var result = MLBitmap(width: targetWidth, height: targetHeight)
    bitmap.withVImageBuffer { src in
        result.withVImageBuffer { dst in
            // kvImageHighQualityResampling 启用 Lanczos 核
            vImageScale_ARGB8888(&src, &dst, nil, vImage_Flags(kvImageHighQualityResampling))
        }
    }
    return result
}

七、vImageMin / vImageMax:形态学操作的 SIMD 实现

腐蚀(Erosion) = 取邻域最小值:暗区域膨胀,亮区域收缩
膨胀(Dilation) = 取邻域最大值:亮区域膨胀,暗区域收缩

// 膨胀(Dilation):每个像素取 k×k 邻域最大值
func applyDilation(bitmap: inout MLBitmap, kernelSize: UInt32) {
    var tempPixels = [UInt8](repeating: 0, count: bitmap.pixels.count)
    bitmap.withVImageBuffer { src in
        tempPixels.withUnsafeMutableBytes { dstPtr in
            var dst = vImage_Buffer(
                data: dstPtr.baseAddress!,
                height: vImagePixelCount(bitmap.height),
                width: vImagePixelCount(bitmap.width),
                rowBytes: bitmap.width * 4
            )
            vImageMax_ARGB8888(&src, &dst, nil, 0, 0,
                               kernelSize, kernelSize,
                               vImage_Flags(kvImageEdgeExtend))
        }
    }
    bitmap.pixels = tempPixels
}

// 腐蚀(Erosion):每个像素取 k×k 邻域最小值
func applyErosion(bitmap: inout MLBitmap, kernelSize: UInt32) {
    // 结构同上,替换为 vImageMin_ARGB8888
    vImageMin_ARGB8888(&src, &dst, nil, 0, 0, kernelSize, kernelSize,
                       vImage_Flags(kvImageEdgeExtend))
}

形态学组合操作

  • 开运算(开 = 先腐蚀后膨胀):去除小噪点,保持主体形状
  • 闭运算(闭 = 先膨胀后腐蚀):填补小孔洞,平滑轮廓

八、完整性能对比

以 4000×3000 RGBA 图像为基准,iPhone 14 实测:

操作Phase 1 Swift 循环vImage/AccelerateCore Image(GPU)加速比(vs Phase 1)
高斯模糊(radius=10)~380ms~28ms~4msvImage 快 13.6×
高斯模糊(radius=20)~1200ms~28ms~5msvImage 快 42.8×(与 radius 无关!)
双线性缩放(50%)~95ms~8ms~2msvImage 快 11.9×
Lanczos 缩放(50%)~35ms~6msGPU 快 5.8×
腐蚀/膨胀(5×5)~220ms~15ms~3msvImage 快 14.7×
Box Filter(radius=15)~340ms~12ms~4msvImage 快 28.3×

选择策略

数据量 < 500KB(小图、缩略图):
  → vImage(启动开销低,无 GPU 上下文切换)

数据量 > 2MB(大图)且操作 < 5ms GPU 阈值:
  → Core Image

需要确定性输出(测试 / CI 环境):
  → vImage(无随机性,跨平台结果一致)

多滤镜链(3 个以上):
  → Core Image(惰性合并 Pass 优势显著)

九、小结

概念核心内容
SIMD一条指令处理 16 字节,ARM NEON 实现,是 vImage 的底层
vImage_Buffer像素数据的包装结构,含 rowBytes 对齐字段
Box Filter积分图实现,O(N) 复杂度,与核大小无关
3× Box ≈ 高斯中心极限定理,误差 < 3%,大半径时远快于真高斯
Ping-Pong 缓冲src/dst 交替,swap 零拷贝,避免读写竞争
Lanczos vs 双线性Lanczos 质量高但计算量大,双线性适合实时预览
vImageMin/Max腐蚀/膨胀的 SIMD 实现,形态学操作基础
选择策略小图/确定性 → vImage;大图多滤镜 → Core Image

♥️喜欢我的内容,欢迎大家点赞、转发、关注。

♥️本人专注于技术+投资+认知三位一体的内容分享。