【图像处理】亮度与对比度——图像的线性变换

亮度和对比度是最基础的图像调整。 看似简单,背后涉及线性变换、锚点、数值溢出...... 把这两个搞透彻,整个图像变换的思维框架就建立起来了。


一、图像变换的本质:函数映射

所有图像调整,本质上都是一个像素值的映射函数:

scss 复制代码
输出像素值 = f(输入像素值)

最简单的映射:线性变换

ini 复制代码
output = input × slope + offset

亮度调整和对比度调整,都是这个公式的特例。

映射的约束

像素值的范围是 [0, 255],输出也必须在这个范围内。 超出范围的值需要做 clamp(截断):

lua 复制代码
output = max(0, min(255, f(input)))

Swift 中用 UInt8(clamping: Int(...)) 实现,它会自动处理上下溢出:

swift 复制代码
UInt8(clamping: 300) // → 255
UInt8(clamping: -50) // → 0

二、亮度调整(Brightness)

公式

css 复制代码
I' = clamp(I + b, 0, 255)

I  = 原始像素值(0~255)
b  = 亮度调整量(正值变亮,负值变暗)
I' = 调整后的像素值

直觉理解

亮度调整 = 整体平移。把所有像素的亮度值向上或向下移动 b 个单位:

ini 复制代码
原始:[50, 100, 150, 200, 250]
+50:[100, 150, 200, 250, 255]  ← 250+50=300 被截断为 255
-50:[0,   50, 100, 150, 200]

图形表示:

css 复制代码
I'
255 │          ╔══════
    │        ╔╝
    │      ╔╝     ← +b(上移,高亮端截断)
    │    ╔╝
  0 └──╝──────────── I
    0              255

实现

swift 复制代码
public struct BrightnessFilter: ImageFilter {

    /// 亮度调整量,范围建议 -255 ~ +255
    public let adjustment: Int

    public func apply(to bitmap: MLBitmap) -> MLBitmap {
        var result = bitmap
        for i in stride(from: 0, to: result.pixels.count, by: 4) {
            result.pixels[i]     = UInt8(clamping: Int(result.pixels[i])     + adjustment)
            result.pixels[i + 1] = UInt8(clamping: Int(result.pixels[i + 1]) + adjustment)
            result.pixels[i + 2] = UInt8(clamping: Int(result.pixels[i + 2]) + adjustment)
            // i + 3 = Alpha,不变
        }
        return result
    }
}

为什么 adjustment 用 Int 而不是 UInt8?

因为 adjustment 可以是负数(变暗),而 UInt8 是无符号整数,无法表示负值。中间运算使用 Int 避免溢出,最后再截断到 [0, 255]。

溢出测试

swift 复制代码
func testBrightnessClampMax() {
    let bmp = MLBitmap(width: 1, height: 1, filling: .white)  // R=255
    let result = BrightnessFilter(adjustment: 100).apply(to: bmp)
    // 255 + 100 = 355,截断为 255(不是 355 % 256 = 99!)
    XCTAssertEqual(result[0, 0].r, 255)
}

UInt8 的默认溢出行为是回绕(wrapping),而不是截断:

  • UInt8(255) + 100 在 Swift 中会触发运行时崩溃(Debug 模式)
  • UInt8(clamping: 355) = 255(我们想要的)
  • (UInt8(255) &+ 100) = 99(回绕,不是我们想要的)

三、对比度调整(Contrast)

公式

css 复制代码
I' = clamp((I - 128) × c + 128, 0, 255)

I   = 原始像素值
c   = 对比度系数(factor)
128 = 锚点(中间灰)
I'  = 调整后的像素值

锚点的意义

为什么是 128?

对比度的本质是拉大或压缩亮暗之间的距离。要拉伸或压缩,需要一个固定不动的"支点"。

选 128(即 255/2,中间灰)作为锚点,是因为:

  • 128 是 0~255 范围的中点
  • 以 128 为锚点拉伸,亮区和暗区对称地变化
  • 任何情况下,值为 128 的像素永远不变 ((128-128)×c+128 = 128)

系数 c 的效果

css 复制代码
c = 1.0  → I' = (I - 128)×1 + 128 = I         原图不变
c = 2.0  → 拉伸距离 128 的距离(高对比)
c = 0.5  → 压缩距离 128 的距离(低对比)
c = 0.0  → I' = 0 + 128 = 128                  全图变灰
c = -1.0 → I' = -(I - 128) + 128 = 256 - I    色调反转

