RGB 是相机记录颜色的方式,不是人类感知颜色的方式。 当你说"把这张图调得更鲜艳一点",你的意思是什么? RGB 不知道;HSV 知道;Lab 更知道。
一、RGB 的局限性:耦合的颜色表示
RGB 颜色空间用三个分量(Red、Green、Blue)直接描述光的组成。
问题 :RGB 三个通道是高度耦合的,颜色编辑极为不直观。
ini
例:把一个橙色(R=255, G=128, B=0)调得"更亮":
目标:保持色相(橙)不变,只提高亮度
在 RGB 中怎么做?
同等比例增加 R/G/B?→ (255, 128, 0) × 1.2 = (306, 154, 0)
但 R 已经超出 255!需要截断 → (255, 154, 0)
这改变了 R/G 的比例,也改变了色相(颜色偏黄了)
在 HSV 中怎么做?
直接修改 V 值:V: 1.0 → 0.8(调暗)或配合曝光
H 和 S 不变,色相和饱和度保持不变
完美解耦!
| 操作 | RGB 实现难度 | HSV/Lab 实现难度 |
|---|---|---|
| 旋转色相 | 极难(需要矩阵变换) | 简单(H += angle) |
| 调整饱和度 | 难(S 与三通道均相关) | 简单(S × factor) |
| 调整亮度 | 中等(会影响色相) | 简单(V × factor) |
| 感知均匀的色差计算 | 不可靠 | Lab 的 ΔE 公式 |
二、HSV 颜色空间:六边形锥体模型
HSV 用三个直觉化的分量描述颜色:
- H(Hue,色相):颜色的"种类"(0°--360°)
- S(Saturation,饱和度):颜色的"鲜艳程度"(0--1)
- V(Value,明度):颜色的"明亮程度"(0--1)
ini
几何模型(倒锥体):
白色(S=0, V=1)
╱─────────────╲
╱ 红 黄 绿 青 ╲ ← 锥体顶面:高饱和色(V=1, S=1)
╱ 蓝 品红 ╲
╱ ╲
╲ H=0°(红) ╱
╲ 沿圆周旋转 H 角度 ╱
╲ ╱
╲──────────────────╱
黑色(V=0,任意 H/S)
各分量的直觉含义:
ini
H=0°, S=1, V=1 → 纯红
H=60°, S=1, V=1 → 纯黄
H=120°, S=1, V=1 → 纯绿
H=180°, S=1, V=1 → 纯青
H=240°, S=1, V=1 → 纯蓝
H=300°, S=1, V=1 → 纯品红
H=任意, S=0, V=1 → 白色(饱和度为 0 → 无色)
H=任意, S=任意, V=0 → 黑色(明度为 0)
三、RGB → HSV 转换推导
设 r, g, b ∈ [0, 1](归一化后的 RGB 值),定义:
ini
M = max(r, g, b) 最大分量(决定明度)
m = min(r, g, b) 最小分量
C = M - m 色度(Chroma,范围跨度)
明度 V(最简单):
ini
V = M
饱和度 S:
ini
S = C / M (若 M > 0)
S = 0 (若 M = 0,即纯黑,无色相)
色相 H(六扇区分类):
ini
M = r 时(色相在红色附近,300°--60°):
H' = (g - b) / C
H' 范围:-1 to +1
M = g 时(色相在绿色附近,60°--180°):
H' = (b - r) / C + 2
M = b 时(色相在蓝色附近,180°--300°):
H' = (r - g) / C + 4
最终 H = H' × 60°(转为度数)
若 H < 0:H += 360°
为什么是六扇区?
色相圆盘被 R/G/B 三个主色和 Y/C/M 三个补色分成 6 个 60° 区间。每个区间内,主导分量是 M(最大者),次主导分量线性插值产生过渡色相。六扇区分类确保 H 在正确的 0°--360° 范围内。
Swift 实现:
swift
func rgbToHSV(r: UInt8, g: UInt8, b: UInt8) -> (h: Double, s: Double, v: Double) {
let rf = Double(r) / 255.0
let gf = Double(g) / 255.0
let bf = Double(b) / 255.0
let M = max(rf, gf, bf)
let m = min(rf, gf, bf)
let C = M - m
let v = M
let s = M > 0 ? C / M : 0.0
var h: Double = 0.0
if C > 0 {
switch M {
case rf: h = (gf - bf) / C
if h < 0 { h += 6.0 } // 处理负值(红色跨越 0°/360°)
case gf: h = (bf - rf) / C + 2.0
default: h = (rf - gf) / C + 4.0
}
h *= 60.0
}
return (h, s, v)
}
四、HSV → RGB 反向变换
已知 h ∈ [0°, 360°), s ∈ [0, 1], v ∈ [0, 1]:
swift
func hsvToRGB(h: Double, s: Double, v: Double) -> (r: UInt8, g: UInt8, b: UInt8) {
if s == 0 {
// 无色(灰色)
let c = UInt8(v * 255)
return (c, c, c)
}
let hSector = h / 60.0 // 扇区索引(0--5.999)
let i = Int(hSector) % 6 // 整数扇区(0--5)
let f = hSector - Double(i) // 扇区内小数部分(0--1)
// 三个中间值
let p = v * (1 - s) // 最小值(V × 无饱和度)
let q = v * (1 - s * f) // 递减插值
let t = v * (1 - s * (1 - f)) // 递增插值
let (r, g, b): (Double, Double, Double)
switch i {
case 0: (r, g, b) = (v, t, p)
case 1: (r, g, b) = (q, v, p)
case 2: (r, g, b) = (p, v, t)
case 3: (r, g, b) = (p, q, v)
case 4: (r, g, b) = (t, p, v)
default:(r, g, b) = (v, p, q)
}
return (UInt8(r * 255), UInt8(g * 255), UInt8(b * 255))
}
p/q/t 三个中间值的几何意义:
ini
在扇区 0(H: 0°--60°,从红到黄):
R = V(保持最大)
G = t(从 p 线性增长到 V,G 通道递增)
B = p(V × (1-S),最小值,保持为"底色")
p:去掉饱和度后的底色,等于灰度值 V×(1-S)
q:递减插值(在相邻扇区的过渡中下降)
t:递增插值(在相邻扇区的过渡中上升)
五、CIE Lab 颜色空间:感知均匀的设计哲学
问题 :HSV 虽然直观,但不是感知均匀的。
在 HSV 中:
将 H 从 0° 改为 10°(红色变为橙红)→ 人眼感知变化大
将 H 从 240° 改为 250°(蓝色) → 人眼感知变化小
同样的 10° 变化,感知到的差异不同!
CIE 1976 Lab 颜色空间的目标:在该空间中,相同的欧氏距离对应相同的感知差异。
Lab 三个分量:
- L(Lightness,亮度):0(黑)-- 100(白)
- a:红--绿轴(负值 = 绿,正值 = 红)
- b:蓝--黄轴(负值 = 蓝,正值 = 黄)
六、RGB → Lab 转换链
Lab 不能直接从 RGB 转换,需要经过中间步骤:
css
sRGB(0--255)
↓ ① 归一化到 [0, 1]
线性化 sRGB(去 gamma)
↓ ② 伽马反校正
线性 sRGB(真实光物理量)
↓ ③ 线性变换(3×3 矩阵)
XYZ(D65 白点)
↓ ④ 非线性映射(含立方根)
CIE Lab
步骤 ①②:去伽马(Gamma Linearization)
相机存储的 sRGB 值经过伽马编码(约 γ=2.2),需要反解:
swift
func linearize(_ c: Double) -> Double {
// sRGB 标准的精确公式
if c <= 0.04045 {
return c / 12.92
} else {
return pow((c + 0.055) / 1.055, 2.4)
}
}
为什么有两段公式?
sRGB 标准在暗部(c < 0.04045)用线性段代替幂函数,避免在极暗区域的数值不稳定和计算误差放大。
步骤 ③:线性 RGB → XYZ(D65 白点矩阵)
swift
// IEC 61966-2-1(sRGB 标准矩阵,D65 白点)
func rgbToXYZ(r: Double, g: Double, b: Double) -> (x: Double, y: Double, z: Double) {
let x = 0.4124564 * r + 0.3575761 * g + 0.1804375 * b
let y = 0.2126729 * r + 0.7151522 * g + 0.0721750 * b
let z = 0.0193339 * r + 0.1191920 * g + 0.9503041 * b
return (x, y, z)
}
步骤 ④:XYZ → Lab
swift
func xyzToLab(x: Double, y: Double, z: Double) -> (l: Double, a: Double, b: Double) {
// D65 白点参考值(归一化,让白色 = L=100)
let xn = 0.95047, yn = 1.00000, zn = 1.08883
func f(_ t: Double) -> Double {
// CIE Lab 的非线性映射(结合了立方根和线性段)
let delta = 6.0 / 29.0
if t > delta * delta * delta {
return pow(t, 1.0 / 3.0)
} else {
return t / (3 * delta * delta) + 4.0 / 29.0
}
}
let fx = f(x / xn)
let fy = f(y / yn)
let fz = f(z / zn)
let l = 116 * fy - 16
let a = 500 * (fx - fy)
let b = 200 * (fy - fz)
return (l, a, b)
}
完整转换链的 Swift 封装:
swift
func rgbToLab(r: UInt8, g: UInt8, b: UInt8) -> (l: Double, a: Double, b: Double) {
let rLin = linearize(Double(r) / 255.0)
let gLin = linearize(Double(g) / 255.0)
let bLin = linearize(Double(b) / 255.0)
let (x, y, z) = rgbToXYZ(r: rLin, g: gLin, b: bLin)
return xyzToLab(x: x, y: y, z: z)
}
七、ΔE 色差(CIE76)
ΔE(Delta E)是 Lab 空间中两种颜色的欧氏距离,用于量化颜色差异:
swift
func deltaE76(lab1: (l: Double, a: Double, b: Double),
lab2: (l: Double, a: Double, b: Double)) -> Double {
let dl = lab1.l - lab2.l
let da = lab1.a - lab2.a
let db = lab1.b - lab2.b
return sqrt(dl*dl + da*da + db*db)
}
ΔE 的感知参考阈值:
| ΔE 值 | 感知程度 |
|---|---|
| < 1.0 | 肉眼无法分辨 |
| 1.0 -- 2.0 | 专业人士仔细观察才能发现 |
| 2.0 -- 3.5 | 普通人侧面对比可以发现 |
| 3.5 -- 5.0 | 普通人正常观察可以发现 |
| > 5.0 | 明显不同 |
应用:
- JPEG 压缩质量评估:比较原图和压缩图的平均 ΔE
- 颜色匹配容差:电商商品颜色校准(ΔE < 2 视为合格)
- Day 14 调色板去重:若两个代表色 ΔE < 5,合并为一个
八、实际应用:滤镜设计原理
8.1 HueRotateFilter(色相旋转)
swift
public struct HueRotateFilter: ImageFilter {
public let angle: Double // 旋转角度(度),范围 -180 ~ +180
public func apply(to bitmap: MLBitmap) -> MLBitmap {
var result = bitmap
for i in stride(from: 0, to: bitmap.pixels.count, by: 4) {
let r = bitmap.pixels[i]
let g = bitmap.pixels[i + 1]
let b = bitmap.pixels[i + 2]
var (h, s, v) = rgbToHSV(r: r, g: g, b: b)
h = fmod(h + angle + 360, 360) // 环绕旋转
let (nr, ng, nb) = hsvToRGB(h: h, s: s, v: v)
result.pixels[i] = nr
result.pixels[i + 1] = ng
result.pixels[i + 2] = nb
}
return result
}
}
8.2 SaturationFilter(饱和度调整)
swift
public struct SaturationFilter: ImageFilter {
public let factor: Double // 1.0 = 不变,0.0 = 灰度,2.0 = 双倍饱和度
public func apply(to bitmap: MLBitmap) -> MLBitmap {
var result = bitmap
for i in stride(from: 0, to: bitmap.pixels.count, by: 4) {
let r = bitmap.pixels[i]
let g = bitmap.pixels[i + 1]
let b = bitmap.pixels[i + 2]
var (h, s, v) = rgbToHSV(r: r, g: g, b: b)
s = min(1.0, max(0.0, s * factor)) // 线性缩放,截断到 [0, 1]
let (nr, ng, nb) = hsvToRGB(h: h, s: s, v: v)
result.pixels[i] = nr
result.pixels[i + 1] = ng
result.pixels[i + 2] = nb
}
return result
}
}
8.3 VibranceFilter:智能饱和度(Vibrance vs Saturation 的核心差异)
Saturation (饱和度):对所有像素等量提升饱和度。
Vibrance (自然饱和度):对低饱和区域 提升更多,高饱和区域提升更少。
效果:皮肤(低饱和,容易变橘色)保持自然;天空和植物(高饱和)适度增强。
swift
public struct VibranceFilter: ImageFilter {
public let strength: Double // 0.0 = 不变,1.0 = 最大 Vibrance
public func apply(to bitmap: MLBitmap) -> MLBitmap {
var result = bitmap
for i in stride(from: 0, to: bitmap.pixels.count, by: 4) {
let r = bitmap.pixels[i]
let g = bitmap.pixels[i + 1]
let b = bitmap.pixels[i + 2]
var (h, s, v) = rgbToHSV(r: r, g: g, b: b)
// 核心差异:权重与饱和度负相关
// 饱和度低的像素(s 接近 0)→ weight 接近 1(提升力度大)
// 饱和度高的像素(s 接近 1)→ weight 接近 0(基本不动)
let weight = (1.0 - s) * strength
s = min(1.0, s + weight * 0.5)
let (nr, ng, nb) = hsvToRGB(h: h, s: s, v: v)
result.pixels[i] = nr
result.pixels[i + 1] = ng
result.pixels[i + 2] = nb
}
return result
}
}
Vibrance vs Saturation 对比:
| 像素类型 | 原饱和度 S | Saturation(factor=1.5) | Vibrance(strength=1.0) |
|---|---|---|---|
| 皮肤色 | S=0.20 | 0.30(+50%) | 0.30(weight≈0.8,+40%) |
| 草地 | S=0.60 | 0.90(+50%) | 0.72(weight≈0.4,+20%) |
| 纯蓝天 | S=0.90 | 1.00(截断,+11%) | 0.925(weight≈0.1,+3%) |
Vibrance 的本质是自适应饱和度:低饱和区域收益最大,高饱和区域几乎不动,避免过饱和带来的失真感。
九、三种颜色空间的使用场景对比
| 颜色空间 | 最适合的操作 | 不适合的操作 |
|---|---|---|
| RGB | 像素混合、Alpha 合成、计算机存储 | 色相旋转、饱和度调整、色差计算 |
| HSV | 色相旋转、饱和度/明度独立调整、颜色选择器 | 感知均匀的色差、颜色外观模型 |
| Lab | 感知均匀色差(ΔE)、颜色分类、压缩质量评估 | 实时滤镜(转换成本高)、简单亮度调整 |
十、小结
| 概念 | 核心内容 |
|---|---|
| RGB 的耦合性 | 调色相需要同时改三个值,不直观 |
| HSV | 色相/饱和度/明度解耦,适合直觉化颜色编辑 |
| 六扇区 H 计算 | 按最大分量分类,六段线性插值,H ∈ [0°, 360°) |
| p/q/t 中间值 | 反向 HSV → RGB 的三个过渡插值 |
| CIE Lab | 感知均匀空间,相同欧氏距离 = 相同感知差异 |
| 转换链 | sRGB → 线性化(去 gamma) → XYZ → Lab |
| ΔE(CIE76) | Lab 空间欧氏距离,< 1 肉眼不可辨 |
| Vibrance vs Saturation | 权重 = 1-S,低饱和区域提升更多,避免过饱和 |
♥️喜欢我的内容,欢迎大家点赞、转发、关注。
♥️专注于技术+投资+认知三位一体的内容分享。
往期推荐: