Skip to content

Commit

Permalink
feat: find closest color to luminance and calculate luminance (#1)
Browse files Browse the repository at this point in the history
* feat: find closest color to luminance and calculate luminance

* chore: disable pnpm auto install peers

* chore: pnpm format

* chore: disable `publish` script being reported by knip
  • Loading branch information
vojtechsimetka authored Jul 20, 2023
1 parent d37f32e commit d1ded48
Show file tree
Hide file tree
Showing 6 changed files with 369 additions and 2 deletions.
2 changes: 1 addition & 1 deletion .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,4 @@ updates:
- package-ecosystem: github-actions
directory: /
schedule:
interval: 'weekly'
interval: 'weekly'
2 changes: 2 additions & 0 deletions .npmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
engine-strict=true
auto-install-peers=false
8 changes: 8 additions & 0 deletions knip.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import type { KnipConfig } from 'knip'

const config: KnipConfig = {
ignoreBinaries: ['publish'],
ignoreExportsUsedInFile: true
}

export default config
2 changes: 1 addition & 1 deletion pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

207 changes: 207 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
export interface RGB {
r: number
g: number
b: number
}

export interface HSL {
h: number
s: number
l: number
}

interface HUE {
p: number
q: number
t: number
}

export function hexToRgb(hex: string): RGB {
const bigint = parseInt(hex.substring(1), 16)
const r = (bigint >> 16) & 255
const g = (bigint >> 8) & 255
const b = bigint & 255

return { r, g, b }
}

export function rgbToHex({ r, g, b }: RGB) {
return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b
.toString(16)
.padStart(2, '0')}`
}

// Converts an HSL color value to RGB.
// h, s, and l are contained in the set [0, 1] and
// returns r, g, and b in the set [0, 255].
export function hslToRgb({ h, s, l }: HSL): RGB {
let r, g, b

if (s == 0) {
r = g = b = l // achromatic
} else {
const q = l < 0.5 ? l * (1 + s) : l + s - l * s
const p = 2 * l - q
r = hue2rgb({ p, q, t: h + 1 / 3 })
g = hue2rgb({ p, q, t: h })
b = hue2rgb({ p, q, t: h - 1 / 3 })
}

return { r: Math.round(r * 255), g: Math.round(g * 255), b: Math.round(b * 255) }
}

// Converts an RGB color value to HSL.
// r, g, and b are contained in the set [0, 255] and
// returns h, s, and l in the set [0, 1].
export function rgbToHsl({ r, g, b }: RGB): HSL {
r /= 255
g /= 255
b /= 255
const max = Math.max(r, g, b)
const min = Math.min(r, g, b)
const l = (max + min) / 2
let h,
s = l

if (max == min) {
h = s = 0 // achromatic
} else {
const d = max - min
s = l > 0.5 ? d / (2 - max - min) : d / (max + min)
switch (max) {
case r:
h = (g - b) / d + (g < b ? 6 : 0)
break
case g:
h = (b - r) / d + 2
break
default: // case b:
h = (r - g) / d + 4
break
}
h /= 6
}

return { h, s, l }
}

function hue2rgb({ p, q, t }: HUE) {
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 gammaCorrect(color: number) {
return color <= 0.03928 ? color / 12.92 : ((color + 0.055) / 1.055) ** 2.4
}

export function calculateLuminance(rgbOrHex: RGB | string) {
if (typeof rgbOrHex === 'string') rgbOrHex = hexToRgb(rgbOrHex)
let { r, g, b } = rgbOrHex

// Normalized to values between 0 and 1
r /= 255
g /= 255
b /= 255

// apply gamma correction
r = gammaCorrect(r)
g = gammaCorrect(g)
b = gammaCorrect(b)

// calculate luminance
return 0.2126 * r + 0.7152 * g + 0.0722 * b
}

export function getClosestColor(
hex: string,
targetLuminance: number,
targetPrecision = 0.01,
maxSteps = 100
) {
return getClosestColorBisection(hex, targetLuminance, targetPrecision, maxSteps)
}

export function getClosestColorNewton(
hex: string,
targetLuminance: number,
targetPrecision = 0.01,
maxSteps = 100
) {
// // Just a shortcut for all colors these are 0 and 100 luminance
if (targetLuminance <= 0) return '#000000'
if (targetLuminance >= 1) return '#ffffff'

const hsl = rgbToHsl(hexToRgb(hex))
const damping = 15 // Damping factor to avoid instability

for (let steps = 0; steps < maxSteps; ++steps) {
// Limit iterations to prevent infinite loop
const rgb = hslToRgb(hsl)
const currentLuminance = calculateLuminance(rgb)
const error = currentLuminance - targetLuminance

if (Math.abs(error) / targetLuminance <= targetPrecision) {
return rgbToHex(rgb) // Found suitable luminance
}

// If the color is white and the target luminance is less than the current, start decreasing the brightness
if (rgb.r === 255 && rgb.g === 255 && rgb.b === 255 && targetLuminance < currentLuminance) {
hsl.l -= 0.01 // decrement brightness
continue
}

// Newton-Raphson update
const tmpRgb = hslToRgb({ ...hsl, l: hsl.l + 0.01 })
const derivative = (calculateLuminance(tmpRgb) - currentLuminance) / 0.01

if (error) hsl.l -= error / derivative / damping

// Clamp brightness to [0,1]
if (hsl.l < 0) hsl.l = 0
if (hsl.l > 1) hsl.l = 1
}

return rgbToHex(hslToRgb(hsl)) // Suitable luminance wasn't found in time, return
}

export function getClosestColorBisection(
hex: string,
targetLuminance: number,
targetPrecision = 0.01,
maxSteps = 100
) {
// Just a shortcut for all colors these are 0 and 100 luminance
if (targetLuminance <= 0) return '#000000'
if (targetLuminance >= 1) return '#ffffff'

const hsl = rgbToHsl(hexToRgb(hex))

let low = 0,
high = 1,
steps = 0
let lowLum = 0,
highLum = 1
for (
steps = 0;
(highLum - lowLum) / targetLuminance > targetPrecision && steps < maxSteps;
steps += 1
) {
// Stop when interval size is within target precision
hsl.l = (low + high) / 2 // Take the midpoint as the new guess
const currentLuminance = calculateLuminance(hslToRgb(hsl))

if (currentLuminance > targetLuminance) {
high = hsl.l
highLum = currentLuminance
} else {
low = hsl.l
lowLum = currentLuminance
}
}

return rgbToHex(hslToRgb(hsl)) // Return the hex color for the best luminance match
}
150 changes: 150 additions & 0 deletions test/index.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import { describe, it, expect } from 'vitest'
import {
hexToRgb,
rgbToHex,
hslToRgb,
calculateLuminance,
getClosestColor,
getClosestColorNewton,
type RGB,
type HSL,
rgbToHsl
} from '../src/index'

function formatRgb(rgb: RGB) {
return `{r=${rgb.r.toString().padStart(3, ' ')}, g=${rgb.g.toString().padStart(3, ' ')}, b=${rgb.b
.toString()
.padStart(3, ' ')}}`
}
function formatHsl(hsl: HSL) {
return `{h=${hsl.h.toString().padStart(3, ' ')}, s=${hsl.s.toString().padStart(3, ' ')}, l=${hsl.l
.toString()
.padStart(3, ' ')}}`
}

describe('hexToRgb', () => {
const testValues = [
{ hex: '#000000', rgb: { r: 0, g: 0, b: 0 } },
{ hex: '#ffffff', rgb: { r: 255, g: 255, b: 255 } },
{ hex: '#ff0000', rgb: { r: 255, g: 0, b: 0 } },
{ hex: '#00ff00', rgb: { r: 0, g: 255, b: 0 } },
{ hex: '#0000ff', rgb: { r: 0, g: 0, b: 255 } }
]

testValues.forEach(({ hex, rgb }) => {
it(`with hex=${hex} should return ${formatRgb(rgb)}`, () => {
const res = hexToRgb(hex)
expect(res.r).toEqual(rgb.r)
expect(res.g).toEqual(rgb.g)
expect(res.b).toEqual(rgb.b)
})
})
})

describe('rgbToHex', () => {
const testValues = [
{ rgb: { r: 0, g: 0, b: 0 }, hex: '#000000' },
{ rgb: { r: 255, g: 255, b: 255 }, hex: '#ffffff' },
{ rgb: { r: 255, g: 0, b: 0 }, hex: '#ff0000' },
{ rgb: { r: 0, g: 255, b: 0 }, hex: '#00ff00' },
{ rgb: { r: 0, g: 0, b: 255 }, hex: '#0000ff' }
]

testValues.forEach(({ rgb, hex }) => {
it(`with rgb = ${formatRgb(rgb)} should return ${hex}`, () => {
const res = rgbToHex(rgb)
expect(res).toEqual(hex)
})
})
})

describe('hslToRgb', () => {
const testValues = [
{ hsl: { h: 0, s: 0, l: 0 }, rgb: { r: 0, g: 0, b: 0 } },
{ hsl: { h: 0, s: 0, l: 1 }, rgb: { r: 255, g: 255, b: 255 } },
{ rgb: { r: 255, g: 0, b: 0 }, hsl: { h: 0, s: 1, l: 0.5 } },
{ rgb: { r: 0, g: 255, b: 0 }, hsl: { h: 0.3333333333333333, s: 1, l: 0.5 } },
{ rgb: { r: 0, g: 0, b: 255 }, hsl: { h: 0.6666666666666666, s: 1, l: 0.5 } }
]

testValues.forEach(({ hsl, rgb }) => {
it(`with hsl=${formatHsl(hsl)} should return ${formatRgb(rgb)}`, () => {
const res = hslToRgb(hsl)
expect(res.r).toEqual(rgb.r)
expect(res.g).toEqual(rgb.g)
expect(res.b).toEqual(rgb.b)
})
})
})

describe('rgbToHsl', () => {
const testValues = [
{ rgb: { r: 0, g: 0, b: 0 }, hsl: { h: 0, s: 0, l: 0 } },
{ rgb: { r: 255, g: 255, b: 255 }, hsl: { h: 0, s: 0, l: 1 } },
{ rgb: { r: 255, g: 0, b: 0 }, hsl: { h: 0, s: 1, l: 0.5 } },
{ rgb: { r: 0, g: 255, b: 0 }, hsl: { h: 0.3333333333333333, s: 1, l: 0.5 } },
{ rgb: { r: 0, g: 0, b: 255 }, hsl: { h: 0.6666666666666666, s: 1, l: 0.5 } }
]

testValues.forEach(({ rgb, hsl }) => {
it(`with rgb = ${formatRgb(rgb)} should return ${formatHsl(hsl)}`, () => {
const res = rgbToHsl(rgb)
expect(res.h).toEqual(hsl.h)
expect(res.s).toEqual(hsl.s)
expect(res.l).toEqual(hsl.l)
})
})
})

describe('calculateLuminance', () => {
const testValues = [
{ rgb: { r: 255, g: 255, b: 255 }, luminance: 1 },
{ rgb: { r: 0, g: 0, b: 0 }, luminance: 0 },
{ rgb: { r: 255, g: 0, b: 0 }, luminance: 0.2126 },
{ rgb: { r: 0, g: 255, b: 0 }, luminance: 0.7152 },
{ rgb: { r: 0, g: 0, b: 255 }, luminance: 0.0722 }
]

testValues.forEach(({ rgb, luminance }) => {
it(`with rgb = ${formatRgb(rgb)} should return ${luminance}`, () => {
const res = calculateLuminance(rgb)
expect(res).toEqual(luminance)
})
})
})

describe('getClosestColor', () => {
const testValues = [
{ hex: '#000000', luminance: 0, hexOut: '#000000' },
{ hex: '#000000', luminance: 1, hexOut: '#ffffff' },
{ hex: '#000000', luminance: 0.5, hexOut: '#bcbcbc' },
{ hex: '#ff0000', luminance: 0.5, hexOut: '#ffa2a2' },
{ hex: '#00ff00', luminance: 0.5, hexOut: '#00d900' },
{ hex: '#0000ff', luminance: 0.5, hexOut: '#b5b5ff' }
]

testValues.forEach(({ hex, luminance, hexOut }) => {
it(`with hex=${hex} and luminance target ${luminance} should return ${hexOut}`, () => {
const res = getClosestColor(hex, luminance)
expect(res).toEqual(hexOut)
})
})
})

describe('getClosestColorNewton', () => {
const testValues = [
{ hex: '#000000', luminance: 0, hexOut: '#000000' },
{ hex: '#000000', luminance: 1, hexOut: '#ffffff' },
{ hex: '#000000', luminance: 0.5, hexOut: '#bbbbbb' },
{ hex: '#ff0000', luminance: 0.5, hexOut: '#ffa2a2' },
{ hex: '#00ff00', luminance: 0.5, hexOut: '#00d900' },
{ hex: '#0000ff', luminance: 0.5, hexOut: '#b4b4ff' }
]

testValues.forEach(({ hex, luminance, hexOut }) => {
it(`with hex=${hex} and luminance target ${luminance} should return ${hexOut}`, () => {
const res = getClosestColorNewton(hex, luminance)
expect(res).toEqual(hexOut)
})
})
})

0 comments on commit d1ded48

Please sign in to comment.