Створення палітри кольорів з алгоритмом APCA
Це перший з двох блог-постів про те, як ми створили палітру кольорів для нової системи дизайну в Canonical. У цьому пості я ділюсь своїм досвідом у перцептивно однорідних кольорових просторах та алгоритмах перцептивного контрасту.
Якщо ви вже знайомі з цими концепціями, перейдіть до цього розділу (або відвідайте репозиторій на GitHub), щоб дізнатися, як я зворотним шляхом розробив доступний алгоритм перцептивного контрасту (APCA) для генерації перцептивно контрастних палітр кольорів. У наступному пості я поділюсь, чому ми не вибрали це рішення та що обрали натомість.
Як люди сприймають колір
Я був вражений статтею колеги Метью Стрема, “Як вибрати найменш неправильні кольори”, що стосується використання перцептивно однорідних кольорових просторів для вибору кольорів для візуалізації даних. Тоді я не знав про такі простори, як Oklab та Oklch.

“Звичайні” кольорові простори, такі як RGB, мають структуру, яка дозволяє машинам легко обробляти кольори. Тому RGB має дуже нелюдські характеристики. Якщо уявити кольоровий простір як геометричну фігуру, RGB був би кубом. Наївно припустити, що кольори, які ми сприймаємо як схожі, розташовані близько один до одного в цьому кубі, вірно? Однак це не так. На диво, сприйняття кольору людиною не відповідає ідеальному кубу. Хто б міг подумати?

Перцептивно однорідні кольорові простори підтримують сприйняття людьми, а не комп’ютерами. Хоча RGB однаковий у тому, як колір відображається на моніторі, PUCs відповідають тому, як ми насправді бачимо колір. В результаті їхні 3D-фігури не є ідеальними геометричними формами, такими як Oklch (на зображенні вище). Це шокуюче!
Ця властивість перцептивно однорідних кольорових просторів, яка більше відповідає реальному сприйняттю кольору людиною, має величезний потенціал у дизайні інтерфейсів. Наприклад, набагато легше створити палітри кольорів, в яких яскравість різних кольорів виглядає більш однорідно в межах однієї “градації”. Цей потенціал вразив мене, тому я вирішив заглибитися в перцептивно однорідні кольорові простори та людське сприйняття кольорів та контрасту загалом.
Як люди сприймають контраст
Однією з речей, яку я дізнався під час дослідження, були недоліки алгоритму контрасту, рекомендованого в вказівках WCAG, які засновані на стандарті ISO-9241-3. Автор APCA, myndex, чудово документує недоліки WCAG.
В основному, WCAG генерує як хибнопозитивні, так і хибно-негативні результати при оцінці контрасту між двома кольорами. Це означає, що затвердження WCAG не є обов’язково доступними, оскільки деякі комбінації з високим контрастом не проходять, а деякі з низьким проходять. APCA – це алгоритм контрасту, який більше відповідає сприйняттю контрасту людиною і тому краще оцінює контраст, ніж WCAG.

Тоді я також планував приступити до створення нової палітри кольорів для системи дизайну Canonical. Я розширив моє дослідження, включивши, як різні кольорові простори та алгоритми контрасту можуть бути використані для створення палітр кольорів. У цьому контексті я також прочитав іншу статтю Метью Стрема про генерацію кольорів, під назвою “Як генерувати палітри кольорів для систем дизайну.” Ця стаття була одним з найважливіших джерел натхнення для моєї подальшої роботи та цього блог-посту; Зокрема, принцип Стрема використовувати контраст для визначення градацій кольорів, що змусило мене задуматися, чи можна його розвинути далі.
Генерація палітр кольорів для систем дизайну…
Щоб підтримати мою роботу над створенням нової палітри кольорів для системи дизайну Canonical, я також досліджував, як кольорові простори та алгоритми контрасту можуть використовуватися для створення палітр кольорів. У своїй статті Стрем досліджує комбінування алгоритмів контрасту та перцептивно однорідних кольорових просторів для генерації палітр кольорів.
Контраст є одним з найважливіших аспектів при роботі з кольором у користувацьких інтерфейсах (та інших медіа). Повинен бути достатній контраст між двома кольорами, щоб люди могли їх розрізняти. Стрем вважає, що контраст повинен визначати градацію між кольорами в палітрі. Застосовуючи до палітри Стрема, це означає, що кожна пара кольорів на відстані 500 матиме затверджене значення контрасту WCAG 4.5:1.

