semmixkolor i percepcja

Funkcje pomocnicze

Wzory na przekształcenia barw wyglądają prosto, ale w kodzie potykają się o drobiazgi: kąt 360° zamiast 0°, ujemne modulo, wartość 255,5 albo szarość, dla której odcień nie istnieje. Ta strona zbiera funkcje, z których korzystają wszystkie pozostałe przekształcenia w tym dziale.

Obcinanie do zakresu (clamp)

Przekształcenia często dają wyniki minimalnie poza zakresem 0..1, przez błędy zaokrągleń albo dlatego, że barwa jest poza gamą (zob. CIE XYZ i RGB).

const clamp = (x, lo = 0, hi = 1) => Math.min(hi, Math.max(lo, x));

Obcinanie trzeba stosować świadomie. Przy błędzie zaokrąglenia (np. 1,0000001) jest właściwe. Przy barwie spoza gamy (np. R = -0,17) zmienia odcień i warto przynajmniej o tym wiedzieć.

Modulo dla kątów

W JavaScripcie, C i Javie operator % zachowuje znak dzielnej: -30 % 360 daje -30, a nie 330. Kąt barwy trzeba normalizować tak:

const wrapHue = (h) => ((h % 360) + 360) % 360;

wrapHue(-30)  // 330
wrapHue(370)  // 10
wrapHue(360)  // 0

W Pythonie % działa od razu poprawnie dla dodatniego dzielnika.

Max, min i chroma

Prawie każde przekształcenie z RGB zaczyna od tych trzech wartości:

max = max(R, G, B) min = min(R, G, B) d = max - min (chroma w sensie sześcianu RGB) dla (200, 80, 40): max = 0,7843 min = 0,1569 d = 0,6275

Gdy d = 0, barwa jest szarością i odcień nie jest określony. W kodzie trzeba to sprawdzić przed dzieleniem przez d. Przy liczbach zmiennoprzecinkowych lepiej porównywać z małą tolerancją (np. d < 1e-9) niż z dokładnym zerem.

Klasyczna funkcja hue2rgb

W wielu bibliotekach przekształcenie HSL na RGB wygląda tak, z pomocniczymi wartościami p i q:

function hue2rgb(p, q, t) {
  if (t < 0) t += 1;
  if (t > 1) t -= 1;
  if (t < 1 / 6) return p + (q - p) * 6 * t;
  if (t < 1 / 2) return q;
  if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
  return p;
}

function hslToRgb(h, s, l) {   // h w zakresie 0..1
  if (s === 0) return { r: l, g: l, b: l };
  const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
  const p = 2 * l - q;
  return { r: hue2rgb(p, q, h + 1 / 3), g: hue2rgb(p, q, h), b: hue2rgb(p, q, h - 1 / 3) };
}

q to największa składowa wyniku, p najmniejsza. Funkcja hue2rgb rysuje "trapez" między nimi: przez 1/6 koła wartość rośnie od p do q, przez 1/3 jest równa q, przez 1/6 maleje, a przez resztę jest równa p. Każda składowa to ten sam trapez przesunięty o 1/3 koła. Dla HSL (15°, 66,7%, 47,1%) wychodzi p = 0,1569 i q = 0,7843, a wynik to (200, 80, 40).

Ten sam wynik daje krótszy wzór z wartością bezwzględną ze strony kąt barwy na RGB. Oba są poprawne. Warto je rozpoznawać, bo w cudzym kodzie spotyka się oba.

Zaokrąglanie do bajtów

const toByte = (c) => Math.round(clamp(c) * 255);
const fromByte = (n) => n / 255;

Mnożymy przez 255, nie przez 256, i zaokrąglamy, a nie obcinamy w dół. Math.floor(c * 256) też się spotyka, ale daje 256 dla c = 1 i przesuwa wszystkie wartości. Po przeliczeniu w obie strony (RGB → HSV → RGB) wynik może się różnić od wejścia o 1 na jednej składowej, jeśli HSV zapisano z małą dokładnością. To normalne.

Zapis HEX

const toHex = (r, g, b) =>
  '#' + [r, g, b].map((c) => toByte(c).toString(16).padStart(2, '0')).join('');

function parseHex(s) {
  let h = s.replace('#', '');
  if (h.length === 3) h = [...h].map((c) => c + c).join('');   // #c52 → #cc5522
  const n = parseInt(h, 16);
  return { r: ((n >> 16) & 255) / 255, g: ((n >> 8) & 255) / 255, b: (n & 255) / 255 };
}

Funkcja przejścia sRGB

Przed obliczeniami fizycznymi (mieszanie, XYZ, luminancja) wartości trzeba zlinearyzować. Wzory i uzasadnienie są w artykule o korekcji gamma:

const toLinear = (c) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
const toGamma = (c) => (c <= 0.0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - 0.055);

Wszystkie te funkcje, razem z przekształceniami do HSV, HSL, HSI, CMYK, XYZ, Lab i Luv, są w skrypcie kalkulatora tej strony: /assets/color.js.