基于APCA的可访问色彩系统生成技术解析

本文深入探讨了如何利用感知对比算法APCA生成可访问的色彩系统,详细解析了色彩空间转换、对比度计算原理及TypeScript实现代码,为设计系统开发提供技术参考。

生成设计系统的可访问色彩方案……灵感来自APCA!

Maximilian Blazek
2025年11月10日

这是关于我们在Canonical为新设计系统创建色彩方案的两篇博客文章中的第一篇。在这篇文章中,我将分享我在感知均匀色彩空间和感知对比算法方面的探索历程。

如果您已经熟悉这些概念,请直接跳转到此部分(或访问Github仓库)查看我如何逆向工程可访问感知对比算法(APCA)来生成感知对比色彩方案。在下一篇文章中,我将分享为什么我们没有选择这个解决方案以及我们最终选择了什么。

人类如何感知色彩

我被同事用Matthew Ström的文章《如何选择最不错误的颜色》给“技术狙击”了,这篇文章讲述了使用感知均匀色彩空间来选择数据可视化颜色。那时我还不知道Oklab和Oklch等感知均匀色彩空间。

“普通”的色彩空间(如RGB)的结构使得机器可以轻松处理颜色。因此,RGB具有非常非人性化的特性。如果您将色彩空间想象成一个几何形状,例如,RGB将是一个立方体。天真的假设是,我们感知为相似的颜色在这个立方体中彼此接近,对吗?然而,事实并非如此。令人惊讶的是,人类色彩感知并不对应一个完美的立方体。谁会想到呢?

感知均匀色彩空间支持的是人类感知,而不是计算机。虽然RGB在颜色在显示器上的显示方式上是一致的,但PUC(感知均匀色彩空间)与我们实际看到颜色的方式是一致的。因此,它们的3D形状不是完美的几何形状,比如上面的Oklch形状。震惊吧!

感知均匀色彩空间的这种特性更贴近实际的人类色彩感知,对UI设计和更广泛的设计领域具有巨大潜力。例如,在相同的“渐变”中,创建不同颜色的亮度看起来更均匀的色彩方案要容易得多。这种潜力让我着迷,因此我深入研究了感知均匀色彩空间以及人类对颜色和对比度的感知。

人类如何感知对比度

在我研究过程中了解到的一点是,目前WCAG指南中推荐的对比度算法存在缺陷,该推荐基于ISO-9241-3标准。APCA的作者myndex很好地记录了WCAG的缺点。

本质上,WCAG在评估两种颜色之间的对比度时会产生误报和漏报。这意味着,WCAG的认可不一定就是可访问的,因为一些高对比度的组合会失败,而一些低对比度的组合却能通过。APCA是一种更贴近人类对比度感知的对比度算法,因此在评估对比度方面比WCAG好得多。

那时,我也正准备开始为Canonical的设计系统创建一个新的色彩方案。因此,我扩展了我的研究,包括如何使用不同的色彩空间和对比度算法来创建色彩方案。在这个背景下,我还读了Matthew Ström另一篇关于颜色生成的文章,题为《如何为设计系统生成色彩方案》。这篇文章是我后续工作和这篇博客文章最重要的灵感来源之一;特别是Ström关于使用对比度来确定颜色渐变的原理,这让我想知道是否可以进一步发展它。

为设计系统生成色彩方案……

为了支持我为Canonical的设计系统创建新色彩方案的工作,我还研究了如何利用色彩空间和对比度算法来制作色彩方案。在他的文章中,Ström探索了结合对比度算法和感知均匀色彩空间来生成色彩方案。

对比度是在用户界面(和其他媒体)中处理颜色最重要的方面之一。两种颜色之间必须有足够的对比度,以便人们能够区分它们。Ström认为对比度应该决定色彩方案中颜色之间的渐变。应用到Ström的方案中,这意味着每对距离为500的颜色都将具有WCAG规定的4.5:1对比度比率。

在一个两种色调之间对比度一致的色彩方案中,选择可访问的颜色对很容易。选择方案中任意两个相距一定距离的色调,您就得到了一个可访问的颜色对。您不再需要手动检查用户界面中的所有颜色组合。在Canonical内部对设计师的调查中,我们发现选择可访问的颜色对是设计师们的一个重要关注点。因此,一个易于选择可访问颜色对的色彩方案对我们来说似乎是理想的。

……灵感来自APCA!

