๐Ÿš€ HickleSecLab

How to transform black into any given color using only CSS filters

How to transform black into any given color using only CSS filters

๐Ÿ“… | ๐Ÿ“‚ Category: Javascript

Have you ever wondered how to magically transform black into any given color using only CSS filters? It might sound like alchemy, but with the power of CSS, it’s surprisingly achievable. This technique opens a world of creative possibilities for web designers and developers, allowing you to dynamically recolor icons, logos, and other graphical elements without needing multiple image assets. Imagine a website where the color scheme seamlessly adapts to the user’s preferences โ€“ this is the kind of flexibility CSS filters can unlock. This article will guide you through the process, demystifying the code and providing practical examples to help you implement this effect in your own projects. Get ready to explore the exciting realm of CSS color manipulation!

Understanding the Basics of CSS Filters for Color Transformation

CSS filters are powerful tools that allow you to apply visual effects to elements directly in the browser. These effects, ranging from simple blurs to complex color manipulations, can significantly enhance the user experience and add a touch of visual flair to your website. When it comes to recoloring black elements, the key lies in understanding how the filter property interacts with grayscale images. By strategically combining filters like brightness, hue-rotate, saturate, and invert, you can effectively map the black color to any desired hue. The brightness filter essentially controls the intensity of the light, while hue-rotate shifts the colors around the color wheel. Saturate adjusts the vibrancy of the colors, and invert flips the colors (black becomes white and vice-versa).

The magic happens when you realize that black, being the absence of color, is easily manipulated. By inverting the colors first, you convert black to white. Then, you can use hue-rotate to change the white to the desired color. Finally, you can adjust the brightness and saturate to fine-tune the result. While seemingly complex, the underlying principle is simple: manipulate the grayscale values to achieve the desired color output. This approach is especially useful for icons or logos that are initially designed in black and need to adapt to different themes or branding guidelines. “CSS filters offer a performant and flexible way to manipulate colors directly in the browser, reducing the need for image editing and improving website responsiveness,” says Sarah Drasner, a renowned web developer and speaker. CSS-Tricks offers a comprehensive guide to all CSS filters.

Let’s consider a practical example. Suppose you have a black SVG icon that you want to recolor to blue. You would first invert the colors, turning black to white. Then, you would apply a hue-rotate filter to shift the white color to blue. Finally, you can adjust the brightness to make the blue darker or lighter. This entire process can be achieved with a few lines of CSS code, making it a much more efficient solution than creating multiple versions of the same icon in different colors.

Step-by-Step Guide: Recoloring Black Elements with CSS Filters

Now, let’s dive into the practical steps of transforming black into any given color using CSS filters. This process involves a combination of filters applied in a specific order to achieve the desired result. Remember that the exact values for each filter may need to be adjusted based on the specific image and desired color, so experimentation is key. The ability to dynamically change the color of elements opens up new possibilities for user interface design, allowing for more personalized and interactive experiences.

Here’s a breakdown of the process:

  1. Start with the black element: Ensure your element is either a black image, SVG, or text.
  2. Invert the colors: Apply the invert(100%) filter to convert black to white. This is a crucial first step.
  3. Apply the hue-rotate filter: Use the hue-rotate() function to shift the color of the white element to your desired hue. The value is specified in degrees (e.g., hue-rotate(180deg) for cyan).
  4. Adjust brightness (optional): Fine-tune the brightness to make the color lighter or darker. Values greater than 1 increase brightness, while values less than 1 decrease it.
  5. Adjust saturate (optional): Control the intensity of the color with the saturate() filter. A value of 0 desaturates the color completely (making it grayscale), while higher values increase the saturation.

For instance, to change black to a vibrant green, you might use the following CSS code:

img { filter: invert(100%) hue-rotate(90deg) saturate(200%); } 

This code snippet first inverts the image, making the black areas white. Then, it rotates the hue by 90 degrees, shifting the white color towards green. Finally, it increases the saturation to make the green more vibrant. This simple yet powerful technique allows you to dynamically recolor black elements with ease.

Advanced Techniques and Considerations

While the basic approach of using invert and hue-rotate is effective, there are several advanced techniques and considerations to keep in mind for more complex scenarios. One important aspect is browser compatibility. While most modern browsers support CSS filters, older versions may not. It’s always a good practice to provide a fallback for browsers that don’t support filters, such as using a different image or a solid color.

