Last active
March 18, 2024 16:07
-
-
Save dwjohnston/7e60bf5d4b6c071cd869f9f346241c08 to your computer and use it in GitHub Desktop.
TS Hex to CSS filters solution
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
//As referenced in this solution | |
import { number } from "prop-types"; | |
//https://codepen.io/sosuke/pen/Pjoqqp | |
interface HSL { | |
h: number; | |
s: number; | |
l: number; | |
} | |
class Color { | |
public r: number; | |
public g: number; | |
public b: number; | |
constructor(r: number, g: number, b: number) { | |
this.r = this.clamp(r); | |
this.g = this.clamp(g); | |
this.b = this.clamp(b); | |
} | |
toString() { | |
return `rgb(${Math.round(this.r)}, ${Math.round(this.g)}, ${Math.round(this.b)})`; | |
} | |
set(r: number, g: number, b: number) { | |
this.r = this.clamp(r); | |
this.g = this.clamp(g); | |
this.b = this.clamp(b); | |
} | |
hueRotate(angle = 0) { | |
angle = angle / 180 * Math.PI; | |
const sin = Math.sin(angle); | |
const cos = Math.cos(angle); | |
this.multiply([ | |
0.213 + cos * 0.787 - sin * 0.213, | |
0.715 - cos * 0.715 - sin * 0.715, | |
0.072 - cos * 0.072 + sin * 0.928, | |
0.213 - cos * 0.213 + sin * 0.143, | |
0.715 + cos * 0.285 + sin * 0.140, | |
0.072 - cos * 0.072 - sin * 0.283, | |
0.213 - cos * 0.213 - sin * 0.787, | |
0.715 - cos * 0.715 + sin * 0.715, | |
0.072 + cos * 0.928 + sin * 0.072, | |
]); | |
} | |
grayscale(value = 1) { | |
this.multiply([ | |
0.2126 + 0.7874 * (1 - value), | |
0.7152 - 0.7152 * (1 - value), | |
0.0722 - 0.0722 * (1 - value), | |
0.2126 - 0.2126 * (1 - value), | |
0.7152 + 0.2848 * (1 - value), | |
0.0722 - 0.0722 * (1 - value), | |
0.2126 - 0.2126 * (1 - value), | |
0.7152 - 0.7152 * (1 - value), | |
0.0722 + 0.9278 * (1 - value), | |
]); | |
} | |
sepia(value = 1) { | |
this.multiply([ | |
0.393 + 0.607 * (1 - value), | |
0.769 - 0.769 * (1 - value), | |
0.189 - 0.189 * (1 - value), | |
0.349 - 0.349 * (1 - value), | |
0.686 + 0.314 * (1 - value), | |
0.168 - 0.168 * (1 - value), | |
0.272 - 0.272 * (1 - value), | |
0.534 - 0.534 * (1 - value), | |
0.131 + 0.869 * (1 - value), | |
]); | |
} | |
saturate(value = 1) { | |
this.multiply([ | |
0.213 + 0.787 * value, | |
0.715 - 0.715 * value, | |
0.072 - 0.072 * value, | |
0.213 - 0.213 * value, | |
0.715 + 0.285 * value, | |
0.072 - 0.072 * value, | |
0.213 - 0.213 * value, | |
0.715 - 0.715 * value, | |
0.072 + 0.928 * value, | |
]); | |
} | |
multiply(matrix: any) { | |
const newR = this.clamp(this.r * matrix[0] + this.g * matrix[1] + this.b * matrix[2]); | |
const newG = this.clamp(this.r * matrix[3] + this.g * matrix[4] + this.b * matrix[5]); | |
const newB = this.clamp(this.r * matrix[6] + this.g * matrix[7] + this.b * matrix[8]); | |
this.r = newR; | |
this.g = newG; | |
this.b = newB; | |
} | |
brightness(value = 1) { | |
this.linear(value); | |
} | |
contrast(value = 1) { | |
this.linear(value, -(0.5 * value) + 0.5); | |
} | |
linear(slope = 1, intercept = 0) { | |
this.r = this.clamp(this.r * slope + intercept * 255); | |
this.g = this.clamp(this.g * slope + intercept * 255); | |
this.b = this.clamp(this.b * slope + intercept * 255); | |
} | |
invert(value = 1) { | |
this.r = this.clamp((value + this.r / 255 * (1 - 2 * value)) * 255); | |
this.g = this.clamp((value + this.g / 255 * (1 - 2 * value)) * 255); | |
this.b = this.clamp((value + this.b / 255 * (1 - 2 * value)) * 255); | |
} | |
hsl(): HSL { | |
// Code taken from https://stackoverflow.com/a/9493060/2688027, licensed under CC BY-SA. | |
const r = this.r / 255; | |
const g = this.g / 255; | |
const b = this.b / 255; | |
const max = Math.max(r, g, b); | |
const min = Math.min(r, g, b); | |
let h = 0; | |
let s = 0; | |
let l = (max + min) / 2; | |
if (max === min) { | |
h = s = 0; | |
} 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; | |
case b: | |
h = (r - g) / d + 4; | |
break; | |
} | |
h /= 6; | |
} | |
return { | |
h: h * 100, | |
s: s * 100, | |
l: l * 100, | |
}; | |
} | |
clamp(value: number): number { | |
if (value > 255) { | |
value = 255; | |
} else if (value < 0) { | |
value = 0; | |
} | |
return value; | |
} | |
} | |
interface Solution { | |
loss: number; | |
values: number[]; | |
} | |
class Solver { | |
private target: Color; | |
private targetHSL: HSL; | |
private reusedColor: Color; | |
constructor(target: Color) { | |
this.target = target; | |
this.targetHSL = target.hsl(); | |
this.reusedColor = new Color(0, 0, 0); | |
} | |
solve() { | |
const result = this.solveNarrow(this.solveWide()); | |
return { | |
values: result.values, | |
loss: result.loss, | |
filter: this.css(result.values), | |
}; | |
} | |
solveWide(): Solution { | |
const A = 5; | |
const c = 15; | |
const a = [60, 180, 18000, 600, 1.2, 1.2]; | |
let best = { loss: Infinity, values: [] as number[] }; | |
for (let i = 0; best.loss > 25 && i < 3; i++) { | |
const initial = [50, 20, 3750, 50, 100, 100]; | |
const result = this.spsa(A, a, c, initial, 1000); | |
if (result.loss < best.loss) { | |
best = result; | |
} | |
} | |
return best; | |
} | |
solveNarrow(wide: Solution) { | |
const A = wide.loss; | |
const c = 2; | |
const A1 = A + 1; | |
const a = [0.25 * A1, 0.25 * A1, A1, 0.25 * A1, 0.2 * A1, 0.2 * A1]; | |
return this.spsa(A, a, c, wide.values, 500); | |
} | |
spsa(A: number, a: number[], c: number, values: number[], iters: number): Solution { | |
const alpha = 1; | |
const gamma = 0.16666666666666666; | |
let best = [] as number[]; | |
let bestLoss = Infinity; | |
const deltas = new Array(6); | |
const highArgs = new Array(6); | |
const lowArgs = new Array(6); | |
for (let k = 0; k < iters; k++) { | |
const ck = c / Math.pow(k + 1, gamma); | |
for (let i = 0; i < 6; i++) { | |
deltas[i] = Math.random() > 0.5 ? 1 : -1; | |
highArgs[i] = values[i] + ck * deltas[i]; | |
lowArgs[i] = values[i] - ck * deltas[i]; | |
} | |
const lossDiff = this.loss(highArgs) - this.loss(lowArgs); | |
for (let i = 0; i < 6; i++) { | |
const g = lossDiff / (2 * ck) * deltas[i]; | |
const ak = a[i] / Math.pow(A + k + 1, alpha); | |
values[i] = fix(values[i] - ak * g, i); | |
} | |
const loss = this.loss(values); | |
if (loss < bestLoss) { | |
best = values.slice(0); | |
bestLoss = loss; | |
} | |
} | |
return { values: best, loss: bestLoss }; | |
function fix(value: number, idx: number): number { | |
let max = 100; | |
if (idx === 2 /* saturate */) { | |
max = 7500; | |
} else if (idx === 4 /* brightness */ || idx === 5 /* contrast */) { | |
max = 200; | |
} | |
if (idx === 3 /* hue-rotate */) { | |
if (value > max) { | |
value %= max; | |
} else if (value < 0) { | |
value = max + value % max; | |
} | |
} else if (value < 0) { | |
value = 0; | |
} else if (value > max) { | |
value = max; | |
} | |
return value; | |
} | |
} | |
loss(filters: number[]) { | |
// Argument is array of percentages. | |
const color = this.reusedColor; | |
color.set(0, 0, 0); | |
color.invert(filters[0] / 100); | |
color.sepia(filters[1] / 100); | |
color.saturate(filters[2] / 100); | |
color.hueRotate(filters[3] * 3.6); | |
color.brightness(filters[4] / 100); | |
color.contrast(filters[5] / 100); | |
const colorHSL = color.hsl(); | |
return ( | |
Math.abs(color.r - this.target.r) + | |
Math.abs(color.g - this.target.g) + | |
Math.abs(color.b - this.target.b) + | |
Math.abs(colorHSL.h - this.targetHSL.h) + | |
Math.abs(colorHSL.s - this.targetHSL.s) + | |
Math.abs(colorHSL.l - this.targetHSL.l) | |
); | |
} | |
css(filters: number[]) { | |
function fmt(idx: number, multiplier = 1) { | |
return Math.round(filters[idx] * multiplier); | |
} | |
return `filter: invert(${fmt(0)}%) sepia(${fmt(1)}%) saturate(${fmt(2)}%) hue-rotate(${fmt(3, 3.6)}deg) brightness(${fmt(4)}%) contrast(${fmt(5)}%);`; | |
} | |
} | |
type RGB = [number, number, number]; | |
function hexToRgb(hex: string): RGB { | |
// Expand shorthand form (e.g. "03F") to full form (e.g. "0033FF") | |
const shorthandRegex = /^#?([a-f\d])([a-f\d])([a-f\d])$/i; | |
hex = hex.replace(shorthandRegex, (m, r, g, b) => { | |
return r + r + g + g + b + b; | |
}); | |
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); | |
if (result) { | |
return [ | |
parseInt(result[1], 16), | |
parseInt(result[2], 16), | |
parseInt(result[3], 16), | |
] | |
} | |
throw new Error("Error pasring hex: " + hex); | |
} | |
export function getFilters(hex: string) { | |
const rgb = hexToRgb(hex); | |
const color = new Color(rgb[0], rgb[1], rgb[2]); | |
const solver = new Solver(color); | |
const result = solver.solve(); | |
console.log(result); | |
} | |
// $(document).ready(() => { | |
// $('button.execute').click(() => { | |
// const rgb = hexToRgb($('input.target').val()); | |
// if (rgb.length !== 3) { | |
// alert('Invalid format!'); | |
// return; | |
// } | |
// const color = new Color(rgb[0], rgb[1], rgb[2]); | |
// const solver = new Solver(color); | |
// const result = solver.solve(); | |
// let lossMsg; | |
// if (result.loss < 1) { | |
// lossMsg = 'This is a perfect result.'; | |
// } else if (result.loss < 5) { | |
// lossMsg = 'The is close enough.'; | |
// } else if (result.loss < 15) { | |
// lossMsg = 'The color is somewhat off. Consider running it again.'; | |
// } else { | |
// lossMsg = 'The color is extremely off. Run it again!'; | |
// } | |
// $('.realPixel').css('background-color', color.toString()); | |
// $('.filterPixel').attr('style', result.filter); | |
// $('.filterDetail').text(result.filter); | |
// $('.lossDetail').html(`Loss: ${result.loss.toFixed(1)}. <b>${lossMsg}</b>`); | |
// }); | |
// }); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
@dwjohnston FYI: https://www.npmjs.com/package/hex-to-css-filter