Matthew Ström在他的博客文章中有效地使用了WCAG算法,但如前所述,WCAG对比度算法有其缺点。我很好奇是否可能遵循相同的原理(基于对比度确定色彩方案渐变),但用感知对比度算法替换WCAG算法;事实上,Ström在他的文章中也提到这将是一个有趣的实验。我觉得尝试使用感知对比度这个想法很令人兴奋,并开始研究其可行性。

于是,我开始了创建受APCA对比度算法原理启发的色彩方案的旅程。

APCA公式

首先,我必须创建一个逆向感知对比度算法。APCA接受两种颜色并输出一个介于-108和106之间的数字(其中0表示低对比度,极值表示高对比度)以指示颜色对的对比程度。逆转算法意味着重新构造它,以便我们可以向算法指定一种颜色和所需的对比度比率,然后它返回满足这些条件的颜色。由于其复杂性,逆转感知对比度算法比逆转WCAG算法要困难得多。

我知道apca-w3包已经有一个“逆向APCA”函数。最初,我以为我必须超越这个函数的功能(它只能对灰度颜色执行逆转)。因此,在一次与朋友的攀岩旅行中,我作为一个副项目,尝试自己在一张餐巾纸上勾勒出APCA算法的逆转(在一位物理学家朋友的帮助下,因为我自己数学没那么好)。

APCA算法的大部分复杂性源于存在四种可能的情况,并且方程根据情况看起来不同。对于我们的逆向算法,我们需要考虑的四种情况是极性(文本是否比背景浅)以及我们想要求解的两个变量中的哪一个(文本或背景)。

因此,对于逆向算法,我们需要考虑四种情况:

  • 情况1:浅色文本在深色背景上,求解文本
  • 情况2:深色文本在浅色背景上,求解文本
  • 情况3:浅色文本在深色背景上,求解背景
  • 情况4:深色文本在浅色背景上,求解背景

我将展示第一种情况的过程。其他情况的过程基本相同,但根据情况必须使用不同的替换和符号。

重复相同的过程处理其他情况,我们得到以下4个方程对应我们的4种情况:

(方程展示)

最后,在APCA中,所有输入的Y值必须被钳位,而作为逆向函数输出的Y值必须被解除钳位。用于钳位和解除钳位Y的两个函数如下:

(函数展示)

完成所有可怕的计算后,我准备将所有这些转化为代码。在这样做的过程中,我意识到我只确定了具有正确对比度值距离的颜色所需的Y分量(在XYZ色彩空间中),而不是完整的颜色。所以,这个公式基本上能够确定一个与输入颜色具有正确对比度距离的灰度颜色——这正是现有的逆向APCA函数可以做的😅。

我重新看了Ström的文章,意识到实际上Y分量就是我生成色彩方案所需要的全部。所以我本可以直接使用apca-w3包中可用的函数……因此,如果您正在考虑类似的项目,您可以节省(和您的物理学家朋友)餐巾纸计算,要么使用apca-w3包中现有的reverseAPCA()函数,要么使用我下面的代码。

我仍然认为亲自逆转它是一个很好的学习经验,而且由于apca-w3不是完全开源的(它没有标准的开源许可证),我也认为拥有一个真正开源许可证的逆向算法实现会很好。我不确定我所做的是否与APCA商标许可证兼容,因此我将避免声称我的结果是符合APCA的。我受APCA算法原理启发的逆向感知对比度查找器的代码如下:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
/**
 * 用于感知对比度计算的常量
 * 灵感来源于 https://github.com/Myndex/apca-w3/blob/c012257167d822f91bc417120bdb82e1b854b4a4/src/apca-w3.js#L146
 */

const PERCEPTUAL_CONTRAST_CONSTANTS: {
    BLACK_THRESHOLD: number
    BLACK_CLAMP: number
    OFFSET: number
    SCALE: number
    MAGIC_OFFSET_IN: number
    MAGIC_OFFSET_OUT: number
    MAGIC_FACTOR: number
    MAGIC_EXPONENT: number
    MACIG_FACTOR_INVERSE: number
} = {
    BLACK_THRESHOLD: 0.022,
    BLACK_CLAMP: 1.414,
    OFFSET: 0.027,
    SCALE: 1.14,
    MAGIC_OFFSET_IN: 0.0387393816571401,
    MAGIC_OFFSET_OUT: 0.312865795870758,
    MAGIC_FACTOR: 1.9468554433171,
    MAGIC_EXPONENT: 0.283343396420869 / 1.414,
    MACIG_FACTOR_INVERSE: 1 / 1.9468554433171,
}

/**
 * 对近黑色颜色解除钳位以恢复原始值
 * 灵感来源于: https://github.com/Myndex/apca-w3/blob/c012257167d822f91bc417120bdb82e1b854b4a4/src/apca-w3.js#L403
 * @param y - 要解除钳位的亮度值
 * @returns 解除钳位后的亮度值
 */