Another consideration is performance. Applying multiple filters can impact rendering performance, especially on complex images or animations. It’s crucial to optimize your code and avoid unnecessary filters. Consider using CSS hardware acceleration techniques, such as transform: translateZ(0), to improve performance. Furthermore, when working with SVGs, you can often manipulate the colors directly within the SVG code using CSS, which can be more performant than applying filters. You can learn more about SVG manipulation for enhanced control.

Furthermore, consider these points:

  • Specificity: Ensure your CSS rules have sufficient specificity to override any conflicting styles.
  • Accessibility: Be mindful of color contrast and ensure that the recolored elements are still accessible to users with visual impairments. Use tools like the WebAIM Contrast Checker to verify sufficient contrast.
Infographic here
Real-World Applications and Examples ------------------------------------

The ability to transform black into any given color using CSS filters has numerous real-world applications across various industries. In e-commerce, it can be used to dynamically change the color of product images based on user selection, allowing customers to visualize different color options without needing multiple images. This not only enhances the user experience but also reduces the need for extensive image storage.

In web applications, CSS filters can be used to create dynamic themes that adapt to the user’s preferences. For example, a dashboard application could allow users to select their preferred color scheme, and the icons and other graphical elements would automatically recolor accordingly. This level of customization can significantly improve user satisfaction and engagement. According to a study by Forrester, personalized experiences can increase customer loyalty and drive revenue growth. Forrester provides research and insights on customer experience.

Here are more examples:

  • Changing the color of social media icons on hover to match the brand color.
  • Creating a “dark mode” toggle that inverts the colors of the entire website.
  • Dynamically recoloring logos based on the user’s selected theme.

Featured Snippet: One common use case is dynamically changing the color of icons. By applying CSS filters, you can transform a black icon into any desired color without needing multiple image assets. This is particularly useful for creating responsive designs that adapt to different themes or branding guidelines. The invert and hue-rotate filters are key to achieving this effect, allowing you to shift the color of the icon while maintaining its shape and detail.

FAQ: Frequently Asked Questions

Can I use CSS filters to change the color of colored images?
While CSS filters can be applied to colored images, the results might not be as predictable as with black images. The hue-rotate filter will shift all the colors in the image, potentially creating unexpected results. For more precise color manipulation of colored images, consider using other techniques like blend modes or SVG filters.
Are CSS filters supported in all browsers?
Most modern browsers support CSS filters, but older versions may not. It's always a good practice to provide a fallback for browsers that don't support filters.
Can I animate CSS filters?
Yes, you can animate CSS filters using CSS transitions or animations. This can create interesting visual effects, such as gradually changing the color of an element over time.
Do CSS filters affect website performance?
Applying multiple filters can impact rendering performance, especially on complex images or animations. Optimize your code and avoid unnecessary filters.
Experiment with different filter combinations and values to discover the endless possibilities of CSS color manipulation. Remember to test your code across different browsers and devices to ensure compatibility and optimal performance.

Mastering CSS filters opens up a world of creative opportunities for web design and development. By understanding the principles behind these filters and experimenting with different combinations, you can create stunning visual effects and enhance the user experience of your websites. So go ahead, explore the power of CSS filters and transform your web designs today. Consider delving further into topics like CSS blend modes and SVG filters to expand your skillset and create even more sophisticated visual effects. With a little practice, you’ll be amazed at what you can achieve with just a few lines of code. This knowledge empowers you to create more dynamic, engaging, and personalized web experiences, making your projects stand out.