图形表示(y 轴 = 输出,x 轴 = 输入):

ini 复制代码
I'
255 │    ╱ c=2
    │   ╱
128 │──╱──────── c=1 (对角线)
    │ ╱
  0 └────────── I
    0    128   255

c > 1 时斜率更陡,亮的像素更亮、暗的像素更暗,对比度增强。

实现

swift 复制代码
public struct ContrastFilter: ImageFilter {

    public let factor: Float

    public init(factor: Float) {
        precondition(factor.isFinite, "factor must be finite, got \(factor)")
        self.factor = factor
    }

    public func apply(to bitmap: MLBitmap) -> MLBitmap {
        var result = bitmap
        let c = factor
        for i in stride(from: 0, to: result.pixels.count, by: 4) {
            result.pixels[i]     = adjust(result.pixels[i],     c)
            result.pixels[i + 1] = adjust(result.pixels[i + 1], c)
            result.pixels[i + 2] = adjust(result.pixels[i + 2], c)
        }
        return result
    }

    @inline(__always)
    private func adjust(_ value: UInt8, _ c: Float) -> UInt8 {
        let v = (Float(value) - 128.0) * c + 128.0
        return UInt8(clamping: Int(v.rounded()))
    }
}

为什么要加 precondition(factor.isFinite)?

如果 factor = Float.nan,则 (v - 128) × nan = nan,Int(nan.rounded()) 会触发运行时 trap(程序崩溃)。在 init 时拦截,能给调用方清晰的错误信息,而不是莫名其妙的崩溃。


四、亮度 vs 对比度的本质区别

维度 亮度(Brightness) 对比度(Contrast)
操作 加法(平移) 乘法(缩放)
公式 I' = I + b I' = (I - 128) × c + 128
视觉效果 整体变亮/暗 拉大/压缩亮暗差距
固定点 无(整体平移) 128(锚点不变)
极端值 b = ±255 → 全黑或全白 c = 0 → 全灰(128)

直觉类比:

  • 亮度 = 音量(整体声音变大/小,各频率等比变化)
  • 对比度 = 均衡器(强调高频和低频的差距,中频不变)

五、合并成通用线性变换

亮度和对比度可以合并为一个通用公式:

css 复制代码
I' = I × a + b

其中:

  • 纯亮度调整:a = 1, b = adjustment
  • 纯对比度调整(展开化简):a = c, b = 128 × (1 - c)
swift 复制代码
// 对比度展开:
I' = (I - 128) × c + 128
   = I × c - 128c + 128
   = I × c + 128(1 - c)

所以两个调整可以合并成一次遍历:

swift 复制代码
// 先对比度(c),再亮度(b)
// 等效于一次线性变换:a = c, offset = 128(1-c) + b
let a = contrastFactor
let offset = 128.0 * (1.0 - contrastFactor) + Float(brightnessAdjustment)
let v = Float(pixel) * a + offset

这在 Phase 2(vImage / Core Image)的 CIColorControls 中有直接对应。


六、滤镜链式调用的设计哲学

回到框架设计层面。为什么这些滤镜都遵循 ImageFilter 协议,而不是直接在 MLBitmap 上添加方法?

ImageFilter 协议

swift 复制代码
public protocol ImageFilter {
    func apply(to bitmap: MLBitmap) -> MLBitmap
}

这个协议的特点:

  • 纯函数:不修改输入,返回新的 bitmap(函数式编程思想)
  • 值类型:每个 Filter 都是 struct,没有副作用
  • 可组合:多个 Filter 可以串联

链式调用

swift 复制代码
let result = bitmap
    .applying(GrayscaleFilter())
    .applying(BrightnessFilter(adjustment: -30))
    .applying(ThresholdFilter(threshold: 100))

每一步的输出是下一步的输入,形成处理管线(Pipeline)。

这比命令式写法更清晰:

swift 复制代码
// 命令式(不推荐)
let step1 = GrayscaleFilter().apply(to: bitmap)
let step2 = BrightnessFilter(adjustment: -30).apply(to: step1)
let result = ThresholdFilter(threshold: 100).apply(to: step2)

为什么不直接改 bitmap?

swift 复制代码
// 如果 Filter 是 mutating 的(修改原图)
bitmap.applyGrayscale()         // 原图被改了
bitmap.applyBrightness(-30)     // 继续修改

