Skip to content

Instantly share code, notes, and snippets.

@DusterTheFirst
Created April 15, 2020 21:26
Show Gist options
  • Save DusterTheFirst/f9b6871d684d775e2bc471759c56014a to your computer and use it in GitHub Desktop.
Save DusterTheFirst/f9b6871d684d775e2bc471759c56014a to your computer and use it in GitHub Desktop.
/*!
* 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