function unclampY(y: number): number {
    return y > PERCEPTUAL_CONTRAST_CONSTANTS.BLACK_THRESHOLD
        ? y
        : Math.pow(
              (y + PERCEPTUAL_CONTRAST_CONSTANTS.MAGIC_OFFSET_IN) *
                  PERCEPTUAL_CONTRAST_CONSTANTS.MAGIC_FACTOR,
              PERCEPTUAL_CONTRAST_CONSTANTS.MAGIC_EXPONENT
          ) *
              PERCEPTUAL_CONTRAST_CONSTANTS.MACIG_FACTOR_INVERSE -
              PERCEPTUAL_CONTRAST_CONSTANTS.MAGIC_OFFSET_OUT
}

/**
 * 对近黑色颜色应用钳位以防止对比度计算问题
 * 灵感来源于: https://github.com/Myndex/apca-w3/blob/c012257167d822f91bc417120bdb82e1b854b4a4/src/apca-w3.js#L381
 * @param y - 要钳位的亮度值
 * @returns 钳位后的亮度值
 */
function clampY(y: number): number {
    return y >= PERCEPTUAL_CONTRAST_CONSTANTS.BLACK_THRESHOLD
        ? y
        : y +
              Math.pow(
                  PERCEPTUAL_CONTRAST_CONSTANTS.BLACK_THRESHOLD - y,
                  PERCEPTUAL_CONTRAST_CONSTANTS.BLACK_CLAMP
              )
}

/**
 * 逆转感知对比度计算以找到匹配的亮度
 * 灵感来源于: https://github.com/Myndex/apca-w3/blob/c012257167d822f91bc417120bdb82e1b854b4a4/images/APCAw3_0.1.17_APCA0.0.98G.svg
 * @param contrast - 目标对比度值(介于5和106.04066之间)
 * @param y - 已知亮度值(介于0和1之间)
 * @param bgIsDarker - 背景是否比文本深
 * @param lookingFor - 我们求解的目标:"txt"(文本颜色)或"bg"(背景颜色)
 * @returns 计算出的亮度值,如果不存在有效解则返回false
 */
export function reversePerceptualContrast(
    contrast: number = 75, // 默认对比度75
    y: number = 1, // 默认亮度1
    bgIsDarker: boolean = false, // 默认假设背景更浅
    lookingFor: "txt" | "bg" = "txt" // 默认求解文本颜色
): number | false {
    contrast = Math.abs(contrast)
    let output: number | undefined

    if (!(y > 0 && y <= 1)) {
        console.log("y 不是有效值 (y > 0 && y <= 1)")
        return false
    }

    if (!(contrast >= 5 && contrast <= 106.04066)) {
        console.log(
            "对比度不是有效值 (contrast >= 5 && contrast <= 106.04066)"
        )
        return false
    }

    // 对输入亮度应用钳位
    y = clampY(y)

    // 根据我们寻找的目标和背景暗度计算输出亮度
    // 您可以在这里用更DRY的方式做这些计算,但我发现通过if语句从原始计算推导更容易理解。

    if (lookingFor === "txt") {
        if (bgIsDarker) {
            // 对于深色背景上的浅色文本
            output =
                (y ** 0.65 -
                    (-contrast / 100 - PERCEPTUAL_CONTRAST_CONSTANTS.OFFSET) *
                        (1 / PERCEPTUAL_CONTRAST_CONSTANTS.SCALE)) **
                (1 / 0.62)
        } else if (!bgIsDarker) {
            // 对于浅色背景上的深色文本
            output =
                (y ** 0.56 -
                    (contrast / 100 + PERCEPTUAL_CONTRAST_CONSTANTS.OFFSET) *
                        (1 / PERCEPTUAL_CONTRAST_CONSTANTS.SCALE)) **
                (1 / 0.57)
        }
    } else if (lookingFor === "bg") {
        if (bgIsDarker) {
            // 对于有浅色文本的深色背景
            output =
                (y ** 0.62 +
                    (-contrast / 100 - PERCEPTUAL_CONTRAST_CONSTANTS.OFFSET) *
                        (1 / PERCEPTUAL_CONTRAST_CONSTANTS.SCALE)) **
                (1 / 0.65)
        } else if (!bgIsDarker) {
            // 对于有深色文本的浅色背景
            output =
                (y ** 0.57 +
                    (contrast / 100 + PERCEPTUAL_CONTRAST_CONSTANTS.OFFSET) *
                        (1 / PERCEPTUAL_CONTRAST_CONSTANTS.SCALE)) **
                (1 / 0.56)
        }
    }

    // 如果有效,解除钳位输出值
    if (output !== undefined && !isNaN(output)) {
        output = unclampY(output)
    }

    // 验证最终输出
    if (
        output === undefined ||
        isNaN(output) ||
        !(output > 0 && output <= 1)
    ) {
        console.log("具有指定规格的颜色不存在")
        return false
    } else {
        return output
    }
}