问题:

  1. 无法保留原始图像用于比较
  2. 无法并行处理多种效果
  3. 测试时难以隔离每个步骤的输出

"不可变 + 函数式变换"是更安全、更可测试的设计。


七、性能考量

每次 apply 都会复制一份 bitmap(CoW 语义)。对于大图:

  • 100×100 图:40 KB,复制几乎不可感知
  • 4K 图:~32 MB,多次链式调用可能达到几百 MB

工业级的处理方式:

  1. Fusion(融合):把多个 Filter 的计算合并到一次像素遍历(一次读写 = 更少的内存操作)
  2. Metal Compute:GPU 并行处理,所有像素同时计算(Phase 3)
  3. vImage:Apple 的向量加速框架,利用 SIMD 一次处理多个像素(Phase 2)

八、小结

概念 核心公式 特点
亮度 I' = clamp(I + b, 0, 255) 平移,无固定点
对比度 I' = clamp((I-128)×c+128, 0, 255) 缩放,锚点 128
线性变换通式 I' = I × a + b 两者的统一形式
溢出处理 UInt8(clamping:) 截断而非回绕
API 设计 ImageFilter 协议 + 链式调用 函数式、可组合

思考题

  1. 如果先调整对比度(c=2),再调整亮度(+50),和先调亮度再调对比度,结果一样吗?为什么?
  2. 如何实现"色调(Hue)旋转"------让红色变成绿色,绿色变成蓝色,蓝色变成红色?(提示:RGB → HSV → 旋转 H → HSV → RGB)
  3. factor = -1 的对比度调整等价于什么经典的图像操作?写出来看看。

上一期参考答案:1. BT.709 = 0.0722×255 ≈ 18,平均值 = 85。蓝色确实比较暗,BT.709 更符合人眼感知(蓝色视锥最少);2. 需要把 bytesPerPixel 改为 1,整个框架都要适配,这是 Phase 2 vImage 可以处理的格式(vImage 支持 Planar8 单通道格式);3. YCbCr Y = 0.2126R + 0.7152G + 0.0722B(BT.709),与我们的灰度公式完全相同------灰度化本质上就是提取亮度分量。

如果这篇对你有一点启发:

点个赞,让更多人少踩一个坑

转发给那个正在纠结的人

也欢迎关注我------

我们一起,把认知变成长期复利。

往期推荐:

一张图了解图像处理中的亮度、对比度与实现 颜色科学与灰度化 从"图片"到"内存"------你真正理解图像处理的第一天
iPhone相册背后的图像处理知识(下)
iPhone相册背后的图像处理知识(中)
iPhone相册背后的图像处理知识(上)
一张图了解图像处理的本质
图像到底是什么
图像处理技术概要图
AI时代,软件工程师必备概念全景图

相关推荐
沧沧凉凉5 小时前
一段需求、零素材,Claude Code 派了 33 个代理给我做了个 3D 动作游戏
游戏·ai编程·three.js
小虎AI生活6 小时前
315 之后 AI 改规矩了,怎么让豆包和 DeepSeek 认得出你
aigc·ai编程
郑州光合科技余经理7 小时前
海外版外卖加盟:总站与分站配送规则怎么分开管
java·开发语言·前端·后端·uni-app·php·ai编程
熊猫钓鱼>_>11 小时前
Kotlin Multiplatform for OpenHarmony 实战:为 Reaktive 实现响应式原语适配(完整版 · 含摘要目录与技术图表)
华为·kotlin·大模型·ai编程·harmonyos·适配·reaktive
姜楠聊AI转型12 小时前
AI 编程安全合规怎么落地?三大风险 + 四道防线(数据脱敏 / 漏洞左移 / 审计 / 权限路由)
ai编程·vibe coding·ai coding·coding agent
茶底世界之下14 小时前
Metal 冷启动并发下,Pipeline Cache 为什么必须合并在途编译
ios·swift
熊猫钓鱼>_>15 小时前
Kotlin Multiplatform for OpenHarmony 实战:为 MVIKotlin 实现单向数据流适配
华为·ai编程·鸿蒙·openharmony·适配·数据流·mvkotlin
Alice-YUE15 小时前
前端 × AI:从 Cursor 到 Transformer,一份完整认知路径
前端·人工智能·transformer·ai编程·前端开发·cursor
weixin_4038101315 小时前
用 Python 调 EasyClick iOS 免越狱脚本:开放接口对接步骤与参数
ios·iphone