普通视图

发现新文章,点击刷新页面。
昨天 — 2026年5月30日iOS

【图像处理】Core Image 与 GPU 渲染管线——让滤镜飞起来

作者 FIRE_Lee
2026年5月30日 16:49

CPU 是一位精英工程师,一次专心做一件事; GPU 是一支万人工厂,每条流水线同时处理一块像素。 选对工具,差距可以是 10 倍。


一、GPU vs CPU:图像处理的架构差异

处理一张 4000×3000(1200 万像素)的照片,每个像素需要独立计算。

架构 核心数 时钟频率 每秒操作数(粗估) 典型图像处理耗时
CPU(Apple M2,4 性能核) 4 3.5 GHz ~14 GFLOPS(浮点) 高斯模糊 ~120ms
GPU(Apple M2,10 核) 10 个着色器簇(每簇 128 核) 1.4 GHz ~3.6 TFLOPS 高斯模糊 ~3ms

差距来自什么?

CPU 处理像素(串行):
  Pixel[0] → 计算 → Pixel[1] → 计算 → Pixel[2] → ...
  → 1200 万次顺序操作

GPU 处理像素(并行):
  Pixel[0..N]   → 同时计算(一个 GPU 核处理一个像素)
  → 理论上 1280 个核同时工作,实际受限于带宽

CPU 的优势在于复杂逻辑、分支预测、大缓存;GPU 的优势在于数量庞大的简单核心,适合对每个像素做相同操作(SIMT:单指令多线程)。

图像滤镜恰好是 GPU 的主场:对每个像素独立执行同一段着色器程序。


二、Core Image 处理流水线

Apple 的 Core Image 框架把 GPU 渲染封装成了一套声明式 API:

原始图像数据
    ↓
CIImage(图像描述符,惰性)
    ↓
CIFilter(滤镜图,节点图)
    ↓  ← CIContext 触发渲染
CGImage / MTLTexture / UIImage

关键概念:CIImage 不是像素数据

CIImage 是一个描述,不是内存中的像素数组。它只记录"这是一张什么图,来自哪里,怎么处理"。

// 这行代码不做任何像素计算:
let ciImage = CIImage(cgImage: myCGImage)

// 这行也只是在图上挂一个滤镜节点:
let blurred = ciImage.applyingFilter("CIGaussianBlur", parameters: ["inputRadius": 10])

// 只有这行才真正触发 GPU 计算:
let result = context.createCGImage(blurred, from: blurred.extent)

这种惰性求值(Lazy Evaluation)设计让 Core Image 可以把多个滤镜合并成一个 GPU Pass,极大减少显存读写。


三、CIContext 的创建成本

CIContext 是连接 CPU/Swift 代码和 GPU 的桥梁,它负责:

  • 编译 Metal 着色器
  • 管理 GPU 纹理缓存
  • 协调 GPU 命令队列

问题:创建 CIContext 非常昂贵。

// 实测(iPhone 14,Debug build):
let t0 = CACurrentMediaTime()
let ctx = CIContext()                    // Metal 默认
let t1 = CACurrentMediaTime()
print((t1 - t0) * 1000)                 // 输出:约 18ms ~ 32ms

在处理每张图片时创建新的 CIContext常见错误

// 错误做法:每次调用都创建新 context,消耗 18-32ms
func applyBlur(to image: CIImage) -> CGImage {
    let context = CIContext()            // 每次都创建!
    return context.createCGImage(image, from: image.extent)!
}

解决方案:复用 CIContext,用对象池管理。


四、CIContextPool 设计

MLImageToolkit 中的 CIContextPool 采用单例 + Metal 优先,CPU 回退的策略:

final class CIContextPool {

    static let shared = CIContextPool()
    private init() {}

    // Metal 上下文(GPU 渲染)
    private lazy var metalContext: CIContext? = {
        guard let device = MTLCreateSystemDefaultDevice() else { return nil }
        return CIContext(mtlDevice: device, options: [
            .workingColorSpace: CGColorSpaceCreateDeviceRGB(),
            .outputColorSpace:  CGColorSpaceCreateDeviceRGB()
        ])
    }()

    // CPU 软件渲染(测试 / Metal 不可用时的回退)
    private lazy var cpuContext: CIContext = {
        CIContext(options: [
            .useSoftwareRenderer: true,
            .workingColorSpace: CGColorSpaceCreateDeviceRGB()
        ])
    }()

    /// 获取当前最优上下文
    func context(preferCPU: Bool = false) -> CIContext {
        if preferCPU { return cpuContext }
        return metalContext ?? cpuContext
    }
}

设计要点

特性 说明
lazy var 延迟初始化,不在 app 启动时占用 18ms
Metal 优先 MTLCreateSystemDefaultDevice() 失败时自动降级
CPU 回退 模拟器、单元测试环境无 GPU,需要 CPU 渲染
单例共享 整个 app 生命周期只创建一次,完全消除重复创建成本

五、MLBitmap ↔ CIImage 的转换

5.1 坐标系差异

这是 Core Image 最容易踩的坑:

UIKit / MLBitmap 坐标系:
  (0,0) ─────────────→ x
    │
    │
    ↓ y
  原点在左上角,y 轴向下

CIImage 坐标系:
  原点在左下角,y 轴向上(类 OpenGL)
    ↑ y
    │
    │
  (0,0) ─────────────→ x

在 iOS 上直接把 UIImage 转成 CIImage 再渲染回来,图像会上下翻转。必须手动处理。

5.2 MLBitmap → CIImage

extension MLBitmap {

    func toCIImage() -> CIImage {
        // 1. 把 MLBitmap 的原始字节包装成 Data(零拷贝)
        let data = Data(bytes: pixels, count: pixels.count)

        // 2. 用 RGBA 格式描述内存布局
        let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue)

        // 3. 创建 CGDataProvider(仍是惰性,不复制数据)
        guard let provider = CGDataProvider(data: data as CFData),
              let cgImage = CGImage(
                width: width,
                height: height,
                bitsPerComponent: 8,
                bitsPerPixel: 32,
                bytesPerRow: width * 4,
                space: CGColorSpaceCreateDeviceRGB(),
                bitmapInfo: bitmapInfo,
                provider: provider,
                decode: nil,
                shouldInterpolate: false,
                intent: .defaultIntent
              )
        else {
            fatalError("MLBitmap → CIImage 转换失败")
        }

        // 4. CIImage 原点在左下,需要翻转 y 轴
        return CIImage(cgImage: cgImage)
            .transformed(by: CGAffineTransform(scaleX: 1, y: -1)
                .concatenating(CGAffineTransform(translationX: 0, y: CGFloat(height))))
    }
}

为什么需要那个 transform?

CIImage(cgImage:) 会把 CGImage 的第一行(上方)映射到 CIImage 坐标的 y=0(下方),导致图像倒置。我们施加一个 y 轴翻转 + 向上平移 height 的变换,把图像重新正过来。

5.3 CIImage → MLBitmap(render 阶段)