Question & Answer :
My question is: given a target RGB color, what is the formula to recolor black (#000) into that color using only CSS filters?

For an answer to be accepted, it would need to provide a function (in any language) that would accept the target color as an argument and return the corresponding CSS filter string.

The context for this is the need to recolor an SVG inside a background-image. In this case, it is to support certain TeX math features in KaTeX: https://github.com/Khan/KaTeX/issues/587.

Example

If the target color is #ffff00 (yellow), one correct solution is:

filter: invert(100%) sepia() saturate(10000%) hue-rotate(0deg) 

(demo)

Non-goals

  • Animation.
  • Non CSS-filter solutions.
  • Starting from a color other than black.
  • Caring about what happens to colors other than black.

Results so far

You can still get an Accepted answer by submitting a non brute-force solution!

Resources

  • How hue-rotate and sepia are calculated: https://stackoverflow.com/a/29521147/181228 Example Ruby implementation:

    LUM_R = 0.2126; LUM_G = 0.7152; LUM_B = 0.0722 HUE_R = 0.1430; HUE_G = 0.1400; HUE_B = 0.2830 def clamp(num) [0, [255, num].min].max.round end def hue_rotate(r, g, b, angle) angle = (angle % 360 + 360) % 360 cos = Math.cos(angle * Math::PI / 180) sin = Math.sin(angle * Math::PI / 180) [clamp( r * ( LUM_R + (1 - LUM_R) * cos - LUM_R * sin ) + g * ( LUM_G - LUM_G * cos - LUM_G * sin ) + b * ( LUM_B - LUM_B * cos + (1 - LUM_B) * sin )), clamp( r * ( LUM_R - LUM_R * cos + HUE_R * sin ) + g * ( LUM_G + (1 - LUM_G) * cos + HUE_G * sin ) + b * ( LUM_B - LUM_B * cos - HUE_B * sin )), clamp( r * ( LUM_R - LUM_R * cos - (1 - LUM_R) * sin ) + g * ( LUM_G - LUM_G * cos + LUM_G * sin ) + b * ( LUM_B + (1 - LUM_B) * cos + LUM_B * sin ))] end def sepia(r, g, b) [r * 0.393 + g * 0.769 + b * 0.189, r * 0.349 + g * 0.686 + b * 0.168, r * 0.272 + g * 0.534 + b * 0.131] end 
    

    Note that the clamp above makes the hue-rotate function non-linear.

    Browser implementations: Chromium, Firefox.

  • Demo: Getting to a non-grayscale color from a grayscale color: https://stackoverflow.com/a/25524145/181228

  • A formula that almost works (from a similar question):
    https://stackoverflow.com/a/29958459/181228

    A detailed explanation of why the formula above is wrong (CSS hue-rotate is not a true hue rotation but a linear approximation):
    https://stackoverflow.com/a/19325417/2441511

@Dave was the first to post an answer to this (with working code), and his answer has been an invaluable source of shameless copy and pasting inspiration to me. This post began as an attempt to explain and refine @Dave’s answer, but it has since evolved into an answer of its own.

My method is significantly faster. According to a jsPerf benchmark on randomly generated RGB colors, @Dave’s algorithm runs in 600 ms, while mine runs in 30 ms. This can definitely matter, for instance in load time, where speed is critical.

Furthermore, for some colors, my algorithm performs better:

  • For rgb(0,255,0), @Dave’s produces rgb(29,218,34) and mine produces rgb(1,255,0)
  • For rgb(0,0,255), @Dave’s produces rgb(37,39,255) and mine produces rgb(5,6,255)
  • For rgb(19,11,118), @Dave’s produces rgb(36,27,102) and mine produces rgb(20,11,112)

Demo

``` "use strict"; class Color { constructor(r, g, b) { this.set(r, g, b); } toString() { return `rgb(${Math.round(this.r)}, ${Math.round(this.g)}, ${Math.round(this.b)})`; } set(r, g, b) { this.r = this.clamp(r); this.g = this.clamp(g); this.b = this.clamp(b); } hueRotate(angle = 0) { angle = angle / 180 * Math.PI; let sin = Math.sin(angle); let 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) { let newR = this.clamp(this.r * matrix[0] + this.g * matrix[1] + this.b * matrix[2]); let newG = this.clamp(this.r * matrix[3] + this.g * matrix[4] + this.b * matrix[5]); let 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() { // Code taken from https://stackoverflow.com/a/9493060/2688027, licensed under CC BY-SA. let r = this.r / 255; let g = this.g / 255; let b = this.b / 255; let max = Math.max(r, g, b); let min = Math.min(r, g, b); let h, s, l = (max + min) / 2; if(max === min) { h = s = 0; } else { let 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) { if(value > 255) { value = 255; } else if(value < 0) { value = 0; } return value; } } class Solver { constructor(target) { this.target = target; this.targetHSL = target.hsl(); this.reusedColor = new Color(0, 0, 0); // Object pool } solve() { let result = this.solveNarrow(this.solveWide()); return { values: result.values, loss: result.loss, filter: this.css(result.values) }; } 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++) { let initial = [50, 20, 3750, 50, 100, 100]; let result = this.spsa(A, a, c, initial, 1000); if(result.loss < best.loss) { best = result; } } return best; } solveNarrow(wide) { 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, a, c, values, iters) { const alpha = 1; const gamma = 0.16666666666666666; let best = null; let bestLoss = Infinity; let deltas = new Array(6); let highArgs = new Array(6); let lowArgs = new Array(6); for(let k = 0; k < iters; k++) { let 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]; } let lossDiff = this.loss(highArgs) - this.loss(lowArgs); for(let i = 0; i < 6; i++) { let g = lossDiff / (2 * ck) * deltas[i]; let ak = a[i] / Math.pow(A + k + 1, alpha); values[i] = fix(values[i] - ak * g, i); } let loss = this.loss(values); if(loss < bestLoss) { best = values.slice(0); bestLoss = loss; } } return { values: best, loss: bestLoss }; function fix(value, idx) { 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 = 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) { // Argument is array of percentages. let 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); let 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) { function fmt(idx, 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)}%);`; } } $("button.execute").click(() => { let rgb = $("input.target").val().split(","); if (rgb.length !== 3) { alert("Invalid format!"); return; } let color = new Color(rgb[0], rgb[1], rgb[2]); let solver = new Solver(color); let 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)}. ${lossMsg}`); }); ```
.pixel { display: inline-block; background-color: #000; width: 50px; height: 50px; } .filterDetail { font-family: "Consolas", "Menlo", "Ubuntu Mono", monospace; }
<script src="https://ajax.googleapis.com/ajax/libs/jquery/2.1.1/jquery.min.js"></script> <input class="target" type="text" placeholder="r, g, b" value="250, 150, 50" /> <button class="execute">Compute Filters</button> <p>Real pixel, color applied through CSS background-color:</p> <div class="pixel realPixel"></div> <p>Filtered pixel, color applied through CSS filter:</p> <div class="pixel filterPixel"></div> <p class="filterDetail"></p> <p class="lossDetail"></p>
---

Usage

let color = new Color(0, 255, 0); let solver = new Solver(color); let result = solver.solve(); let filterCSS = result.filter; 

Explanation

We’ll begin with some Javascript.

"use strict"; class Color { constructor(r, g, b) { 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)})`; } hsl() { // Code taken from https://stackoverflow.com/a/9493060/2688027, licensed under CC BY-SA. let r = this.r / 255; let g = this.g / 255; let b = this.b / 255; let max = Math.max(r, g, b); let min = Math.min(r, g, b); let h, s, l = (max + min) / 2; if(max === min) { h = s = 0; } else { let 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) { if(value > 255) { value = 255; } else if(value < 0) { value = 0; } return value; } } class Solver { constructor(target) { this.target = target; this.targetHSL = target.hsl(); } css(filters) { function fmt(idx, 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)}%);`; } } 

