亮度和对比度是最基础的图像调整。 看似简单,背后涉及线性变换、锚点、数值溢出...... 把这两个搞透彻,整个图像变换的思维框架就建立起来了。
一、图像变换的本质:函数映射
所有图像调整,本质上都是一个像素值的映射函数:
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) // 继续修改
问题:
- 无法保留原始图像用于比较
- 无法并行处理多种效果
- 测试时难以隔离每个步骤的输出
"不可变 + 函数式变换"是更安全、更可测试的设计。
七、性能考量
每次 apply 都会复制一份 bitmap(CoW 语义)。对于大图:
- 100×100 图:40 KB,复制几乎不可感知
- 4K 图:~32 MB,多次链式调用可能达到几百 MB
工业级的处理方式:
- Fusion(融合):把多个 Filter 的计算合并到一次像素遍历(一次读写 = 更少的内存操作)
- Metal Compute:GPU 并行处理,所有像素同时计算(Phase 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 协议 + 链式调用 |
函数式、可组合 |
思考题
- 如果先调整对比度(c=2),再调整亮度(+50),和先调亮度再调对比度,结果一样吗?为什么?
- 如何实现"色调(Hue)旋转"------让红色变成绿色,绿色变成蓝色,蓝色变成红色?(提示:RGB → HSV → 旋转 H → HSV → RGB)
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时代,软件工程师必备概念全景图