semmixcolor and perception

Converting HSV, HSB and RGB

HSV is the most frequently implemented color conversion: it sits behind every color picker. This page collects three versions of the algorithm and a direct path to HSL. The model itself is described on the HSV and HSB models page.

RGB to HSV

max = max(R, G, B), min = min(R, G, B), d = max - min V = max S = d / max (S = 0 if max = 0) H = hexagonal formula (see RGB to hue angle)

HSV to RGB: three versions

1. Sectors. The classic version with a table of six cases:

C = V · S, X = C · (1 - |(H / 60°) mod 2 - 1|), m = V - C 0°..60° → (C, X, 0) 180°..240° → (0, X, C) 60°..120° → (X, C, 0) 240°..300° → (X, 0, C) 120°..180° → (0, C, X) 300°..360° → (C, 0, X) (R, G, B) = result + m

2. One formula per component. No branches, handy in shaders and spreadsheets:

f(n) = V - V · S · max(0, min(k, 4 - k, 1)), k = (n + H / 60°) mod 6 R = f(5), G = f(3), B = f(1)

3. Via the pure color. First the color of the angle (see hue angle to RGB), then one multiplication:

RGB = V · (1 - S + S · P(H))

All three give the same result. For HSV (15°, 80%, 78.4%):

version 1: C = 0.6275 X = 0.1569 m = 0.1569 (C, X, 0) + m = (0.7843, 0.3137, 0.1569) version 2: R: k = (5 + 0.25) mod 6 = 5.25 → min(5.25, -1.25, 1) = -1.25 → max(0, ...) = 0 f = 0.7843 - 0 = 0.7843 G: k = 3.25 → min(3.25, 0.75, 1) = 0.75 f = 0.7843 - 0.7843 · 0.8 · 0.75 = 0.3137 B: k = 1.25 → min(1.25, 2.75, 1) = 1 f = 0.7843 - 0.6275 = 0.1569 version 3: 0.7843 · (0.2 + 0.8 · (1, 0.25, 0)) = (0.7843, 0.3137, 0.1569) RGB = (200, 80, 40)

An integer version

On microcontrollers and in old graphics code HSV is computed without floating point: H in 0..359, S and V in 0..255. The sector is H / 60, and the position within the sector is the remainder scaled to 0..255. Every division by 255 is rounded:

const div255 = (x) => ((x + 127) / 255) | 0;          // x / 255, rounded

// h: 0..359, s, v: 0..255, result 0..255
function hsvToRgb8(h, s, v) {
  const sector = (h / 60) | 0;
  const f = (((h % 60) * 255 + 30) / 60) | 0;          // position in sector 0..255
  const p = div255(v * (255 - s));
  const q = div255(v * (255 - div255(s * f)));
  const t = div255(v * (255 - div255(s * (255 - f))));
  switch (sector) {
    case 0: return [v, t, p];
    case 1: return [q, v, p];
    case 2: return [p, v, t];
    case 3: return [p, q, v];
    case 4: return [t, p, v];
    default: return [v, p, q];
  }
}

For HSV (15, 204, 200) the result is exactly (200, 80, 40). Across the whole range it differs from the floating-point version by at most 1. A faster variant using >> 8, i.e. dividing by 256 instead of 255, is common. It can be off by 2, and a gray (S = 0, V = 128) comes out as (127, 127, 128). For driving LEDs that does not matter; for graphics, divide by 255.

HSV to HSL and back

Hue stays the same. The other two components:

HSV → HSL: L = V · (1 - S_v / 2) S_l = (V - L) / min(L, 1 - L) (0 if L = 0 or L = 1) HSL → HSV: V = L + S_l · min(L, 1 - L) S_v = 2 · (1 - L / V) (0 if V = 0)

For our color: HSV (15°, 80%, 78.4%) ↔ HSL (15°, S 66.7%, L 47.1%).

Code (floating-point version)

function rgbToHsv(r, g, b) {
  const max = Math.max(r, g, b), min = Math.min(r, g, b), d = max - min;
  let h = 0;
  if (d > 1e-9) {
    h = max === r ? (g - b) / d : max === g ? (b - r) / d + 2 : (r - g) / d + 4;
    h = (h * 60 + 360) % 360;
  }
  return { h, s: max === 0 ? 0 : d / max, v: max };
}

function hsvToRgb(h, s, v) {
  const f = (n) => {
    const k = (n + h / 60) % 6;
    return v - v * s * Math.max(0, Math.min(k, 4 - k, 1));
  };
  return { r: f(5), g: f(3), b: f(1) };
}