Explanation:

  • The Color class represents a RGB color.
    • Its toString() function returns the color in a CSS rgb(...) color string.
    • Its hsl() function returns the color, converted to HSL.
    • Its clamp() function ensures that a given color value is within bounds (0-255).
  • The Solver class will attempt to solve for a target color.
    • Its css() function returns a given filter in a CSS filter string.

Implementing grayscale(), sepia(), and saturate()

The heart of CSS/SVG filters are filter primitives, which represent low-level modifications to an image.

The filters grayscale(), sepia(), and saturate() are implemented by the filter primative <feColorMatrix>, which performs matrix multiplication between a matrix specified by the filter (often dynamically generated), and a matrix created from the color. Diagram:

Matrix multiplication

There are some optimizations we can make here:

  • The last element of the color matrix is and will always be 1. There is no point of calculating or storing it.
  • There is no point of calculating or storing the alpha/transparency value (A) either, since we are dealing with RGB, not RGBA.
  • Therefore, we can trim the filter matrices from 5x5 to 3x5, and the color matrix from 1x5 to 1x3. This saves a bit of work.
  • All <feColorMatrix> filters leave columns 4 and 5 as zeroes. Therefore, we can further reduce the filter matrix to 3x3.
  • Since the multiplication is relatively simple, there is no need to drag in complex math libraries for this. We can implement the matrix multiplication algorithm ourselves.