在执行感知对比度逆转之后,我所要做的就是将我的逆向感知对比度代码与Ström的代码结合起来:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
import Color from "colorjs.io"

/**
 * 将OKHSl颜色转换为sRGB数组
 * @param {OkHSL} hsl - 包含 [色调, 饱和度, 亮度] 的数组
 *   色调: 数字 (0-360) - 色调角度,单位为度
 *   饱和度: 数字 (0-1) - 饱和度值
 *   亮度: 数字 (0-1) - 亮度值
 * @returns {[number, number, number]} sRGB数组 [r, g, b],范围0-255
 */
export function okhslToSrgb(
    hsl: [number, number, number],
): [number, number, number] {
    // 在OKHSl空间中创建新颜色
    let c = new Color("okhsl", hsl)
    // 转换为sRGB色彩空间
    c = c.to("srgb")

    return [c.srgb[0] * 255, c.srgb[1] * 255, c.srgb[2] * 255]
}

/**
 * 将Y(亮度)值转换为OKHSL亮度
 * 灵感来源于 https://github.com/Myndex/apca-w3/blob/c012257167d822f91bc417120bdb82e1b854b4a4/src/apca-w3.js#L418
 * @param {number} y - 线性亮度值 (0-1)
 * @returns {number} OKHSL亮度值 (0-1)
 */
export function yToOkhslLightness(y: number): number {
    const srgbComponent = y ** (1 / 2.4)
    const c = new Color("srgb", [srgbComponent, srgbComponent, srgbComponent])
    return c.okhsl[2]
}

/**
 * 颜色比例对象,按键为比例数字的十六进制颜色值
 */
interface ColorScale {
    [step: number]: [number, number, number]
}

/**
 * 补偿贝佐尔德-布鲁克效应,即颜色在阴影中显得更偏紫,在高光中显得更偏黄,通过将色调偏移最多5度来实现
 * 衍生自 https://mattstromawn.com/writing/generating-color-palettes/#putting-it-all-together%3A-all-the-code-you-need
 * 版权所有 (c) 2025 Matthew Ström-Awn
 * 根据MIT许可证授权。参见LICENSE文件。
 * @param step - 比例步长值 (0-1000)
 * @param baseHue - 起始色调,单位为度 (0-360)
 * @returns 调整后的色调值
 * @throws 如果参数无效
 */
function computeHue(step: number, baseHue: number): number {
    // 将步长从0-1000范围标准化为0-1
    const normalizedStep = step / 1000

    // 验证normalizedStep在0到1之间
    if (normalizedStep < 0 || normalizedStep > 1) {
        throw new Error("步长必须产生一个介于0和1之间的标准化值")
    }

    // 验证baseHue在0到360之间
    if (baseHue < 0 || baseHue > 360) {
        throw new Error("baseHue 必须是0到360之间的数字")
    }

    if (baseHue === 0) {
        return baseHue
    }

    return baseHue + 5 * (1 - normalizedStep)
}

/**
 * 创建一个用于色度/饱和度的抛物线函数,在中值处达到峰值
 * 这确保颜色在比例中间最为鲜艳,而在极端处更为 subtle
 * 衍生自 https://mattstromawn.com/writing/generating-color-palettes/#putting-it-all-together%3A-all-the-code-you-need
 * 版权所有 (c) 2025 Matthew Ström-Awn
 * 根据MIT许可证授权。参见LICENSE文件。
 * @param step - 比例步长值 (0-1000)
 * @param minChroma - 最小色度/饱和度值 (0-1)
 * @param maxChroma - 最大色度/饱和度值 (0-1)
 * @returns 计算出的色度值
 * @throws 如果参数无效
 */