func ciImageToBitmap(_ ciImage: CIImage, width: Int, height: Int) -> MLBitmap {

    // 关键:render bounds 固定为 (0, 0, width, height)
    // 不能用 ciImage.extent,因为经过 transform 后 extent 可能有偏移
    let renderRect = CGRect(x: 0, y: 0, width: width, height: height)

    let context = CIContextPool.shared.context()
    guard let cgImage = context.createCGImage(ciImage, from: renderRect) else {
        fatalError("CIImage → CGImage 渲染失败")
    }

    // 从 CGImage 读回像素
    var bitmap = MLBitmap(width: width, height: height)
    let bmpContext = CGContext(
        data: &bitmap.pixels,
        width: width,
        height: height,
        bitsPerComponent: 8,
        bytesPerRow: width * 4,
        space: CGColorSpaceCreateDeviceRGB(),
        bitmapInfo: CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue).rawValue
    )
    bmpContext?.draw(cgImage, in: renderRect)
    return bitmap
}

为什么 render bounds 固定为 (0, 0, w, h)

经过坐标系翻转变换后,ciImage.extent 的原点可能变成 (0, -height),直接传入 createCGImage 会渲染出全黑或裁剪错误。固定 (0, 0, w, h) 确保始终渲染图像的有效区域。


六、CIFilterBridge 协议设计

为了让自定义滤镜和系统 CIFilter 无缝集成,MLImageToolkit 定义了 CIFilterBridge 协议:

/// 所有基于 Core Image 的滤镜都实现此协议
protocol CIFilterBridge {
    /// 将自身应用到 CIImage,返回处理后的 CIImage(仍是惰性)
    func apply(to image: CIImage, extent: CGRect) -> CIImage
}

/// 工厂协议:创建对应的系统 CIFilter(按需懒创建)
protocol CIFilterFactory: CIFilterBridge {
    var filterName: String { get }
    var parameters: [String: Any] { get }
}

extension CIFilterFactory {
    func apply(to image: CIImage, extent: CGRect) -> CIImage {
        image.applyingFilter(filterName, parameters: parameters)
    }
}

具体滤镜只需声明名称和参数:

struct CIBoxBlurBridge: CIFilterFactory {
    let radius: Float
    var filterName: String { "CIBoxBlur" }
    var parameters: [String: Any] { ["inputRadius": radius] }
}

struct CIColorControlsBridge: CIFilterFactory {
    let brightness: Float
    let contrast:   Float
    let saturation: Float
    var filterName: String { "CIColorControls" }
    var parameters: [String: Any] {
        [
            "inputBrightness": brightness,
            "inputContrast":   contrast,
            "inputSaturation": saturation
        ]
    }
}

工厂模式的好处:新增一个 Core Image 滤镜只需 5 行代码,无需关心 CIContext、坐标系转换、渲染时机等底层细节。


七、惰性求值与 GPU 合并渲染

Core Image 的惰性求值在多滤镜链时效果最显著:

// 三个滤镜叠加
let input: CIImage = bitmap.toCIImage()
let step1 = CIBoxBlurBridge(radius: 5).apply(to: input, extent: input.extent)
let step2 = CIColorControlsBridge(brightness: 0.1, contrast: 1.2, saturation: 0.9)
              .apply(to: step1, extent: step1.extent)
let step3 = step2.applyingFilter("CIVignette", parameters: ["inputIntensity": 0.5])

// 上面三步都没有发生任何 GPU 运算
// 只有这一行触发渲染,Core Image 自动把三个滤镜合并成一个 GPU Pass:
let result = CIContextPool.shared.context().createCGImage(step3, from: renderRect)
没有惰性求值(假如每步都立即渲染):
  GPU Pass 1:blur    → 显存写入 12M 像素
  GPU Pass 2color   → 显存读取 + 写入 12M 像素
  GPU Pass 3:vignette→ 显存读取 + 写入 12M 像素
  总显存带宽:72M 像素次读写