Implementation:

function multiply(matrix) { let newR = this.clamp(this.r * matrix[0] + this.g * matrix[1] + this.b * matrix[2]); let newG = this.clamp(this.r * matrix[3] + this.g * matrix[4] + this.b * matrix[5]); let newB = this.clamp(this.r * matrix[6] + this.g * matrix[7] + this.b * matrix[8]); this.r = newR; this.g = newG; this.b = newB; } 

(We use temporary variables to hold the results of each row multiplication, because we do not want changes to this.r, etc. affecting subsequent calculations.)

Now that we have implemented <feColorMatrix>, we can implement grayscale(), sepia(), and saturate(), which simply invoke it with a given filter matrix:

function 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) ]); } function 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) ]); } function 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 ]); } 

Implementing hue-rotate()

The hue-rotate() filter is implemented by <feColorMatrix type="hueRotate" />.

The filter matrix is calculated as shown below:

For instance, element a00 would be calculated like so:

Notes:

  • The angle of rotation is given in degrees, which must be converted to radians before passed to Math.sin() or Math.cos().
  • Math.sin(angle) and Math.cos(angle) should be computed once and then cached.

Implementation:

function hueRotate(angle = 0) { angle = angle / 180 * Math.PI; let sin = Math.sin(angle); let 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 ]); } 

Implementing brightness() and contrast()

The brightness() and contrast() filters are implemented by <feComponentTransfer> with <feFuncX type="linear" />.

Each <feFuncX type="linear" /> element accepts a slope and intercept attribute. It then calculates each new color value through a simple formula:

value = slope * value + intercept 

This is easy to implement:

function 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); } 

Once this is implemented, brightness() and contrast() can be implemented as well:

function brightness(value = 1) { this.linear(value); } function contrast(value = 1) { this.linear(value, -(0.5 * value) + 0.5); } 

Implementing invert()

The invert() filter is implemented by <feComponentTransfer> with <feFuncX type="table" />.

The spec states:

In the following, C is the initial component and C’ is the remapped component; both in the closed interval [0,1].

For “table”, the function is defined by linear interpolation between values given in the attribute tableValues. The table has n + 1 values (i.e., v0 to vn) specifying the start and end values for n evenly sized interpolation regions. Interpolations use the following formula:

For a value C find k such that:

k / n โ‰ค C < (k + 1) / n

The result C’ is given by:

C’ = vk + (C - k / n) * n * (vk+1 - vk)

An explanation of this formula:

  • The invert() filter defines this table: [value, 1 - value]. This is tableValues or v.
  • The formula defines n, such that n + 1 is the table’s length. Since the table’s length is 2, n = 1.
  • The formula defines k, with k and k + 1 being indexes of the table. Since the table has 2 elements, k = 0.

Thus, we can simplify the formula to:

C’ = v0 + C * (v1 - v0)

Inlining the table’s values, we are left with:

C’ = value + C * (1 - value - value)

One more simplification:

C’ = value + C * (1 - 2 * value)

The spec defines C and C’ to be RGB values, within the bounds 0-1 (as opposed to 0-255). As a result, we must scale down the values before computation, and scale them back up after.

Thus we arrive at our implementation:

function 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); } 

Interlude: @Dave’s brute-force algorithm

@Dave’s code generates 176,660 filter combinations, including:

  • 11 invert() filters (0%, 10%, 20%, …, 100%)
  • 11 sepia() filters (0%, 10%, 20%, …, 100%)
  • 20 saturate() filters (5%, 10%, 15%, …, 100%)
  • 73 hue-rotate() filters (0deg, 5deg, 10deg, …, 360deg)

It calculates filters in the following order:

filter: invert(a%) sepia(b%) saturate(c%) hue-rotate(ฮธdeg); 

It then iterates through all computed colors. It stops once it has found a generated color within tolerance (all RGB values are within 5 units from the target color).

However, this is slow and inefficient. Thus, I present my own answer.

Implementing SPSA