У палітрі кольорів, де контраст між двома відтінками є сталішим, легше вибирати доступні пари кольорів. Виберіть будь-які два відтінки в палітрі, які знаходяться на певній відстані один від одного, і у вас вже є доступна пара кольорів. Тепер вам не потрібно вручну перевіряти всі комбінації кольорів у вашому інтерфейсі. У внутрішньому опитуванні дизайнерів Canonical ми з’ясували, що вибір доступних пар кольорів є важливим питанням для дизайнерів. Тому палітра кольорів, у якій легко вибрати доступні кольорові пари, здавалася ідеальною для нас.
… натхнення від APCA!
Метью Стрем використовував алгоритм WCAG у своєму блозі з хорошим ефектом, але, як вже зазначалося, алгоритм контрасту WCAG має свої недоліки. Мене цікавило, чи можливо дотриматися того ж принципу (основуючи градацію палітри кольорів на контрасті), але замінити алгоритм WCAG на алгоритм перцептивного контрасту; насправді, навіть Стрем зазначив у своїй статті, що це було б цікавим експериментом. Ідея спробувати це з перцептивним контрастом здавалася мені цікавою, і я почав досліджувати її здійсненність.
Так почалася моя подорож зі створення палітри кольорів під впливом принципів алгоритму APCA.

По-перше, мені потрібно було створити зворотний алгоритм перцептивного контрасту. APCA бере два кольори і видає число від -108 до 106 (де 0 є низьким контрастом, а крайні значення – високим контрастом), щоб вказати, наскільки контрастними є кольорові пари. Зворотний алгоритм означає структуризацію його так, щоб ми могли вказати колір і бажане значення контрасту, а алгоритм повертає колір, який відповідає цим критеріям. Через його складність зворотити алгоритм перцептивного контрасту було набагато складніше, ніж зворотити алгоритм WCAG.
Я знав, що пакет apca-w3 вже мав функцію “зворотного APCA”. Спочатку я думав, що мені доведеться вийти за межі можливостей цієї функції (вона може виконувати реверс лише з градаційними кольорами). Під час сходження з друзями я спробував самостійно розробити реверс алгоритму APCA на серветці (за допомогою фізика-друга, оскільки я не особливо вмію в математиці).

Багато з складності алгоритму APCA виникає з того факту, що є чотири можливі випадки, і рівняння виглядає по-різному в залежності від випадку. Чотири випадки, які ми повинні врахувати для нашого зворотного алгоритму, – це полярність (чи є текст світлішим за фон) і який з двох змінних ми хочемо вирішити (текст або фон).
Отже, для зворотного алгоритму потрібно розглянути чотири випадки:
- Випадок 1: Світлий текст на темному фоні, вирішення для тексту
- Випадок 2: Темний текст на світлому фоні, вирішення для тексту
- Випадок 3: Світлий текст на темному фоні, вирішення для фону
- Випадок 4: Темний текст на світлому фоні, вирішення для фону
Я покажу свій процес для першого випадку. Процес для інших випадків в основному той же, але в залежності від випадку повинні бути використані інші підстановки та знаки.

Повторюючи той же процес для інших випадків, ми отримуємо наступні 4 рівняння для наших 4 випадків:

Нарешті, у APCA всі вхідні значення Y повинні бути обмежені, а значення Y, що повертається в результаті зворотної функції, повинно бути розширене. Дві функції для обмеження та розширення Y виглядають так:

