UNPKG

hb-lib-tools

Version:

homebridge-lib Command-Line Tools`

387 lines (361 loc) 11.2 kB
// hb-lib-tools/lib/Colour.js // // Library for Homebridge plugins. // Copyright © 2016-2025 Erik Baauw. All rights reserved. const gamutByManufacturer = { GLEDOPTO: { r: [0.7006, 0.2993], g: [0.1387, 0.8148], b: [0.1510, 0.0227] }, 'IKEA of Sweden': { r: [0.68, 0.31], g: [0.11, 0.82], b: [0.13, 0.04] }, innr: { r: [0.8817, 0.1033], g: [0.2204, 0.7758], b: [0.0551, 0.1940] }, LEDVANCE: { r: [0.6972, 0.3027], g: [0.1737, 0.6991], b: [0.1227, 0.0959] }, MLI: { r: [0.68, 0.31], g: [0.11, 0.82], b: [0.13, 0.04] }, OSRAM: { r: [0.6850, 0.3149], g: [0.1780, 0.7253], b: [0.1241, 0.0578] }, Philips: { A: { r: [0.7040, 0.2960], g: [0.2151, 0.7106], b: [0.1380, 0.0800] }, B: { r: [0.6750, 0.3220], g: [0.4090, 0.5180], b: [0.1670, 0.0400] }, C: { r: [0.6920, 0.3080], g: [0.1700, 0.7000], b: [0.1530, 0.0480] } } } gamutByManufacturer['Signify Netherlands B.V.'] = gamutByManufacturer.Philips // Return point in color gamut closest to p. function closestInGamut (p, gamut) { // Return cross product of two points. function crossProduct (p1, p2) { return p1.x * p2.y - p1.y * p2.x } // Return distance between two points. function distance (p1, p2) { const dx = p1.x - p2.x const dy = p1.y - p2.y return Math.sqrt(dx * dx + dy * dy) } // Return point on line a,b closest to p. function closest (a, b, p) { const ap = { x: p.x - a.x, y: p.y - a.y } const ab = { x: b.x - a.x, y: b.y - a.y } let t = (ap.x * ab.x + ap.y * ab.y) / (ab.x * ab.x + ab.y * ab.y) t = t < 0.0 ? 0.0 : t > 1.0 ? 1.0 : t return { x: a.x + t * ab.x, y: a.y + t * ab.y } } const r = { x: gamut.r[0], y: gamut.r[1] } const g = { x: gamut.g[0], y: gamut.g[1] } const b = { x: gamut.b[0], y: gamut.b[1] } const v1 = { x: g.x - r.x, y: g.y - r.y } const v2 = { x: b.x - r.x, y: b.y - r.y } const v = crossProduct(v1, v2) const q = { x: p.x - r.x, y: p.y - r.y } const s = crossProduct(q, v2) / v const t = crossProduct(v1, q) / v if (s >= 0.0 && t >= 0.0 && s + t <= 1.0) { return p } const pRG = closest(r, g, p) const pGB = closest(g, b, p) const pBR = closest(b, r, p) const dRG = distance(p, pRG) const dGB = distance(p, pGB) const dBR = distance(p, pBR) let min = dRG p = pRG if (dGB < min) { min = dGB p = pGB } if (dBR < min) { p = pBR } return p } /** Colour conversions. * <br>See {@link Colour}. * @name Colour * @type {Class} * @memberof module:hb-lib-tools */ /** Colour conversions. * @class * @hideconstructor */ class Colour { /** [sRGB](https://en.wikipedia.org/wiki/SRGB) colour in * [HSV](https://en.wikipedia.org/wiki/HSL_and_HSV). * @typedef * @property {integer} h - Hue, between 0˚ and 360˚. * @property {integer} s - Saturation, between 0% and 100%. * @property {integer} v - Value, between 0% and 100%. */ static get HSV () {} /** [sRGB](https://en.wikipedia.org/wiki/SRGB) colour in * [RGB color model](https://en.wikipedia.org/wiki/RGB_color_model). * @typedef * @property {number} r - Red, between 0.0 and 1.0. * @property {number} g - Green, between 0.0 and 1.0. * @property {number} b - Blue, between 0.0 and 1.0. */ static get RGB () {} /** Convert {@link Colour.HSV HSV} to {@link Colour.RGB RGB}. * * See [HSL and HSV](https://en.wikipedia.org/wiki/HSL_and_HSV). * @param {integer} h - Hue, between 0˚ and 360˚. * @param {integer} s - Saturation, between 0% and 100%. * @param {integer} [v=100] - Value, between 0% and 100%. * @return {RGB} rgb - The corresponding {@link Colour.RGB RGB} value. */ static hsvToRgb (h, s, v = 100) { h /= 60.0 s /= 100.0 v /= 100.0 const C = v * s const m = v - C let x = (h % 2) - 1.0 if (x < 0) { x = -x } x = C * (1.0 - x) let r, g, b switch (Math.floor(h) % 6) { case 0: r = C + m; g = x + m; b = m; break case 1: r = x + m; g = C + m; b = m; break case 2: r = m; g = C + m; b = x + m; break case 3: r = m; g = x + m; b = C + m; break case 4: r = x + m; g = m; b = C + m; break case 5: r = C + m; g = m; b = x + m; break } return { r, g, b } } /** Convert {@link Colour.RGB RGB} to {@link Colour.HSV HSV}. * * See [HSL and HSV](https://en.wikipedia.org/wiki/HSL_and_HSV). * @param {number} r - Red, between 0.0 and 1.0. * @param {number} g - Green, between 0.0 and 1.0. * @param {number} b - Blue, between 0.0 and 1.0. * @return {HSV} hsv - The corresponding {@link Colour.HSV HSV} value. */ static rgbToHsv (r, g, b) { const M = Math.max(r, g, b) const m = Math.min(r, g, b) const C = M - m const S = (M === 0.0) ? 0.0 : C / M let H switch (M) { case m: H = 0.0 break case r: H = (g - b) / C if (H < 0) { H += 6.0 } break case g: H = (b - r) / C H += 2.0 break case b: H = (r - g) / C H += 4.0 break } return { h: Math.round(H * 60.0), s: Math.round(S * 100.0), v: Math.round(M * 100.0) } } /** Colour [gamut](https://en.wikipedia.org/wiki/Gamut). * @typedef * @property {number[]} r - `xy` coordinates for red, * x, y between 0.0000 and 1.0000. * @property {number[]} g - `xy` coordinates for green, * x, y between 0.0000 and 1.0000.. * @property {number[]} b - `xy` coordinates for blue, * x, y between 0.0000 and 1.0000. */ static get Gamut () {} /** Default gamut. * @type {Gamut} * @readonly */ static get defaultGamut () { // Safe default gamut taking into account: // - The maximum value for CurrentX and CurrentY, 65279 (0xfeff), // as defined by the ZCL spec; // - A potential division by zero error for CurrentY, when translating the // xy values back to hue/sat. return { r: [0.9961, 0.0001], g: [0, 0.9961], b: [0, 0.0001] } } /** Gamut per manufacturer. * @type {Object.<String, Gamut>} * @readonly */ static get gamutByManufacturer () { return gamutByManufacturer } /** Transform [sRGB](https://en.wikipedia.org/wiki/SRGB) * {@link Colour.HSV HSV} to * [CIE 1931](https://en.wikipedia.org/wiki/CIE_1931_color_space) `xy`. * * See [Hue developer portal](https://developers.meethue.com/develop/application-design-guidance/color-conversion-formulas-rgb-to-xy-and-back/). * @param {integer} h - Hue, between 0˚ and 360˚. * @param {integer} s - Saturation, between 0% and 100%. * @param {Gamut} [gamut=defaultGamut] - The gamut supported by the light. * @return {number[]} xy - The closest matching CIE 1931 colour, * x, y between 0.0000 and 1.0000. */ static hsvToXy (h, s, gamut = Colour.defaultGamut) { // Gamma correction (inverse sRGB Companding). function invCompand (v) { return v > 0.04045 ? Math.pow((v + 0.055) / (1.0 + 0.055), 2.4) : v / 12.92 } let { r, g, b } = Colour.hsvToRgb(h, s) // RGB to XYZ to xyY r = invCompand(r) g = invCompand(g) b = invCompand(b) const X = r * 0.664511 + g * 0.154324 + b * 0.162028 const Y = r * 0.283881 + g * 0.668433 + b * 0.047685 const Z = r * 0.000088 + g * 0.072310 + b * 0.986039 const sum = X + Y + Z const p = sum === 0.0 ? { x: 0.0, y: 0.0 } : { x: X / sum, y: Y / sum } const q = closestInGamut(p, gamut) return [Math.round(q.x * 10000) / 10000, Math.round(q.y * 10000) / 10000] } /** Transform [CIE 1931](https://en.wikipedia.org/wiki/CIE_1931_color_space) * `xy` to [sRGB](https://en.wikipedia.org/wiki/SRGB) * {@link Colour.HSV HSV}. * * See [Hue developer portal](https://developers.meethue.com/develop/application-design-guidance/color-conversion-formulas-rgb-to-xy-and-back/). * @param {number[]} xy - The CIE 1931 xy colour, * x, y between 0.0000 and 1.0000. * @param {Gamut} [gamut=defaultGamut] - The gamut supported by the light. * @return {HSV} hsv - The closest matching sRGB colour. */ static xyToHsv (xy, gamut = Colour.defaultGamut) { // Inverse Gamma correction (sRGB Companding). function compand (v) { return v <= 0.0031308 ? 12.92 * v : (1.0 + 0.055) * Math.pow(v, (1.0 / 2.4)) - 0.055 } // Correction for negative values is missing from Philips' documentation. function correctNegative () { const m = Math.min(r, g, b) if (m < 0.0) { r -= m g -= m b -= m } } function rescale () { const M = Math.max(r, g, b) if (M > 1.0) { r /= M g /= M b /= M } } // xyY to XYZ to RGB const p = closestInGamut({ x: xy[0], y: xy[1] }, gamut) const x = p.x const y = p.y === 0.0 ? 0.000001 : p.y const z = 1.0 - x - y const Y = 1.0 const X = (Y / y) * x const Z = (Y / y) * z let r = X * 1.656492 + Y * -0.354851 + Z * -0.255038 let g = X * -0.707196 + Y * 1.655397 + Z * 0.036152 let b = X * 0.051713 + Y * -0.121364 + Z * 1.011530 correctNegative() rescale() r = compand(r) g = compand(g) b = compand(b) rescale() return Colour.rgbToHsv(r, g, b) } /** Transform * [colour temperature](https://en.wikipedia.org/wiki/Color_temperature) to * [CIE 1931](https://en.wikipedia.org/wiki/CIE_1931_color_space) `xy`. * * Source: [deCONZ REST API plugin](https://github.com/dresden-elektronik/deconz-rest-plugin/blob/master/colorspace.cpp). * The results don't match exactly the `xy` values as returned by a Hue * LCT015 light, but seem to be close enough. * @param {integer} ct - The colour temperature * in [mired](https://en.wikipedia.org/wiki/Mired). * @return {number[]} xy - The closest matching CIE 1931 colour, * x, y between 0.0000 and 1.0000. */ static ctToXy (ct) { const kelvin = 1000000 / ct let x, y if (kelvin < 4000) { x = 11790 + 57520658 / kelvin + -15358885888 / kelvin / kelvin + -17440695910400 / kelvin / kelvin / kelvin } else { x = 15754 + 14590587 / kelvin + 138086835814 / kelvin / kelvin + -198301902438400 / kelvin / kelvin / kelvin } if (kelvin < 2222) { y = -3312 + 35808 * x / 0x10000 + -22087 * x * x / 0x100000000 + -18126 * x * x * x / 0x1000000000000 } else if (kelvin < 4000) { y = -2744 + 34265 * x / 0x10000 + -22514 * x * x / 0x100000000 + -15645 * x * x * x / 0x1000000000000 } else { y = -6062 + 61458 * x / 0x10000 + -96229 * x * x / 0x100000000 + 50491 * x * x * x / 0x1000000000000 } y *= 4 x /= 0xFFFF y /= 0xFFFF return [Math.round(x * 10000) / 10000, Math.round(y * 10000) / 10000] } } export { Colour }