使用惰性求值(实际情况):
  GPU Pass 1:blur + color + vignette 合并 → 显存写入 12M 像素
  总显存带宽:12M 像素次读写(节省 83%

八、用 CPUWrapped 在测试中注入 CPU 上下文

单元测试环境(模拟器、CI 服务器)通常没有可用的 Metal GPU。为了不跳过 GPU 路径的测试,MLImageToolkit 使用依赖注入模式:

// 生产代码:从 Pool 获取最优 context
struct GPUGaussianBlur: ImageFilter {
    let radius: Float
    let contextProvider: () -> CIContext   // 依赖注入点

    init(radius: Float, contextProvider: @escaping () -> CIContext = {
        CIContextPool.shared.context()
    }) {
        self.radius = radius
        self.contextProvider = contextProvider
    }

    func apply(to bitmap: MLBitmap) -> MLBitmap {
        let ciImage = bitmap.toCIImage()
        let blurred = ciImage.applyingFilter("CIGaussianBlur",
                                             parameters: ["inputRadius": radius])
        let ctx = contextProvider()   // 测试时注入 CPU context
        return ciImageToBitmap(blurred, context: ctx,
                               width: bitmap.width, height: bitmap.height)
    }
}

// 测试代码:注入 CPU 软件渲染 context
func testGPUGaussianBlur() {
    let cpuCtx = CIContext(options: [.useSoftwareRenderer: true])
    let filter = GPUGaussianBlur(radius: 5) { cpuCtx }
    let result = filter.apply(to: testBitmap)
    XCTAssertEqual(result.width, testBitmap.width)
    // ...
}

依赖注入原则:让调用方提供依赖(CIContext),而不是在内部硬编码获取方式。测试时注入可控的假实现,生产时使用真实的 GPU Context。


九、性能对比

以 4000×3000 图像的高斯模糊(radius=10)为例,在 iPhone 14 上实测:

方案 耗时 备注
Phase 1 CPU 卷积(Swift 循环) ~380ms 纯 Swift,无 SIMD
vImage/Accelerate(SIMD,Day 12) ~28ms CPU SIMD 加速
Core Image(Metal GPU) ~4ms GPU 并行,含首次 kernel 编译
Core Image(复用 Context,非首次) ~3ms 热路径,kernel 已编译

关键结论:GPU 处理比 Phase 1 的 CPU 循环快约 100 倍;复用 CIContext 与每次创建相比节省 18–32ms。


十、小结

概念 核心内容
GPU vs CPU GPU 靠核心数量(1000+)胜出,适合大量独立计算
CIImage 惰性 构建滤镜图不触发计算,render 时才执行,可合并多个 Pass
CIContext 成本 创建耗时 5–30ms,必须复用,用 Pool 管理
CIContextPool Metal 优先,CPU 回退,单例生命周期
坐标系差异 CIImage 原点在左下,y 向上;需要 transform 翻转
render bounds 固定为 (0, 0, w, h),避免 extent 偏移导致黑图
CIFilterBridge 工厂协议统一封装 CIFilter,5 行新增一个滤镜
依赖注入 CPUWrapped context 注入,让 GPU 路径在 CI 中也可测试

思考题

  1. 如果一个 CIFilter 链中某个节点依赖前一步的输出才能确定参数(比如自适应对比度:先扫描直方图,再决定拉伸系数),Core Image 的惰性求值还能把它们合并成一个 GPU Pass 吗?为什么?

  2. CIContextPool 使用 lazy var 保证线程安全吗?如果两个线程同时首次调用 metalContext,会发生什么?应该怎么修复?

  3. 在把 MLBitmap 转成 CIImage 时,代码中施加了一个 scaleX:1, y:-1 的变换。如果把这个变换去掉,改为在 ciImageToBitmap 里用 CGContext.draw 时翻转坐标系,两种方案哪个性能更好?为什么?

答案:1. 不能合并。Core Image 的合并渲染要求滤镜图是纯函数(输入确定输出),若需要先渲染再读回 CPU 数据再决定下一步参数,就必须分两次 render。这种场景需要手动拆成两次 context.createCGImage 调用。2. 不安全。Swift 的 lazy var 在多线程并发首次访问时会有数据竞争。修复方式:用 DispatchQueueNSLock 保护初始化,或者用 static let 替代(Swift 的 static let 保证线程安全的一次性初始化)。3. 在 toCIImage 里用 transform 更好。transform 会被 Core Image 合并进 GPU 着色器,无额外开销。而在 CGContext 里翻转需要在 CPU 端重新绘制一次,是额外的栅格化操作。

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

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

往期推荐:

一图了解Sobel边缘检测原理

一图了解图像处理中的高斯模糊

为什么卷积核通常必须是奇数?

一图了解卷积中的边界处理

一图了解几种常用卷积核

一图了解卷积的核心原理

一张图带你了解——卷积到底是什么?

一图了解饱和度:控制色彩鲜艳程度的关键

一图了解OCR的处理流程及相关图像处理技术

一图了解二值化与阈值,从灰度到黑白的决策

一张图了解图像处理中的亮度、对比度与实现

颜色科学与灰度化

从"图片"到"内存"——你真正理解图像处理的第一天

iPhone相册背后的图像处理知识(下)

iPhone相册背后的图像处理知识(中)

iPhone相册背后的图像处理知识(上)

一张图了解图像处理的本质

图像到底是什么

图像处理技术概要图

AI时代,软件工程师必备概念全景图

【图像处理】框架设计——协议、值类型与工程化思维

作者 FIRE_Lee
2026年5月28日 20:08

同样是实现"灰度化"功能, 一个函数、一个类的方法、一个协议的实现,结果一样,设计完全不同。 这一天我们来聊聊这个框架的设计决策背后的思考, 以及什么样的代码算是"工业级"的。


一、从需求到设计的思维过程

需求:实现灰度化、亮度、对比度、阈值四个图像滤镜,并支持链式调用。

方案 A:函数式

func applyGrayscale(_ bitmap: MLBitmap) -> MLBitmap { ... }
func applyBrightness(_ bitmap: MLBitmap, adjustment: Int) -> MLBitmap { ... }

// 使用:
let result = applyBrightness(applyGrayscale(bitmap), adjustment: 30)
// 问题:嵌套调用难以阅读,顺序从里到外读

方案 B:命令式方法

extension MLBitmap {
    mutating func applyGrayscale() { ... }
    mutating func applyBrightness(adjustment: Int) { ... }
}

// 使用:
bitmap.applyGrayscale()
bitmap.applyBrightness(adjustment: 30)
// 问题:修改原始数据,无法保留中间结果,难以测试

方案 C:协议 + 链式(本框架选择)

protocol ImageFilter {
    func apply(to bitmap: MLBitmap) -> MLBitmap
}

// 使用:
let result = bitmap
    .applying(GrayscaleFilter())
    .applying(BrightnessFilter(adjustment: 30))
// 清晰、可组合、可测试

二、ImageFilter 协议的设计哲学

协议定向编程(POP)

Swift 的核心设计理念之一:用协议而非继承来定义行为

public protocol ImageFilter {
    func apply(to bitmap: MLBitmap) -> MLBitmap
}

这个协议只定义一件事:把一个 bitmap 转换成另一个 bitmap。极度简单,但这种简单性是强大的。

为什么不用继承(class hierarchy)?

// 面向对象风格(不好):
class ImageFilter {
    func apply(to bitmap: MLBitmap) -> MLBitmap {
        fatalError("Subclass must override")
    }
}
class GrayscaleFilter: ImageFilter {
    override func apply(to bitmap: MLBitmap) -> MLBitmap { ... }
}

继承的问题:

  • 强制使用 class(引用类型),增加内存管理复杂度
  • 强耦合:子类依赖父类实现
  • 扩展困难:第三方代码无法"继承扩展"

协议的优势:

  • struct 实现,值类型语义
  • 第三方可以轻松实现自己的 Filter
  • 组合优于继承

纯函数(Pure Function)

func apply(to bitmap: MLBitmap) -> MLBitmap

纯函数的定义

  • 相同输入 → 永远产生相同输出(确定性)
  • 没有副作用(不修改外部状态)

纯函数的好处:

  • 易于测试:无需 mock,直接传入测试数据
  • 易于并行:多个 Filter 可以并行处理不同图像,没有竞争条件
  • 易于组合:输出直接作为下一个的输入

三、值类型(Struct)与 Copy-on-Write

为什么 MLBitmap 是 struct?

public struct MLBitmap {
    public var pixels: [UInt8]
    ...
}

值类型的语义

var bitmap1 = MLBitmap(width: 10, height: 10, filling: .white)
var bitmap2 = bitmap1     // 看起来像复制

bitmap2[0, 0] = .red      // 修改 bitmap2

// bitmap1[0, 0] 仍然是 .white!
// 两者完全独立

如果 MLBitmap 是 class

class MLBitmap {
    var pixels: [UInt8]
}

var bitmap2 = bitmap1     // 实际上是引用复制
bitmap2.pixels[0] = 255  // bitmap1.pixels[0] 也变了!

图像处理中,每个 Filter 应该生成新图像,不影响原图。值类型的语义天然满足这个需求。

Copy-on-Write(写时复制)

Swift 的数组([UInt8])实现了 CoW:

var pixels1 = [UInt8](repeating: 0, count: 1000)
var pixels2 = pixels1    // 此时不复制,只是共享引用

pixels2[0] = 255         // 第一次写入时,才真正复制
// pixels1 不受影响

这使得 var result = bitmap 的操作几乎没有成本——只有当你真正写入 result 时,内存才会复制。

性能影响

// 三次 Filter 链式调用
let result = bitmap
    .applying(GrayscaleFilter())   // 第 1 次 CoW 触发,复制 bitmap
    .applying(BrightnessFilter())  // 第 2 次 CoW 触发,复制中间结果
    .applying(ThresholdFilter())   // 第 3 次 CoW 触发,复制中间结果

// 共有 3 次内存复制
// 对 100×100 图:3 × 40 KB = 120 KB,几乎不可感知
// 对 4K 图:3 × 32 MB = 96 MB,链式调用时峰值内存较高

工业级优化:Fusion(把多个 Filter 的计算合并到一次遍历)。


四、some ImageFilter vs any ImageFilter

// MLBitmap 的链式调用方法
func applying(_ filter: some ImageFilter) -> MLBitmap {
    filter.apply(to: self)
}

some Protocol(Opaque Type)

  • 调用时类型固定,编译器知道具体类型
  • 零运行时开销(不需要 existential box)
  • 适合:每次调用类型确定的场景

any Protocol(Existential Type)

  • 类型在运行时动态决定
  • 有运行时开销(existential box + vtable 查找)
  • 适合:把不同类型的 Filter 放入同一个数组
// 需要存放不同 Filter 的数组时,用 any:
let pipeline: [any ImageFilter] = [
    GrayscaleFilter(),
    BrightnessFilter(adjustment: 30),
    ThresholdFilter()
]
let result = pipeline.reduce(bitmap) { $1.apply(to: $0) }

五、Precondition vs Guard vs Throw:三种防御方式

框架代码中有三种处理错误的方式,选择哪种取决于错误性质

precondition:编程错误(Bug)

// 调用方传了不合法的参数,这是 bug,应该在开发阶段崩溃暴露
precondition(width > 0 && height > 0, "Width and height must be positive")
precondition(factor.isFinite, "factor must be finite")
precondition(values.count % 2 == 1, "Kernel height must be odd")

适用:不变量被违反,是调用方的编程错误。在 Debug 下崩溃(暴露 bug),在 Release 下行为未定义(Swift 优化掉 precondition 检查)。

guard + throw:运行时错误(预期可能发生)

// 图像可能真的很大,这不是 bug,而是正常运行时的条件
guard width <= maxDimension && height <= maxDimension else {
    throw LoadError.dimensionTooLarge(width: width, height: height)
}

适用:外部资源(文件大小、内存限制、网络状态)不可控,调用方需要处理这些情况。

return nil / 默认值:可恢复的退化

// CGDataProvider 创建失败,返回 nil,调用方检查
guard let provider = CGDataProvider(data: data as CFData) else { return nil }

适用:失败是轻量级的,调用方可以通过 optional 判断处理。

选择原则

  • "这种情况不应该发生,发生了说明有 bug" → precondition
  • "这种情况可能发生,调用方必须处理" → throw
  • "这种情况可能发生,调用方可以忽略" → return nil

六、@inline(__always)@discardableResult

@inline(__always)

@inline(__always)
func index(x: Int, y: Int) -> Int {
    (y * width + x) * Self.bytesPerPixel
}

这个函数在像素遍历的内层循环中被调用,100×100 图调用 10,000 次,4K 图调用 800 万次。普通函数调用有开销(压栈/出栈、跳转)。@inline(__always) 让编译器把函数体直接嵌入调用处,消除调用开销。

权衡:内联会增加代码体积(每个调用处都展开一份代码),但对热路径的小函数是合理的。

@discardableResult

@discardableResult
public static func process(_ bitmap: MLBitmap, to url: URL, scenario: ExportScenario) -> ExportResult

Swift 默认情况下,如果你忽略一个有返回值的函数的返回值,编译器会给出警告。@discardableResult 表示"忽略返回值是可以接受的"。

适用场景:返回值提供额外信息(如成功/失败详情),但调用方也可能只关心副作用(文件是否写出),而不在乎详细的返回值。


七、单一可信来源(SSOT)原则

// ❌ 错误:同样的常量在两个地方定义
// ImageLoader.swift
let bitmapInfo = CGImageAlphaInfo.premultipliedLast.rawValue | CGBitmapInfo.byteOrder32Big.rawValue

// ImageExporter.swift  
let bitmapInfo = CGImageAlphaInfo.premultipliedLast.rawValue | CGBitmapInfo.byteOrder32Big.rawValue
// 问题:如果只改了一处,另一处不同步,导致颜色错乱,且没有编译器提示

// ✅ 正确:单一定义,双端引用
// MLBitmap.swift(单一可信来源)
public static let bitmapInfo: CGBitmapInfo = CGBitmapInfo(rawValue:
    CGImageAlphaInfo.premultipliedLast.rawValue |
    CGBitmapInfo.byteOrder32Big.rawValue
)

// ImageLoader.swift
let bitmapInfo = MLBitmap.bitmapInfo.rawValue  // 引用

// ImageExporter.swift
let bitmapInfo = MLBitmap.bitmapInfo            // 引用

SSOT 原则(Single Source of Truth):每个知识(常量、配置、逻辑)只在一个地方定义,其他地方引用。


八、测试驱动的工程化

每一个重要功能都有对应的测试:

testBitmapMemoryLayout()          ← 验证内存布局公式
testCoordinateOriginIsTopLeft()   ← 验证坐标系约定(最容易出错的地方)
testGrayscaleLuminanceFormula()   ← 验证 BT.709 公式精度
testBrightnessClampMax()          ← 验证溢出截断(不是回绕)
testContrastAnchorPoint()         ← 验证 128 锚点不变性
testSobelDetectsVerticalEdge()    ← 验证边缘检测有效性
testAutoFormatTransparentImage()  ← 验证透明度检测
testResampleReducesOversized()    ← 验证等比缩放

测试的价值

  • 文档化了预期行为(代码即文档)
  • 重构时有安全网(改代码不怕破坏已有功能)
  • 发现设计缺陷(如 testCoordinateOriginIsTopLeft 暴露了坐标系 bug)

测试的粒度

好的测试只测一件事

// ❌ 测试太多,失败时不知道哪里出了问题
func testGrayscale() {
    // 测试白色、黑色、亮度公式、Alpha 保护……全放在一起
}

// ✅ 每个测试一个断言
func testGrayscaleWhiteStaysWhite() { ... }
func testGrayscaleBlackStaysBlack() { ... }
func testGrayscaleLuminanceFormula() { ... }
func testGrayscaleAlphaUnchanged() { ... }

九、代码注释的层次

本框架的注释分为三层:

Layer 1:文件头注释(解释"为什么这个文件存在")

// ImageProcessor.swift
// 工业级图像预处理管线
//
// 职责:在导出/上传前,根据场景策略对图像进行:
//   1. 尺寸重采样(Resample)
//   2. 格式选择(Format Selection)
//   3. 质量决策(Quality Decision)

Layer 2:函数注释(解释"这个函数做什么,参数是什么")

/// 将 UIImage 解码为 MLBitmap(RGBA8888 / sRGB)。
///
/// 流程:UIImage → CGImage → CGContext(重新绘制)→ [UInt8]
/// 通过重新绘制确保颜色空间统一(Display P3 / sRGB 均归一化为 sRGB)
///
/// - Throws: `LoadError`(尺寸超限 / 内存超限 / CGImage 缺失)
public static func load(from image: UIImage) throws -> MLBitmap

Layer 3:关键步骤注释(解释"为什么这么做,不是这么做会怎样")

// ⚠️ 不要加 translateBy/scaleBy flip:
// flip 会把 CGImage row 0 翻到 buffer 末尾,
// 反而使 bitmap[0,0] 变成视觉「左下角」。
context.draw(cgImage, in: CGRect(x: 0, y: 0, width: width, height: height))

原则:注释解释"为什么",而不是重复"做什么"(代码本身已经说明做什么了)。


十、阶段一学习完整架构回顾

MLImageCore
│
├── Core/
│   └── MLBitmap.swift          # 核心数据结构(struct + CoW)
│
├── Filters/
│   ├── ImageFilter.swift       # 协议定义(POP)
│   ├── GrayscaleFilter.swift   # BT.709 灰度化
│   ├── BrightnessFilter.swift  # 线性亮度调整
│   ├── ThresholdFilter.swift   # 二值化
│   └── ContrastFilter.swift    # 对比度调整
│
├── Algorithms/
│   ├── Convolution.swift       # 2D 卷积引擎(通用)
│   ├── GaussianBlur.swift      # 可分离高斯模糊
│   └── SobelEdge.swift         # Sobel 边缘检测
│
└── IO/
    ├── ImageLoader.swift       # UIImage → MLBitmap(颜色空间归一化)
    ├── ImageExporter.swift     # MLBitmap → UIImage / 文件(回退链)
    └── ImageProcessor.swift   # 工业级管线(重采样 + 格式决策 + 体积控制)

每一层都遵循单一职责原则(SRP)

  • ImageLoader:只负责加载和格式归一化
  • ImageExporter:只负责编码和写文件
  • ImageProcessor:只负责决策和调度(不操作像素)
  • Convolution:只是纯数学引擎,不知道 Filter 业务

十一、工业级 vs 学习级代码

维度 学习级 工业级
错误处理 强制解包 !,打印错误 结构化 Error,throw/Result
日志 print() os.log,带级别和类别
内存 随意分配,不考虑峰值 预估峰值,设置上限,提前拦截
边界 "应该不会发生" precondition + guard + throw
API 功能正确即可 命名清晰,访问控制合理,文档完善
测试 手动跑一下 单元测试覆盖核心路径
可扩展性 直接改代码 协议 + 预设 + 自定义接口
常量管理 魔法数字散落各处 SSOT,单一定义

工业级代码的核心追求:在 3 个月后,由另一个人来维护这段代码时,他能快速理解、安全修改


十二、小结与展望

Phase 1 建立了:

  • 图像处理的基础数据结构和坐标系约定
  • CPU 层的完整算法实现(灰度、亮度、对比度、二值化、卷积、高斯模糊、Sobel)
  • 工业级的 IO 管线(颜色空间归一化、格式决策、体积控制)
  • 良好的代码架构和测试覆盖

Phase 2 目标:从"手写 CPU 算法"升级到"调用 Apple 系统框架加速":

  • Core Image:GPU 渲染管线,CIFilter 包装
  • vImage / Accelerate:SIMD 向量化,更快的卷积
  • 直方图分析:Otsu 自适应阈值,直方图均衡
  • 颜色空间转换:RGB ↔ HSV ↔ Lab

Phase 3 目标:Metal Compute Shader,真正的 GPU 并行:

  • 数千个像素同时计算
  • 实时滤镜(30fps 视频处理)
  • Custom Compute Kernel(自定义 GPU 程序)

思考题

  1. 如果要把 MLBitmap 从 struct 改为 class,需要修改哪些地方?会带来哪些新的问题?
  2. 设计一个 CompositFilter,它包含一个 [any ImageFilter] 数组,调用 apply 时依次执行所有 Filter。写出这个类型的定义,并说明它是 struct 还是 class,理由是什么?
  3. 在 iOS 开发中,如果你的图像处理需要在后台线程运行(避免主线程阻塞),现有的 ImageFilter 协议设计需要做什么改动?(提示:Swift Concurrency 的 Sendable

答案:2. 应该是 struct(值类型),因为它只是 Filter 序列的组合,没有共享状态;定义:struct CompositFilter: ImageFilter { let filters: [any ImageFilter]; func apply(to bitmap: MLBitmap) -> MLBitmap { filters.reduce(bitmap) { $1.apply(to: $0) } } };3. 需要让所有 Filter 标注 Sendablestruct GrayscaleFilter: ImageFilter, Sendable {}),以及让 MLBitmap 也标注 Sendable,这样才能在不同 actor 之间安全传递。

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

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

往期推荐:

一图了解图像处理中的高斯模糊

为什么卷积核通常必须是奇数?

一图了解卷积中的边界处理

一图了解几种常用卷积核

一图了解卷积的核心原理

一张图带你了解——卷积到底是什么?

一图了解饱和度:控制色彩鲜艳程度的关键

一图了解OCR的处理流程及相关图像处理技术

一图了解二值化与阈值,从灰度到黑白的决策

一张图了解图像处理中的亮度、对比度与实现

颜色科学与灰度化

从"图片"到"内存"——你真正理解图像处理的第一天

iPhone相册背后的图像处理知识(下)

iPhone相册背后的图像处理知识(中)

iPhone相册背后的图像处理知识(上)

一张图了解图像处理的本质

图像到底是什么

图像处理技术概要图

AI时代,软件工程师必备概念全景图

昨天以前iOS

【图像处理】卷积原理与卷积核——图像处理的核心引擎

作者 FIRE_Lee
2026年5月20日 16:33

模糊、锐化、边缘检测、浮雕…… 这些看起来完全不同的效果,底层都是同一个操作:卷积。 理解了卷积,你就掌握了图像处理最核心的工具。


一、从"滑动窗口"理解卷积

想象你拿着一个 3×3 的放大镜,在图像上从左到右、从上到下滑动。每移动到一个位置:

  1. 放大镜覆盖当前像素周围的 3×3 区域(共 9 个像素)
  2. 把这 9 个像素值分别乘以放大镜上对应位置的权重
  3. 把 9 个乘积加起来,得到一个数
  4. 这个数就是输出图像在该位置的像素值

这个"放大镜"就是卷积核(Kernel / Filter),这个操作就是卷积

输入图像(局部)        卷积核(3×3)
┌───┬───┬───┐        ┌───┬───┬───┐
│ ab │ c │        │ k │ l │ m │
├───┼───┼───┤   ⊛    ├───┼───┼───┤
│ d │ e │ f │        │ n │ o │ p │
├───┼───┼───┤        ├───┼───┼───┤
│ g │ h │ i │        │ q │ r │ s │
└───┴───┴───┘        └───┴───┴───┘

输出 = a·k + b·l + c·m
     + d·n + e·o + f·p
     + g·q + h·r + i·s

二、数学定义

离散 2D 卷积

output[x, y] = Σ  Σ  input[x + i, y + j] · kernel[i, j]
               i=-r j=-r

其中 r 是核的半径(3×3 核的 r = 1),求和范围是核的所有位置。

注意:严格的数学卷积(Convolution)需要把核翻转 180°,而图像处理中实际使用的是相关(Cross-correlation)

卷积(Convolution):     kernel 翻转后再滑动
相关(Cross-correlation):kernel 直接滑动(不翻转)

对于对称核(如高斯核),两者结果相同,所以在图像处理领域,大家习惯性地把相关操作也叫做"卷积"。本框架遵循这个惯例。


三、常见卷积核的原理

均值模糊核(Box Filter)

           ┌ 1/9  1/9  1/9 ┐
blur3×3 = │ 1/9  1/9  1/9 │
           └ 1/9  1/9  1/9 ┘
  • 权重全部相等,每个像素值是周围 9 个像素的平均值
  • 效果:模糊(每个像素被邻居"平均"掉了,细节丢失)
  • 权重之和 = 1:保证亮度不变(不会整体变亮或变暗)

关键:如果权重之和 ≠ 1,输出亮度会变化:

  • 权重和 > 1 → 整体变亮
  • 权重和 < 1 → 整体变暗
  • 权重和 = 0 → 只看差异(Sobel 就是这样)

锐化核(Sharpen)

             ┌  0  -1   0 ┐
sharpen3×3 = │ -1   5  -1 │
             └  0  -1   0 ┘

原理

中心权重 5 = 1 + 4
周边权重 -1(共 4 个)

等价于:原图 × 1 + 拉普拉斯算子 × 1
      = 原图 + (原图中心 - 周围平均) × 4

中心像素与周围的差异被放大了,边缘对比更强,看起来更清晰。

权重之和 = 0 + (-1) + 0 + (-1) + 5 + (-1) + 0 + (-1) + 0 = 1,亮度不变。

浮雕核(Emboss)

           ┌ -2  -1   0 ┐
emboss3×3 = │ -1   1   1 │
             └  0   1   2 ┘

原理:模拟从左上角打来的平行光。

  • 左上方权重为负(被"遮挡"的一侧变暗)
  • 右下方权重为正(受光一侧变亮)
  • 结果:图像呈现出立体浮雕感

权重之和 = 1,亮度基本不变。

Sobel 核(边缘检测,见 Day 8)

      ┌ -1   0   1 ┐          ┌ -1  -2  -1 ┐
Gx = │ -2   0   2 │    Gy = │  0   0   0 │
      └ -1   0   1 ┘          └  1   2   1 ┘

权重之和 = 0,纯色区域(无梯度)输出为 0,只有像素值变化的地方有非零输出。


四、边界问题及处理策略

当卷积核处于图像边缘时,核的部分位置会超出图像范围(越界)。

图像:5×5,核:3×3,核半径 = 1
当 x=0, y=0 时:
核需要访问 (-1,-1), (0,-1), (1,-1), (-1,0), (0,0)...
其中 (-1,-1) 等坐标超出图像范围!

三种常见策略

策略 做法 特点
Zero Padding(补零) 越界位置当作 0 边缘会变暗,适合某些场景
Clamp(复制边缘) 越界时取最近有效像素 本框架使用,简单且不引入黑边
Mirror(镜像) 越界时取镜像位置的像素 更自然,对称图像效果好

Clamp 实现

for ky in 0..<kernel.height {
    for kx in 0..<kernel.width {
        // clamp:超出边界时取最近的有效坐标
        let px = min(max(x + kx - kernel.radiusX, 0), w - 1)
        let py = min(max(y + ky - kernel.radiusY, 0), h - 1)

        let idx = bitmap.index(x: px, y: py)
        sumR += Float(bitmap.pixels[idx]) * kernel.values[ky][kx]
        ...
    }
}

五、卷积核必须是奇数尺寸

为什么?

核半径 r = size / 2(整数除法)。

  • 3×3 核:r = 1,中心在 (1, 1),覆盖中心像素 ✅
  • 5×5 核:r = 2,中心在 (2, 2)
  • 4×4 核:r = 2,但中心实际在 (1.5, 1.5)(非整数),无法对齐像素格子!

偶数尺寸核会导致输出图像产生亚像素偏移,在实践中几乎从不使用。

// 本框架在 ConvolutionKernel init 中强制检查
precondition(values.count % 2 == 1, "Kernel height must be odd")
precondition(w % 2 == 1, "Kernel width must be odd")

六、卷积的完整实现

public static func apply(
    _ bitmap: MLBitmap,
    kernel: ConvolutionKernel,
    scale: Float = 1.0,
    bias:  Float = 0.0
) -> MLBitmap {

    let w = bitmap.width
    let h = bitmap.height
    var result = bitmap  // CoW:写入时自动分离

    for y in 0..<h {
        for x in 0..<w {
            var sumR: Float = 0
            var sumG: Float = 0
            var sumB: Float = 0

            for ky in 0..<kernel.height {
                for kx in 0..<kernel.width {
                    let px = min(max(x + kx - kernel.radiusX, 0), w - 1)
                    let py = min(max(y + ky - kernel.radiusY, 0), h - 1)

                    let weight = kernel.values[ky][kx]
                    let idx    = bitmap.index(x: px, y: py)

                    // ⚠️ 始终读 bitmap(原始)而不是 result(输出)
                    // 避免 in-place 卷积的经典 bug:
                    // 如果读 result,则当前像素的计算依赖已被修改的邻域
                    sumR += Float(bitmap.pixels[idx])     * weight
                    sumG += Float(bitmap.pixels[idx + 1]) * weight
                    sumB += Float(bitmap.pixels[idx + 2]) * weight
                }
            }

            let i = result.index(x: x, y: y)
            result.pixels[i]     = UInt8(clamping: Int((sumR * scale + bias).rounded()))
            result.pixels[i + 1] = UInt8(clamping: Int((sumG * scale + bias).rounded()))
            result.pixels[i + 2] = UInt8(clamping: Int((sumB * scale + bias).rounded()))
        }
    }

    return result
}

scalebias 参数的用途

某些操作需要在卷积后做额外的缩放和偏移:

  • Sobel 可视化:梯度值范围是 -255~+255,无法直接存为 UInt8。通过 bias = 128,把 [-255, 255] 映射到 [-127, 383],再 clamp 到 [0, 255],负梯度显示为暗色,正梯度显示为亮色:
Convolution.apply(bitmap, kernel: .sobelX, bias: 128.0)
  • Sobel 计算中间值:用 applyGrayscaleRaw 获取未截断的 Float,保留符号信息,外部做绝对值合并:
// 不截断,用于外部 |Gx| + |Gy| 计算
public static func applyGrayscaleRaw(_ bitmap: MLBitmap, kernel: ConvolutionKernel) -> [Float]

七、计算复杂度

每个像素:需要 kW × kH 次乘加运算
全图:W × H × kW × kH 次运算

3×3 核,100×100 图:100×100×9 = 90,000 次
3×3 核,4K 图(3840×2160):3840×2160×9 ≈ 75,000,000 次

每次运算包含:1 次读像素、1 次乘法、1 次加法
CPU 主频 3 GHz,每秒约 30 亿次简单操作
→ 4K 图单次卷积:约 25ms(纯 CPU,无优化)

对实时视频(30 fps):每帧只有 33ms,单次 3×3 卷积就快耗尽 budget。

这就是为什么后续的需要:

  • vImage(SIMD 向量加速,一次处理 16 字节)
  • Metal Compute Shader(GPU 并行,数千个像素同时计算)
  • 高斯核的可分离性

八、applyGrayscaleRaw 的设计价值

标准 apply 把结果截断到 [0, 255] 存为 UInt8。但有些算法需要负值或超出 255 的中间结果:

  • SobelGxGy 的值范围是 [-1020, 1020](255 × 最大权重 4)
  • 拉普拉斯:类似,需要负值
  • 如果截断,这些信息会丢失,后续的 |Gx| + |Gy| 合并会出错
// 错误流程(截断后合并):
let gx = Convolution.apply(gray, kernel: .sobelX)  // 截断到 [0,255]
// 所有负梯度变成了 0,正梯度被截断,信息严重丢失

// 正确流程(保留浮点后合并):
let gxRaw = Convolution.applyGrayscaleRaw(gray, kernel: .sobelX)  // Float 数组
let gyRaw = Convolution.applyGrayscaleRaw(gray, kernel: .sobelY)  // Float 数组
let magnitude = abs(gxRaw[i]) + abs(gyRaw[i])  // 利用符号信息

九、小结

概念 核心内容
卷积本质 用权重矩阵对每个像素的邻域做加权求和
核的设计 权重和=1(亮度不变),=0(检测变化),>1(增强)
边界处理 Clamp(复制边缘),实现简单,无黑边
奇数尺寸 偶数核无法对齐像素中心,必须为奇数
读写分离 永远读原始 bitmap,写独立 result,避免 in-place bug
计算复杂度 O(W×H×kW×kH),大核 + 大图需要 GPU 加速

思考题

  1. 设计一个 3×3 卷积核,使得输出图像是原图的水平方向平均(每个像素 = 同行左中右三个像素的平均),但垂直方向不变。
  2. 一个 5×5 核可以等效为两次 3×3 卷积吗?在什么条件下等效?
  3. 如果要实现"运动模糊"(Motion Blur,模拟相机运动时的拖影效果),卷积核应该长什么样?

答案:1. [[0,0,0],[1/3,1/3,1/3],[0,0,0]] — 中间行权重相等,其他行全零;2. 不是所有 5×5 核都能分解为两个 3×3,只有可分离核(rank-1 矩阵)才能;3. 水平运动模糊:[[0,0,0,0,0],[1/5,1/5,1/5,1/5,1/5],[0,0,0,0,0]],一行中 5 个相等权重,模拟水平拖影。

如果这篇对你有一点启发:点个赞,让更多人少踩一个坑 转发给那个正在纠结的人也欢迎关注我—— 我们一起,把认知变成长期复利。

往期推荐:

一张图带你了解——卷积到底是什么?

一图了解饱和度:控制色彩鲜艳程度的关键

一图了解OCR的处理流程及相关图像处理技术

一图了解二值化与阈值,从灰度到黑白的决策

一张图了解图像处理中的亮度、对比度与实现

颜色科学与灰度化

从"图片"到"内存"——你真正理解图像处理的第一天

iPhone相册背后的图像处理知识(下)

iPhone相册背后的图像处理知识(中)

iPhone相册背后的图像处理知识(上)

一张图了解图像处理的本质

图像到底是什么

图像处理技术概要图

AI时代,软件工程师必备概念全景图

【图像处理】饱和度——颜色的浓淡与灰度化

作者 FIRE_Lee
2026年5月18日 16:32

饱和度为 0,图像变成灰色。 饱和度为 1,颜色恢复原样。 看似简单的一个滑块,背后是颜色空间的混合运算—— 而"直接灰度化"并不总是最好的选择。


一、饱和度的直觉

在 HSB(或 HSL)色彩模型里,颜色由三个维度描述:

H(Hue,色相)       → 颜色的种类(红/绿/蓝/黄…)
S(Saturation,饱和度)→ 颜色的浓淡(鲜艳 ↔ 灰白)
B(Brightness,亮度) → 颜色的明暗(亮 ↔ 暗)

调整饱和度,就是在"原始颜色"和"同亮度的灰色"之间做插值:

饱和度 = 1.0:鲜艳的红色  (255, 0, 0)
饱和度 = 0.5:粉灰色      (191, 128, 128)  ← 向灰色靠近 50%
饱和度 = 0.0:纯灰色      (128, 128, 128)  ← 完全变成灰色

二、数学:灰度化 + 插值

CIColorControlsinputSaturation 本质上是两步:

Step 1:计算该像素对应的灰度值

gray = 0.299 × R + 0.587 × G + 0.114 × B

这是 Rec.601 亮度公式(Day 3 详细讲过),权重来自人眼对红绿蓝的感知灵敏度差异。

Step 2:在原色和灰度之间线性插值

R' = gray × (1 - s) + R × s
G' = gray × (1 - s) + G × s
B' = gray × (1 - s) + B × s

其中 s = inputSaturation(0.0 ~ 1.0)

验证:

  • s = 1.0:输出 = 原始 RGB(完全保留颜色)
  • s = 0.0:输出 = (gray, gray, gray)(完全灰度化)
  • s = 0.5:输出 = 原始和灰度各占一半

具体计算示例

鲜红色 (255, 0, 0),计算 s = 0.3

gray = 0.299 × 255 + 0.587 × 0 + 0.114 × 0 = 76.276

R' = 76 × 0.7 + 255 × 0.3 = 53.2 + 76.5 = 129.7 ≈ 130
G' = 76 × 0.7 +   0 × 0.3 = 53.2 +  0.0 =  53.253
B' = 76 × 0.7 +   0 × 0.3 = 53.2 +  0.0 =  53.2 ≈  53

s=0.3 后的颜色:(130, 53, 53) ← 带点红色调的暗灰

三、完全灰度化(s=0.0)并不总是最好的

初看之下,OCR 场景应该直接灰度化:数字本身是无色的,颜色只是干扰。

但实测有两个副作用:

3.1 Vision 丢失颜色线索

Vision OCR 是多模态模型,训练时见过大量彩色图像。颜色是它区分相似形状的辅助信号之一:

例:深蓝背景上的白色 LOGO 文字
  彩色图:深蓝(30, 80, 180) vs 白色(255, 255, 255) → 颜色对比强,Vision 轻松分割
  灰度图:亮度 ≈ 0.25 vs 亮度 ≈ 1.0 → 亮度对比也够,没问题

例:金色数字在米白色背景上
  彩色图:金色(255, 215, 0) vs 米白(255, 250, 220) → 颜色略有差异,Vision 可感知
  灰度图:金色亮度 ≈ 0.81 vs 米白亮度 ≈ 0.97 → 亮度差仅 0.16,对比度很弱!

结论:当颜色对比强于亮度对比时,降饱和会损失有效信息。

3.2 特定颜色组合在灰度下消失

问题场景:橙色数字在青色背景上

橙色  RGB(255, 140, 0):
  gray = 0.299×255 + 0.587×140 + 0.114×0 = 76.2 + 82.2 = 158

青色  RGB(0, 200, 200):
  gray = 0.299×0 + 0.587×200 + 0.114×200 = 0 + 117.4 + 22.8 = 140

灰度对比度 = |158 - 140| / 255 ≈ 7%   ← 几乎看不见!

但彩色对比度(色相完全不同):非常鲜明

这就是为什么银行卡预处理不能无脑灰度化:不同颜色组合需要不同策略。


四、各场景的 saturation 策略

s = 1.0   完全保留颜色
  适用:彩色背景对 OCR 有益,或颜色是区分字符的关键线索
  例:深色底变体(不降饱和,颜色通道提供额外边缘信息)

s = 0.7   轻度降饱和
  适用:颜色有一定干扰,但不希望完全失去颜色线索
  例:全卡自适应预处理的基线值(正常/低对比度场景)

s = 0.3   保留少量颜色
  适用:背景颜色复杂,但担心某些卡面的颜色对比消失
  例:ROI 浅底提亮灰变体(在大 radius USM 之后,颜色已大幅弱化,保留 30% 兜底)

s = 0.0   完全灰度化
  适用:已确认亮度对比足够强,颜色只是噪声
  例:ROI 背景减除变体(大 radius USM 已经消除低频背景,颜色无意义)
       ROI 超对比灰变体(高 contrast 专门压暗处理,颜色干扰大于收益)

五、saturation 与 contrast 的联动

降低饱和度会改变有效亮度对比度,两者需要协调。

5.1 降饱和降低了可用对比度

原始颜色对比:
  橙色 (255,140,0)  亮度 0.62
  青色 (0,200,200)  亮度 0.55
  颜色对比很明显,亮度对比只有 0.07

s=0.0 灰度化后:
  橙色 灰度 0.62
  青色 灰度 0.55
  仅靠亮度,对比度只有 7%,需要 contrast 补偿
  
  contrast 需要多高?理论上需要 1/0.07 ≈ 14 才能将对比拉至全范围
  实际上 contrast 最多用到 2.0,因此这种组合用灰度化会失败

5.2 contrast 依赖饱和度处理后的亮度分布

推荐处理顺序:
  CIColorControls(saturation → contrast → brightness)

这三个参数在同一个 CIFilter 里,同时生效,内部顺序由 Core Image 决定。
实际等效于:先降饱和(得到亮度均等的图),再拉伸亮度分布。

5.3 选择 saturation 值的决策树

开始
  │
  ├─ 背景是否为复杂图案(风景/纹理/渐变)?
  │   ├─ 是 → 用 CIUnsharpMask 大 radius(25px)消背景 → s=0.0 灰度化
  │   └─ 否 ↓
  │
  ├─ 数字颜色是否与背景亮度接近(亮度差 < 0.2)?
  │   ├─ 是 → 颜色对比是主要线索 → s=0.7~1.0,同时提高 contrast
  │   └─ 否 ↓
  │
  ├─ 背景颜色是否鲜艳(高饱和)?
  │   ├─ 是 → 颜色干扰 Vision → s=0.0~0.3 降饱和
  │   └─ 否 ↓
  │
  └─ 默认:s=0.7(保留颜色线索,轻度降低颜色干扰)

六、Swift 实现

手动实现(适合 MLBitmap)

public struct SaturationFilter: ImageFilter {

    /// 饱和度系数,0.0 = 完全灰度化,1.0 = 原色
    public let saturation: Float

    public func apply(to bitmap: MLBitmap) -> MLBitmap {
        var result = bitmap
        for i in stride(from: 0, to: result.pixels.count, by: 4) {
            let r = Float(result.pixels[i])
            let g = Float(result.pixels[i + 1])
            let b = Float(result.pixels[i + 2])
            // Rec.601 亮度
            let gray = 0.299 * r + 0.587 * g + 0.114 * b
            // 在原色和灰度之间插值
            result.pixels[i]     = UInt8(clamping: Int(gray * (1 - saturation) + r * saturation))
            result.pixels[i + 1] = UInt8(clamping: Int(gray * (1 - saturation) + g * saturation))
            result.pixels[i + 2] = UInt8(clamping: Int(gray * (1 - saturation) + b * saturation))
            // i + 3 = Alpha,不变
        }
        return result
    }
}

通过 CIColorControls(适合 CIImage 管线)

func adjustSaturation(_ cgImage: CGImage, saturation: Float) -> CGImage? {
    let input = CIImage(cgImage: cgImage)
    guard let filter = CIFilter(name: "CIColorControls") else { return nil }
    filter.setValue(input,      forKey: kCIInputImageKey)
    filter.setValue(saturation, forKey: kCIInputSaturationKey)
    // contrast / brightness 不传时保持默认(1.0 / 0.0)
    return filter.outputImage.flatMap { context.createCGImage($0, from: $0.extent) }
}

两种方式等效,CIColorControls 在 GPU 上运行,大图时更快。


七、"降饱和"不等于"灰度化"的证明

之前讲的灰度化:

gray = 0.299R + 0.587G + 0.114B
输出像素 = (gray, gray, gray)

CIColorControls(saturation=0.0) 的结果:

R' = gray × 1 + R × 0 = gray
G' = gray × 1 + G × 0 = gray
B' = gray × 1 + B × 0 = gray
输出像素 = (gray, gray, gray)

两者数学上完全等价,都是 Rec.601 加权灰度化。区别在于:

  • saturation=0.0 是完整 CIColorControls 管线的一个参数,可以同时调整 contrast/brightness
  • 手动灰度化(Day 3 的方法)是独立的 CPU 操作,更灵活但更慢

八、小结

saturation(s)的本质:
  s=1.0 → 原色
  s=0.0 → Rec.601 加权灰度
  中间值 → 两者线性插值

何时降饱和:
  ✅ 背景颜色是噪声(复杂图案、鲜艳彩色背景)
  ✅ 已用 CIUnsharpMask 消除背景,颜色不再有意义
  ✅ 处于"超对比"模式,需要纯亮度信息

何时保留颜色(s=0.5~1.0):
  ✅ 数字颜色与背景亮度接近(颜色对比 > 亮度对比)
  ✅ Vision 需要颜色线索区分相似字形
  ✅ 保底措施:不确定时先用 s=0.7,再根据识别结果调整

如果这篇对你有一点启发:点个赞,让更多人少踩一个坑 转发给那个正在纠结的人也欢迎关注我—— 我们一起,把认知变成长期复利。

往期推荐:

一图了解饱和度:控制色彩鲜艳程度的关键

一图了解OCR的处理流程及相关图像处理技术

一图了解二值化与阈值,从灰度到黑白的决策

一张图了解图像处理中的亮度、对比度与实现

颜色科学与灰度化

从"图片"到"内存"——你真正理解图像处理的第一天

iPhone相册背后的图像处理知识(下)

iPhone相册背后的图像处理知识(中)

iPhone相册背后的图像处理知识(上)

一张图了解图像处理的本质

图像到底是什么

图像处理技术概要图

AI时代,软件工程师必备概念全景图

❌
❌