Після завершення всіх складних обчислень я був готовий перевести все це в код. При цьому я усвідомив, що я лише визначив необхідний компонент Y (в кольоровому просторі XYZ) кольору з правильним значенням контрасту, але не повний колір. Отже, формула здатна визначити градаційний колір, який має правильну дистанцію контрасту до вхідного кольору – саме це вже може робити існуюча функція зворотного APCA.
Я ще раз подивився на статтю Стрема і зрозумів, що компонент Y насправді був всім, що мені потрібно було для генерації палітр. Тож я міг би просто скористатися функцією, доступною в пакеті apca-w3… Тож, якщо ви розглядаєте подібний проект, ви можете заощадити (і своїм фізикам-друзям) на обчисленнях на серветках і або використати існуючу функцію reverseAPCA() в пакеті apca-w3, або мій код нижче.
Я все ще вважав, що це був хороший досвід для навчання, розвертаючи його самостійно, а оскільки apca-w3 не є повністю відкритим вихідним кодом (вона не має стандартної ліцензії на відкритий код), я також вважав би непоганим мати реалізацію зворотного алгоритму з дійсно відкритою ліцензією. Я не впевнений, чи відповідає те, що я зробив, ліцензії на товарний знак APCA, тому я утримаюся від вимоги про те, що мій результат відповідає APCA. Код для мого пошуку зворотного перцептивного контрасту, натхненного принципами алгоритму APCA, виглядає наступним чином:
/** * Константи, що використовуються в обчисленнях перцептивного контрасту * Натхнено формулою, знайденою з 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, // Значення контрасту за замовчуванням y: number = 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( "контраст не є дійсним значенням (контраст >= 5 && контраст <= 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) } } // Розширте вихідне значення, якщо дійсно якщо (output !== undefined && !isNaN(output)) { output = unclampY(output) } // Валідація остаточного виходу if ( output === undefined || isNaN(output) || !(output > 0 && output <= 1) ) { console.log("Колір зі специфікаціями не існує") return false } else { return output }}
Після виконання зворотного перцептивного контрасту, все, що мені залишалося зробити, це об'єднати мій код для зворотного перцептивного контрасту з кодом Стрема:
import Color from "colorjs.io"/** * Перетворює колір OKHSl в масив sRGB * @param {OkHSL} hsl - масив, що містить [відтінок, насиченість, яскравість] * відтінок: number (0-360) - кут відтінку в градусах * насиченість: number (0-1) - значення насиченості * яскравість: number (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 Метью Стрем-Ам * Ліцензовано за 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("step must produce a normalized value between 0 and 1") } // Валідація baseHue між 0 і 360 if (baseHue < 0 || baseHue > 360) { throw new Error("baseHue must be a number between 0 and 360") } if (baseHue === 0) { return baseHue } return baseHue + 5 * (1 - normalizedStep)}/** * Створює параболічну функцію для хроми/насиченості, яка досягає максимуму в середніх значеннях * Це забезпечує, щоб кольори були найбільш яскравими в середині шкали, а в крайніх значеннях - * більш м'якими * Виведено з https://mattstromawn.com/writing/generating-color-palettes/#putting-it-all-together%3A-all-the-code-you-need * Авторське право (c) 2025 Метью Стрем-Ам * Ліцензовано за 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("step must produce a normalized value between 0 and 1") } // Валідація значень хроми між 0 і 1 і правильний порядок if (minChroma < 0 || minChroma > 1 || maxChroma < 0 || maxChroma > 1) { throw new Error("Chroma values must be numbers between 0 and 1") } if (minChroma > maxChroma) { throw new Error("minChroma must be less than or equal to 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 Метью Стрем-Ам * Ліцензовано за 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 must be a number between 0 and 360") } if (minChroma < 0 || minChroma > 1 || maxChroma < 0 || maxChroma > 1) { throw new Error("Chroma values must be numbers between 0 and 1") } if (minChroma > maxChroma) { throw new Error("minChroma must be less than or equal to maxChroma") } if ( steps.some((step) => step < 0 || step > 1000 || !Number.isInteger(step)) ) { throw new Error("All steps must be integers between 0 and 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 } }, {})}
І ось так ми можемо генерувати палітру кольорів з передбачуваними відтінками, що контрастують:
| Відтінок | Сірий | Синій | Зелений | Червоний | Жовтий |
|---|
Ви можете знайти весь код у репозиторії на GitHub. Я згадував, що я здійснював усю цю роботу при підготовці до розробки нової палітри кольорів для системи дизайну Canonical. Але врешті-решт ми вирішили (з об'єктивних причин) обрати підхід на основі WCAG, про що я напишу в моєму наступному блог-посту. Тож залишайтеся з нами 🙂
Зв'яжіться з нами сьогодні
Цікавитесь використанням Ubuntu у вашій організації?