First, we must define a loss function, that returns the difference between the color produced by a filter combination, and the target color. If the filters are perfect, the loss function should return 0.

We will measure color difference as the sum of two metrics:

  • RGB difference, because the goal is to produce the closest RGB value.
  • HSL difference, because many HSL values correspond to filters (e.g. hue roughly correlates with hue-rotate(), saturation correlates with saturate(), etc.) This guides the algorithm.

The loss function will take one argument โ€“ an array of filter percentages.

We will use the following filter order:

filter: invert(a%) sepia(b%) saturate(c%) hue-rotate(ฮธdeg) brightness(e%) contrast(f%); 

Implementation:

function loss(filters) { let color = new Color(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); let 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); } 

We will try to minimize the loss function, such that:

loss([a, b, c, d, e, f]) = 0 

The SPSA algorithm (website, more info, paper, implementation paper, reference code) is very good at this. It was designed to optimize complex systems with local minima, noisy/nonlinear/ multivariate loss functions, etc. It has been used to tune chess engines. And unlike many other algorithms, the papers describing it are actually comprehensible (albeit with great effort).

Implementation:

function spsa(A, a, c, values, iters) { const alpha = 1; const gamma = 0.16666666666666666; let best = null; let bestLoss = Infinity; let deltas = new Array(6); let highArgs = new Array(6); let lowArgs = new Array(6); for(let k = 0; k < iters; k++) { let 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]; } let lossDiff = this.loss(highArgs) - this.loss(lowArgs); for(let i = 0; i < 6; i++) { let g = lossDiff / (2 * ck) * deltas[i]; let ak = a[i] / Math.pow(A + k + 1, alpha); values[i] = fix(values[i] - ak * g, i); } let loss = this.loss(values); if(loss < bestLoss) { best = values.slice(0); bestLoss = loss; } } return { values: best, loss: bestLoss }; function fix(value, idx) { 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 = value % max; } else if(value < 0) { value = max + value % max; } } else if(value < 0) { value = 0; } else if(value > max) { value = max; } return value; } } 

I made some modifications/optimizations to SPSA:

  • Using the best result produced, instead of the last.
  • Reusing all arrays (deltas, highArgs, lowArgs), instead of recreating them with each iteration.
  • Using an array of values for a, instead of a single value. This is because all of the filters are different, and thus they should move/converge at different speeds.
  • Running a fix function after each iteration. It clamps all values to between 0% and 100%, except saturate (where the maximum is 7500%), brightness and contrast (where the maximum is 200%), and hueRotate (where the values are wrapped around instead of clamped).

I use SPSA in a two-stage process:

  1. The “wide” stage, that tries to “explore” the search space. It will make limited retries of SPSA if the results are not satisfactory.
  2. The “narrow” stage, that takes the best result from the wide stage and attempts to “refine” it. It uses dynamic values for A and a.

Implementation:

function solve() { let result = this.solveNarrow(this.solveWide()); return { values: result.values, loss: result.loss, filter: this.css(result.values) }; } function 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++) { let initial = [50, 20, 3750, 50, 100, 100]; let result = this.spsa(A, a, c, initial, 1000); if(result.loss < best.loss) { best = result; } } return best; } function solveNarrow(wide) { 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); } 

Tuning SPSA

Warning: Do not mess with the SPSA code, especially with its constants, unless you are sure you know what you are doing.

The important constants are A, a, c, the initial values, the retry thresholds, the values of max in fix(), and the number of iterations of each stage. All of these values were carefully tuned to produce good results, and randomly screwing with them will almost definitely reduce the usefulness of the algorithm.

If you insist on altering it, you must measure before you “optimize”.

First, apply this patch.

Then run the code in Node.js. After quite some time, the result should be something like this:

Average loss: 3.4768521401985275 Average time: 11.4915ms 

Now tune the constants to your heart’s content.

Some tips:

  • The average loss should be around 4. If it is greater than 4, it is producing results that are too far off, and you should tune for accuracy. If it is less than 4, it is wasting time, and you should reduce the number of iterations.
  • If you increase/decrease the number of iterations, adjust A appropriately.
  • If you increase/decrease A, adjust a appropriately.
  • Use the --debug flag if you want to see the result of each iteration.

TL;DR