Created
April 15, 2020 21:26
-
-
Save DusterTheFirst/f9b6871d684d775e2bc471759c56014a to your computer and use it in GitHub Desktop.
https://stackoverflow.com/a/43960991 but typescript
This file contains hidden or 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
/*! | |
* Copyright (C) 2018-2020 Zachary Kohnen (DusterTheFirst) | |
*/ | |
/** A class to represent a color */ | |
export class Color { | |
/** The red component for the color */ | |
public r: number; | |
/** The green component for the color */ | |
public g: number; | |
/** The blue component for the color */ | |
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); | |
} | |
/** String css representation of the color */ | |
public toString() { | |
return `rgb(${Math.round(this.r)}, ${Math.round(this.g)}, ${Math.round(this.b)})`; | |
} | |
/** Method to set the r g and b components of the color */ | |
public set(r: number, g: number, b: number) { | |
this.r = this.clamp(r); | |
this.g = this.clamp(g); | |
this.b = this.clamp(b); | |
} | |
/** Method to rotate the hue of the color */ | |
public hueRotate(angle = 0) { | |
const radAngle = angle / 180 * Math.PI; | |
const sin = Math.sin(radAngle); | |
const cos = Math.cos(radAngle); | |
this.multiply([ | |
(cos * 0.787) - (sin * 0.213) + 0.213, | |
(cos * 0.715) - (sin * 0.715) - 0.715, | |
(cos * 0.072) + (sin * 0.928) - 0.072, | |
(cos * 0.213) + (sin * 0.143) - 0.213, | |
(cos * 0.285) + (sin * 0.14) + 0.715, | |
(cos * 0.072) - (sin * 0.283) - 0.072, | |
(cos * 0.213) - (sin * 0.787) - 0.213, | |
(cos * 0.715) + (sin * 0.715) - 0.715, | |
(cos * 0.928) + (sin * 0.072) + 0.072, | |
]); | |
} | |
/** Method to convert the color to a greyscale representation */ | |
public grayscale(value = 1) { | |
this.multiply([ | |
(value - 1) * 0.7874 + 0.2126, | |
(value - 1) * 0.7152 - 0.7152, | |
(value - 1) * 0.0722 - 0.0722, | |
(value - 1) * 0.2126 - 0.2126, | |
(value - 1) * 0.2848 + 0.7152, | |
(value - 1) * 0.0722 - 0.0722, | |
(value - 1) * 0.2126 - 0.2126, | |
(value - 1) * 0.7152 - 0.7152, | |
(value - 1) * 0.9278 + 0.0722, | |
]); | |
} | |
/** Method to convert the color to a sepia representation */ | |
public sepia(value = 1) { | |
this.multiply([ | |
(value - 1) * 0.607 + 0.393, | |
(value - 1) * 0.769 - 0.769, | |
(value - 1) * 0.189 - 0.189, | |
(value - 1) * 0.349 - 0.349, | |
(value - 1) * 0.314 + 0.686, | |
(value - 1) * 0.168 - 0.168, | |
(value - 1) * 0.272 - 0.272, | |
(value - 1) * 0.534 - 0.534, | |
(value - 1) * 0.869 + 0.131, | |
]); | |
} | |
/** Method to change the saturation of the color */ | |
public saturate(value = 1) { | |
this.multiply([ | |
value * 0.787 + 0.213, | |
value * 0.715 - 0.715, | |
value * 0.072 - 0.072, | |
value * 0.213 - 0.213, | |
value * 0.285 + 0.715, | |
value * 0.072 - 0.072, | |
value * 0.213 - 0.213, | |
value * 0.715 - 0.715, | |
value * 0.928 + 0.072, | |
]); | |
} | |
/** Method to apply a matrix multiplication against the current color values */ | |
public multiply(matrix: [number, number, number, number, number, number, number, number, number]) { | |
this.r = this.clamp(this.r * matrix[0] + this.g * matrix[1] + this.b * matrix[2]); | |
this.g = this.clamp(this.r * matrix[3] + this.g * matrix[4] + this.b * matrix[5]); | |
this.b = this.clamp(this.r * matrix[6] + this.g * matrix[7] + this.b * matrix[8]); | |
} | |
/** Method to change the brightness of the color */ | |
public brightness(value = 1) { | |
this.linear(value); | |
} | |
/** Method to change the contrast of the color */ | |
public contrast(value = 1) { | |
this.linear(value, - (value * 0.5) + 0.5); | |
} | |
/** Method to linearly interpolate the color */ | |
public 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); | |
} | |
/** Method to invert the color */ | |
public invert(value = 1) { | |
this.r = this.clamp((value + this.r / 255 * (1 - value * 2)) * 255); | |
this.g = this.clamp((value + this.g / 255 * (1 - value * 2)) * 255); | |
this.b = this.clamp((value + this.b / 255 * (1 - value * 2)) * 255); | |
} | |
/** Method to get the Hue Saturation and Lightness */ | |
public 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; | |
const 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; | |
default: | |
} | |
h /= 6; | |
} | |
return { | |
h: h * 100, | |
s: s * 100, | |
l: l * 100, // tslint:disable-line: object-literal-sort-keys | |
}; | |
} | |
/** Method to clamp a number value for a color in the valid range */ | |
public clamp(value: number) { | |
if (value > 255) { | |
return 255; | |
} else if (value < 0) { | |
return 0; | |
} | |
return value; | |
} | |
} | |
/** Class that can create a css filter for a color */ | |
export class Solver { | |
/** The target color */ | |
public target: Color; | |
/** The target hsl */ | |
public targetHSL: ReturnType<Color["hsl"]>; | |
/** The */ | |
public reusedColor: Color; | |
constructor(target: Color) { | |
this.target = target; | |
this.targetHSL = target.hsl(); | |
this.reusedColor = new Color(0, 0, 0); | |
} | |
/** Solve the value */ | |
public solve() { | |
const result = this.solveNarrow(this.solveWide()); | |
return { | |
filter: this.css(result.values ?? []), | |
loss: result.loss, | |
values: result.values, | |
}; | |
} | |
/** A heuristic to get close to the expected result */ | |
public solveWide() { | |
const A = 5; | |
const c = 15; | |
const a = [60, 180, 18000, 600, 1.2, 1.2]; | |
let best = { loss: Infinity }; | |
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; | |
} | |
/** A precise solver for a result */ | |
public solveNarrow(wide: ReturnType<Solver["spsa"]>) { | |
const A = wide.loss; | |
const c = 2; | |
const A1 = A + 1; | |
const a = [A1 * 0.25, A1 * 0.25, A1, A1 * 0.25, A1 * 0.2, A1 * 0.2]; | |
return this.spsa(A, a, c, wide.values ?? [], 500); | |
} | |
/** https://en.wikipedia.org/wiki/Simultaneous_perturbation_stochastic_approximation */ | |
public spsa(A: number, a: number[], c: number, values: number[], iters: number): { | |
/** The computed values */ | |
values?: number[]; | |
/** The computed loss */ | |
loss: number; | |
} { | |
const alpha = 1; | |
const gamma = 0.16666666666666666; | |
let best; | |
let bestLoss = Infinity; | |
const deltas = new Array<number>(6); | |
const highArgs = new Array<number>(6); | |
const lowArgs = new Array<number>(6); | |
const fix = (val: number, idx: number) => { | |
let max = 100; | |
let value = val; | |
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; | |
}; | |
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 / (ck * 2) * 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 }; | |
} | |
/** Calculate the loss of a given filter set */ | |
public 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) | |
); | |
} | |
/** The css values of the filters */ | |
public css(filters: number[]) { | |
const fmt = (idx: number, multiplier = 1) => { | |
return Math.round(filters[idx] * multiplier); | |
}; | |
return `invert(${fmt(0)}%) sepia(${fmt(1)}%) saturate(${fmt(2)}%) hue-rotate(${fmt(3, 3.6)}deg) brightness(${fmt(4)}%) contrast(${fmt(5)}%)`; | |
} | |
} | |
/** Helper method to create a RGB value from a hex input */ | |
export function hexToRgb(inHex: string) { | |
// Expand shorthand form (e.g. "03F") to full form (e.g. "0033FF") | |
const shorthandRegex = /^#?([a-f\d])([a-f\d])([a-f\d])$/i; | |
const hex = inHex.replace(shorthandRegex, (_, r: string, g: string, b: string) => { | |
return r + r + g + g + b + b; | |
}); | |
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); | |
return result !== null | |
? [ | |
parseInt(result[1], 16), | |
parseInt(result[2], 16), | |
parseInt(result[3], 16), | |
] | |
: undefined; | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment