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

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


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

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

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 = nanInt(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时代,软件工程师必备概念全景图

相关推荐
無名路人1 小时前
uniApp 小程序 vue3 app.vue静默登录其他页面等待登录完成方式二
前端·微信小程序·ai编程
该用户已不存在1 小时前
用 Claude Code Agents 与 CI/CD 搭建自动化研发团队(Part 3)
后端·ai编程·claude
CoCo的编程之路1 小时前
2026 前端效能飞跃:深度解析智能助手的页面构建最大化方案
前端·人工智能·ai编程·智能编程助手·文心快码baiducomate
bryceZh2 小时前
iOS26适配-UISplitViewController配置分栏和分屏
ios·ui kit
songgeb2 小时前
NumberFormatter 货币格式化属性详解
ios·swift
小虎AI生活3 小时前
WorkBuddy+PPT Master组合,AI-PPT 的效率革命
ai编程·codebuddy
花千树-0103 小时前
Proposer-Critic 多轮辩论:两个 LLM Agent 用 loop() 逼近共识
langchain·agent·ai编程·skill·multi-agent·claude code·ai 工程化
可夫小子3 小时前
不用再手动改配置文件了,3 步让 Claude Code 接入 DeepSeek
ai编程
爱吃的小肥羊3 小时前
Claude code额度限时提高50%,但Claude 又改计费模式了
aigc·ai编程