【图像处理】颜色空间——RGB之外的世界

0 阅读10分钟

RGB 是相机记录颜色的方式,不是人类感知颜色的方式。 当你说"把这张图调得更鲜艳一点",你的意思是什么? RGB 不知道;HSV 知道;Lab 更知道。


一、RGB 的局限性:耦合的颜色表示

RGB 颜色空间用三个分量(Red、Green、Blue)直接描述光的组成。

问题:RGB 三个通道是高度耦合的,颜色编辑极为不直观。

例:把一个橙色(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)
几何模型(倒锥体):
         白色(S=0, V=1)
        ╱─────────────╲
       ╱ 红  黄  绿  青  ╲   ← 锥体顶面:高饱和色(V=1, S=1)
      ╱      蓝  品红      ╲
     ╱                      ╲
    ╲        H=0°(红)      ╱
     ╲    沿圆周旋转 H 角度  ╱
      ╲                    ╱
       ╲──────────────────╱
              黑色(V=0,任意 H/S)

各分量的直觉含义

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 值),定义:

M = max(r, g, b)   最大分量(决定明度)
m = min(r, g, b)   最小分量
C = M - m          色度(Chroma,范围跨度)

明度 V(最简单):

V = M

饱和度 S

S = C / M     (若 M > 0S = 0         (若 M = 0,即纯黑,无色相)

色相 H(六扇区分类):

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 实现

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]

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 三个中间值的几何意义

在扇区 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 转换,需要经过中间步骤:

sRGB(0255)
    ↓ ① 归一化到 [0, 1]
线性化 sRGB(去 gamma)
    ↓ ② 伽马反校正
线性 sRGB(真实光物理量)
    ↓ ③ 线性变换(3×3 矩阵)
XYZ(D65 白点)
    ↓ ④ 非线性映射(含立方根)
CIE Lab

步骤 ①②:去伽马(Gamma Linearization)

相机存储的 sRGB 值经过伽马编码(约 γ=2.2),需要反解:

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 白点矩阵)

// 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

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 封装

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 空间中两种颜色的欧氏距离,用于量化颜色差异:

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(色相旋转)

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(饱和度调整)

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(自然饱和度):对低饱和区域提升更多,高饱和区域提升更少。

效果:皮肤(低饱和,容易变橘色)保持自然;天空和植物(高饱和)适度增强。

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 对比

像素类型原饱和度 SSaturation(factor=1.5)Vibrance(strength=1.0)
皮肤色S=0.200.30(+50%)0.30(weight≈0.8,+40%)
草地S=0.600.90(+50%)0.72(weight≈0.4,+20%)
纯蓝天S=0.901.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,低饱和区域提升更多,避免过饱和

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

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

往期推荐:

经济机器是怎样运行的?
解决AI焦虑的唯一办法,建议收藏
图解Otsu算法
AI工业革命,有哪些能力最稀缺?
为什么很多创业者做出来的产品没人买?——读《The Mom Test》有感
巴菲特和芒格没有说的那些话
『纳瓦尔宝典』长期主义才是财富密码
『纳瓦尔宝典』独特知识才是真正资产
『纳瓦尔宝典』不要竞争,要变得不可替代!
我们终将成为自己选择的样子!!
你的环境,正在替你做决定!!
芒格的人生操作系统,构建复利人生的基石
如何选择真正适合你的职业?
决策矩阵
二阶思维
决策日志
AI时代,软件工程师必备概念全景图