function computeChroma(
    step: number,
    minChroma: number,
    maxChroma: number,
): number {
    const normalizedStep = step / 1000

    // 验证normalizedStep在0到1之间
    if (normalizedStep < 0 || normalizedStep > 1) {
        throw new Error("步长必须产生一个介于0和1之间的标准化值")
    }

    // 验证色度值在0到1之间且顺序正确
    if (minChroma < 0 || minChroma > 1 || maxChroma < 0 || maxChroma > 1) {
        throw new Error("色度值必须是0到1之间的数字")
    }
    if (minChroma > maxChroma) {
        throw new Error("minChroma 必须小于或等于 maxChroma")
    }

    const chromaDifference = maxChroma - minChroma
    return (
        -4 * chromaDifference * Math.pow(normalizedStep, 2) +
        4 * chromaDifference * normalizedStep +
        minChroma
    )
}

/**
 * 使用感知对比度从目标对比度步长计算OKHSL亮度
 * 衍生自 https://mattstromawn.com/writing/generating-color-palettes/#putting-it-all-together%3A-all-the-code-you-need
 * 版权所有 (c) 2025 Matthew Ström-Awn
 * 根据MIT许可证授权。参见LICENSE文件。
 * @param step - 比例步长值 (0-1000)
 * @returns OKHSL亮度值 (0-1)
 * @throws 如果无法计算目标亮度
 */
function computeLightness(step: number): number {
    // 将低于最小阈值的值裁剪为全亮度(白色)
    if (step < 50) {
        return 1
    }

    // 将50-999重新缩放到感知对比度的5-106.04066范围
    const perceptualContrast = 5 + ((step - 50) * (106.04066 - 5)) / (1000 - 50)

    const targetLuminance = reversePerceptualContrast(
        perceptualContrast,
        1,
        false,
        "txt",
    )

    if (targetLuminance === false) {
        throw new Error(
            `计算步长 ${step} 的目标亮度时出现问题`,
        )
    }

    return yToOkhslLightness(targetLuminance)
}

/**
 * 生成颜色比例的选项
 */
export interface GenerateColorScaleOptions {
    /** 基色调,单位为度 (0-360) */
    baseHue: number
    /** 最小色度/饱和度 (0-1) */
    minChroma: number
    /** 最大色度/饱和度 (0-1) */
    maxChroma: number
    /** 要生成的比例值数组(0-1000之间的整数值) */
    steps: number[]
}

/**
 * 生成具有可访问对比度水平的完整颜色比例
 * @param options - 颜色比例生成的配置对象
 * @returns 比例对象,颜色sRGB值以比例数字为键
 */
export function generateColorScale(
    options: GenerateColorScaleOptions,
): ColorScale {
    const { baseHue, minChroma, maxChroma, steps } = options

    if (baseHue < 0 || baseHue > 360) {
        throw new Error("baseHue 必须是0到360之间的数字")
    }

    if (minChroma < 0 || minChroma > 1 || maxChroma < 0 || maxChroma > 1) {
        throw new Error("色度值必须是0到1之间的数字")
    }

    if (minChroma > maxChroma) {
        throw new Error("minChroma 必须小于或等于 maxChroma")
    }

    if (
        steps.some((step) => step < 0 || step > 1000 || !Number.isInteger(step))
    ) {
        throw new Error("所有步长必须是0到1000之间的整数")
    }

    // 使用map和reduce生成颜色比例
    return steps.reduce((scale, step) => {
        const h = computeHue(step, baseHue)
        const s = computeChroma(step, minChroma, maxChroma)
        const l = computeLightness(step)

        const srgb = okhslToSrgb([h, s, l])

        return { ...scale, [step]: srgb }
    }, {})
}

就这样,我们可以生成一个具有可预测感知对比度底色的色彩方案:

色调 灰色 蓝色 绿色 红色 黄色
0 #fff #fff #fff #fff #fff
10 #e9e9e9 #e4eaf4 #dfeee1 #f4e6e4 #f2e8dc
20 #d7d7d7 #c7d9f5 #a9eab2 #f5ccc7 #f3d1a9
30 #c4c4c4 #a5c6fa #66e37e #faaea5 #f7b666
40 #b1b1b1 #81b2fe #32d25b #fd8c81 #f09c1b
50 #9c9c9c #5a9cff #00bd43 #ff5f58 #d88900
60 #878787 #3083f8 #2ba142 #f32c34 #bb7608
70 #707070 #2a6ecb #3b8343 #c13938 #9a6317
80 #585858 #2e5892 #38643a #8c3a37 #754f23
90 #3c3c3c #2c3d56 #2f422f #543230 #4b3926
100 #000 #000 #000 #000 #000

您可以在Github仓库中找到完整的代码。我提到我所有这些工作都是为了开发Canonical设计系统的新色彩方案做准备。但最终,我们决定(出于充分的理由)采用基于WCAG的方法,我将在下一篇博客文章中对此进行介绍。敬请期待 🙂

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计