218

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

Mister Jojo
  • 20,093
  • 6
  • 21
  • 40
glebm
  • 20,282
  • 8
  • 51
  • 67
  • So you want to LERP #000000 to #RRGGBB ? (Just clarifying) – Zze Mar 23 '17 at 03:31
  • Not sure if it's a LERP, but yes I want to get to #RGB from #000 using CSS filters (`sepia`, `hue-rotate` etc). – glebm Mar 23 '17 at 03:38
  • 2
    Yeah sweet - just clarifying that you didn't want to incorporate a transition into the solution. – Zze Mar 23 '17 at 03:52
  • I've updated the question with more resources on this that I've found – glebm Mar 23 '17 at 04:06
  • Have you seen this? http://stackoverflow.com/questions/29037023/how-to-calculate-required-hue-rotate-to-generate-specific-colour – Lars Beck May 04 '17 at 06:26
  • Thanks but I've seen it, it's the second link in "Resources", it has a problem as explained above. – glebm May 05 '17 at 00:50
  • To clarify, you want every black pixel to turn to the target color, and all other pixels remain the same? – Dotan May 13 '17 at 16:25
  • All pixels are black (it doesn't matter what happens to the other pixels). – glebm May 13 '17 at 16:36
  • 2
    May be a blend mode would work for you ? You can easily convert black to any color ... But I don't get the global picture of what you want to achieve – vals May 13 '17 at 20:13
  • In response to the `hue-rotate` issue, there was fantastic article written several years ago about how Studio 53 defined and approached the problem when they were developing the color mixer for their Paper drawing app. This doesn't solve the issue, but it might help you think about the problem: https://www.fastcompany.com/3002676/magical-tech-behind-paper-ipads-color-mixing-perfection – Bmd May 13 '17 at 20:38
  • @vals Recoloring external SVGs in browsers that do not support mask-iamge. – glebm May 13 '17 at 23:31
  • @Kaiido, there is a link to a KaTex issue in the description if you want full detail. Black is a requirement (needs to be black for non-filter supporting browser fallback). A fixed list of colors is not an option. If you read the links in the Resources section you'll learn about the `sepia` filter which allows to get a non-black color from black. This is not an impossible problem, but it does require math skills to solve in a non-brute force way. – glebm May 14 '17 at 00:39
  • white is just `invert(100%)` – glebm May 14 '17 at 00:56
  • Let us [continue this discussion in chat](http://chat.stackoverflow.com/rooms/144149/discussion-between-glebm-and-kaiido). – glebm May 14 '17 at 01:01
  • 1
    @glebm so you need to find a formula (using any method) to turn black into any color and apply it using css ? – ProllyGeek May 14 '17 at 01:12
  • 3
    @ProllyGeek Yes. One other constraint I should mention is that the resulting formula cannot be a brute force lookup of a 5GiB table (it should be usable from e.g. javascript on a webpage). – glebm May 14 '17 at 01:15
  • How exactly is the target color "given", i.e. how is it to be incorporated into the formula? String concatenation? Or can it be converted to a scalar before getting inserted into the filter formula? – Siguza May 14 '17 at 02:21
  • The formula (or function) accepts the target color as an argument and returns generated CSS. – glebm May 14 '17 at 02:28
  • Possible duplicate of [How to calculate required hue-rotate to generate specific colour?](https://stackoverflow.com/questions/29037023/how-to-calculate-required-hue-rotate-to-generate-specific-colour) – KyleMit Dec 03 '18 at 21:44
  • That question is less detailed and its accepted answer is incorrect (as mentioned in the comments) – glebm Dec 04 '18 at 04:45
  • Having had to deal with a similar problem, I tested the different solution here, I propose to go look at my answer via a svg filter to reach any rgb driver, demonstrated by the example. https://stackoverflow.com/questions/55986792/how-to-reference-an-svg-filter-on-a-png-image-if-this-filter-is-not-part-of-the/56027099#56027099 I also have been very disappointed by the answers here, because none of them can accurately target the chosen color, even if they claim it: if we use a color picker (Gpick - gnome) we can easily see it. – Mister Jojo May 07 '19 at 16:57

13 Answers13

384

@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)}. <b>${lossMsg}</b>`);
});
.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 <code>background-color</code>:</p>
<div class="pixel realPixel"></div>

<p>Filtered pixel, color applied through CSS <code>filter</code>:</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

Mir-Ismaili
  • 13,974
  • 8
  • 82
  • 100
MultiplyByZer0
  • 6,302
  • 3
  • 32
  • 48
  • 6
    Very nice summary of the development process! Are you reading my thoughts?! – Dave May 14 '17 at 14:00
  • 1
    @Dave Actually, I was working on this independently, but you beat me to it. – MultiplyByZer0 May 15 '17 at 00:44
  • 1
    I also found that if we start NOT from black image, but from RED one (255, 0, 0) and remove sepia() filter form calculation, result are similar and it works in Edge too. – cronfy Apr 27 '18 at 15:21
  • 24
    Great Answer! [Implementation in this codepen](https://codepen.io/sosuke/pen/Pjoqqp) – KyleMit Dec 03 '18 at 21:46
  • 12
    This is a completely insane method. You can set a color directly using a SVG filter (fifth column in a feColorMatrix) and you can reference that filter from CSS - why wouldn't you use that method? – Michael Mullany Dec 30 '18 at 02:00
  • 10
    @MichaelMullany Well, that's embarrassing for me, considering how long I worked on this. I didn't think of your method, but now I understand – to recolor an element to any arbitrary color, you just dynamically generate a SVG with a `` containing a `` with the proper values (all zeroes except the last column, which contains the target RGB values, 0, and 1), insert the SVG into the DOM, and reference the filter from CSS. Please write up your solution as an answer (with a demo), and I'll upvote. – MultiplyByZer0 Dec 30 '18 at 04:54
  • Great answer, is there a sass mixin to generate the filter when compiling css? – Fabio Caccamo Jun 21 '20 at 13:52
  • @MultiplyByZer0 This is a amazing research! Is it easy to adapt this answer to convert one hex color to another using CSS filters (this stuff is beyond me)? – sunknudsen Dec 17 '20 at 10:20
  • @sunknudsen Yes. To make such a modification, take the code from the demo, and in the function `loss()` of the class `Solver` (text-search for `loss(filters) { // Argument is array of percentages.`), replace the zeros of `color.set(0, 0, 0);` with the RGB values of the desired initial color. I *believe* that ought to work; please report whether it does. – MultiplyByZer0 Dec 18 '20 at 07:24
  • 2
    By the way, from today's perspective this answer is almost four years old and I find I am no longer proud or satisfied with my existing solution. As a result, I am planning a complete rewrite of this answer, featuring a npm package, a pretty website, a more efficient numerical optimization algorithm than SPSA, better explanations and visualizations, etc., and yes, the ability to set the initial color. Hopefully we'll see it in the next year. – MultiplyByZer0 Dec 18 '20 at 07:35
  • @MultiplyByZer0 That would be amazing! I love the idea of the npm package... and a website to convert a hex color to another would be very useful in some niche (but nonetheless powerful) use cases. I was actually considering putting together a website with you code to help myself and others scratch that itch. – sunknudsen Dec 22 '20 at 10:34
  • @MultiplyByZer0 this hack is still needed today because to use `` you have to put the filter in separate external file to make it work with all of Chrome, Safari and Firefox. And in case downloading that external file fails in Firefox, the whole element with the `filter` applied will turn totally transparent. As such, it's dangerous to apply that kind of external filter to any important element. See https://bugzilla.mozilla.org/show_bug.cgi?id=1415856 for details. – Mikko Rantalainen Jun 07 '21 at 12:56
  • 1
    @MultiplyByZer0 This is amazing! Why not roll this up into an npm package + docs? If you're interested, I'd be happy to help with that – Muers Jun 11 '21 at 16:35
  • 1
    The people who defined the filters in the CSS standard should read this answer and feel profoundly ashamed. The fact that one has to resort to this in order to get a filter that does such a trivial job, shows how pathetically bad of a job they did. – matteo Aug 06 '21 at 16:25
  • @MultiplyByZer0 did this become an NPM package? If not, is your comment above still valid for converting any color to any other? – Trevor Buckner Jan 24 '22 at 14:57
  • Why brute force the result? Wouldn't converting rgb to hsl give the values for hue-rotate saturate and brigthness directly? – Tofandel Jun 23 '22 at 16:01
  • Yo, massive respect! – Maksim Dimitrov Sep 02 '22 at 08:34
82

This was quite a trip down the rabbit hole but here it is!

var tolerance = 1;
var invertRange = [0, 1];
var invertStep = 0.1;
var sepiaRange = [0, 1];
var sepiaStep = 0.1;
var saturateRange = [5, 100];
var saturateStep = 5;
var hueRotateRange = [0, 360];
var hueRotateStep = 5;
var possibleColors;
var color = document.getElementById('color');
var pixel = document.getElementById('pixel');
var filtersBox = document.getElementById('filters');
var button = document.getElementById('button');
button.addEventListener('click', function() {          
 getNewColor(color.value);
})

// matrices taken from https://www.w3.org/TR/filter-effects/#feColorMatrixElement
function sepiaMatrix(s) {
 return [
  (0.393 + 0.607 * (1 - s)), (0.769 - 0.769 * (1 - s)), (0.189 - 0.189 * (1 - s)),
  (0.349 - 0.349 * (1 - s)), (0.686 + 0.314 * (1 - s)), (0.168 - 0.168 * (1 - s)),
  (0.272 - 0.272 * (1 - s)), (0.534 - 0.534 * (1 - s)), (0.131 + 0.869 * (1 - s)),
 ]
}

function saturateMatrix(s) {
 return [
  0.213+0.787*s, 0.715-0.715*s, 0.072-0.072*s,
  0.213-0.213*s, 0.715+0.285*s, 0.072-0.072*s,
  0.213-0.213*s, 0.715-0.715*s, 0.072+0.928*s,
 ]
}

function hueRotateMatrix(d) {
 var cos = Math.cos(d * Math.PI / 180);
 var sin = Math.sin(d * Math.PI / 180);
 var a00 = 0.213 + cos*0.787 - sin*0.213;
 var a01 = 0.715 - cos*0.715 - sin*0.715;
 var a02 = 0.072 - cos*0.072 + sin*0.928;

 var a10 = 0.213 - cos*0.213 + sin*0.143;
 var a11 = 0.715 + cos*0.285 + sin*0.140;
 var a12 = 0.072 - cos*0.072 - sin*0.283;

 var a20 = 0.213 - cos*0.213 - sin*0.787;
 var a21 = 0.715 - cos*0.715 + sin*0.715;
 var a22 = 0.072 + cos*0.928 + sin*0.072;

 return [
  a00, a01, a02,
  a10, a11, a12,
  a20, a21, a22,
 ]
}

function clamp(value) {
 return value > 255 ? 255 : value < 0 ? 0 : value;
}

function filter(m, c) {
 return [
  clamp(m[0]*c[0] + m[1]*c[1] + m[2]*c[2]),
  clamp(m[3]*c[0] + m[4]*c[1] + m[5]*c[2]),
  clamp(m[6]*c[0] + m[7]*c[1] + m[8]*c[2]),
 ]
}

function invertBlack(i) {
 return [
  i * 255,
  i * 255,
  i * 255,
 ]
}

function generateColors() {
 let possibleColors = [];

 let invert = invertRange[0];
 for (invert; invert <= invertRange[1]; invert+=invertStep) {
  let sepia = sepiaRange[0];
  for (sepia; sepia <= sepiaRange[1]; sepia+=sepiaStep) {
   let saturate = saturateRange[0];
   for (saturate; saturate <= saturateRange[1]; saturate+=saturateStep) {
    let hueRotate = hueRotateRange[0];
    for (hueRotate; hueRotate <= hueRotateRange[1]; hueRotate+=hueRotateStep) {
     let invertColor = invertBlack(invert);
     let sepiaColor = filter(sepiaMatrix(sepia), invertColor);
     let saturateColor = filter(saturateMatrix(saturate), sepiaColor);
     let hueRotateColor = filter(hueRotateMatrix(hueRotate), saturateColor);

     let colorObject = {
      filters: { invert, sepia, saturate, hueRotate },
      color: hueRotateColor
     }

     possibleColors.push(colorObject);
    }
   }
  }
 }

 return possibleColors;
}

function getFilters(targetColor, localTolerance) {
 possibleColors = possibleColors || generateColors();

 for (var i = 0; i < possibleColors.length; i++) {
  var color = possibleColors[i].color;
  if (
   Math.abs(color[0] - targetColor[0]) < localTolerance &&
   Math.abs(color[1] - targetColor[1]) < localTolerance &&
   Math.abs(color[2] - targetColor[2]) < localTolerance
  ) {
   return filters = possibleColors[i].filters;
   break;
  }
 }

 localTolerance += tolerance;
 return getFilters(targetColor, localTolerance)
}

function getNewColor(color) {
 var targetColor = color.split(',');
 targetColor = [
     parseInt(targetColor[0]), // [R]
     parseInt(targetColor[1]), // [G]
     parseInt(targetColor[2]), // [B]
    ]
    var filters = getFilters(targetColor, tolerance);
    var filtersCSS = 'filter: ' +
     'invert('+Math.floor(filters.invert*100)+'%) '+
     'sepia('+Math.floor(filters.sepia*100)+'%) ' +
     'saturate('+Math.floor(filters.saturate*100)+'%) ' +
     'hue-rotate('+Math.floor(filters.hueRotate)+'deg);';
    pixel.style = filtersCSS;
    filtersBox.innerText = filtersCSS
}

getNewColor(color.value);
#pixel {
  width: 50px;
  height: 50px;
  background: rgb(0,0,0);
}
<input type="text" id="color" placeholder="R,G,B" value="250,150,50" />
<button id="button">get filters</button>
<div id="pixel"></div>
<div id="filters"></div>

EDIT: This solution is not intended for production use and only illustrates an approach that can be taken to achieve what OP is asking for. As is, it is weak in some areas of the color spectrum. Better results can be achieved by more granularity in the step iterations or by implementing more filter functions for reasons described in detail in @MultiplyByZer0's answer.

EDIT2: OP is looking for a non brute force solution. In that case it's pretty simple, just solve this equation:

CSS Filter Matrix Equations

where

a = hue-rotation
b = saturation
c = sepia
d = invert
Community
  • 1
  • 1
Dave
  • 1,569
  • 10
  • 19
  • If I put in `255,0,255`, my digital color meter reports the outcome as `#d619d9` rather than `#ff00ff`. – Siguza May 14 '17 at 02:40
  • @Siguza It's definitely not perfect, edge case colors can be tweaked by adjusting the boundaries in the loops. – Dave May 14 '17 at 03:29
  • 4
    That equation is anything but "pretty simple" – MultiplyByZer0 May 16 '17 at 09:21
  • I think the equation above is also missing `clamp`? – glebm May 16 '17 at 10:45
  • 1
    Clamp has no place in there. And from what I remember from my college math, these equations are computed by numerical calculations aka "brute force" so good luck! – Dave May 16 '17 at 14:59
  • `clamp` is applied after every multiplication, so the result will be different than without it. If not for `clamp`, I have a feeling it would be possible to obtain a closed formula for the solution space. – glebm May 16 '17 at 18:57
  • Can this be easily modified to add a source colour? Like, instead of always starting from black, I could start from rgb(255, 0, 0)? – CupOfTea696 May 22 '17 at 14:24
  • @CupOfTea696 just add `brightness(0) saturate(100%) ` first to convert all colors to black. – Kaiido May 30 '17 at 02:23
  • @Kaiido that won't work with images that aren't just a single colour though – CupOfTea696 May 31 '17 at 13:29
  • wow...thank you so much for this tool! ...it would make a great public page on dave.github.io...will bookmark. – Elon Zito Oct 19 '17 at 19:54
  • Tested this on Codepen with color 'Sepia', #704214 / rgb(112,66,20) / hsl(30,69.7%,25.9%) (https://codepen.io/renevanderlende/pen/mdbVjoz). Result was: #744816 / rgb(116,72,22) / hsl(31.9, 68.1%, 27.1%). Too far off to be acceptable. Thanks for the work you've done, though. – Rene van der Lende Aug 14 '19 at 01:17
  • Multiplying the final invert value by 1.4 improves the output considerably. – Michael Shopsin Mar 17 '20 at 15:29
35

Note : OP asked me to undelete, but the bounty shall go to Dave's answer.


I know it's not what was asked in the body of the question, and certainly not what we were all waiting for, but there is one CSS filter which does exactly this : drop-shadow()

Caveats :

  • The shadow is drawn behind the existing content. This means we have to make some absolute positioning tricks.
  • All pixels will be treated the same, but OP said [we should not be ] "Caring about what happens to colors other than black."
  • Browser support. (I'm not sure about it, tested only under latests FF and chrome).

/* the container used to hide the original bg */

.icon {
  width: 60px;
  height: 60px;
  overflow: hidden;
}


/* the content */

.icon.green>span {
  -webkit-filter: drop-shadow(60px 0px green);
  filter: drop-shadow(60px 0px green);
}

.icon.red>span {
  -webkit-filter: drop-shadow(60px 0px red);
  filter: drop-shadow(60px 0px red);
}

.icon>span {
  -webkit-filter: drop-shadow(60px 0px black);
  filter: drop-shadow(60px 0px black);
  background-position: -100% 0;
  margin-left: -60px;
  display: block;
  width: 61px; /* +1px for chrome bug...*/
  height: 60px;
  background-image: url(data:image/svg+xml;base64,PHN2ZyBmaWxsPSIjMDAwMDAwIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIiB2ZXJzaW9uPSIxLjEiIHg9IjBweCIgeT0iMHB4IiB2aWV3Qm94PSIwIDAgOTAgOTAiIGVuYWJsZS1iYWNrZ3JvdW5kPSJuZXcgMCAwIDkwIDkwIiB4bWw6c3BhY2U9InByZXNlcnZlIj48Zz48cGF0aCBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGNsaXAtcnVsZT0iZXZlbm9kZCIgZD0iTTYxLjUxMSwyNi4xNWMtMC43MTQtMS43MzgtMS43MjMtMy4yOTgtMy4wMjYtNC42NzkgICBjLTEuMzAzLTEuMzY2LTIuODA5LTIuNDUyLTQuNTE1LTMuMjU5Yy0xLjc1NC0wLjgyMi0zLjYwMS0xLjI4OC01LjU0LTEuMzk2Yy0wLjI4LTAuMDMxLTAuNTUyLTAuMDQ3LTAuODE0LTAuMDQ3ICAgYy0wLjAxOCwwLTAuMDMxLDAtMC4wNDcsMGMtMC4zMjcsMC4wMTYtMC41NzQsMC4wMjMtMC43NDUsMC4wMjNjLTEuOTcxLDAuMTA4LTMuODQxLDAuNTc0LTUuNjA5LDEuMzk3ICAgYy0xLjcwOCwwLjgwNy0zLjIxMiwxLjg5My00LjUxNywzLjI1OWMtMS4zMTgsMS4zODEtMi4zMjcsMi45NDgtMy4wMjYsNC43MDJ2LTAuMDIzYy0wLjc0NCwxLjgxNS0xLjExOCwzLjcxNi0xLjExOCw1LjcwMiAgIGMtMC4wMTUsMi4wNjQsMC41MzcsNC4xODIsMS42NTQsNi4zNTVjMC41NzQsMS4xMzMsMS4yOTUsMi4yNSwyLjE2NCwzLjM1MmMwLjQ4MiwwLjYwNSwxLjAwMiwxLjIxLDEuNTYsMS44MTYgICBjMC4wMzEsMC4wMTYsMC4wNTUsMC4wMzksMC4wNzEsMC4wN2MwLjUyNywwLjQ5NiwwLjg5MiwwLjk3OCwxLjA5MywxLjQ0M2MwLjEwOCwwLjIzMywwLjE3OSwwLjUyLDAuMjEsMC44NjIgICBjMC4wNDYsMC4zNzEsMC4wNjksMC44MjIsMC4wNjksMS4zNXYxLjA0OGMwLDAuNjIsMC4xMTcsMS4yMTgsMC4zNDksMS43OTJjMC4yMzQsMC41NDMsMC41NiwxLjAyNCwwLjk3OCwxLjQ0M2gwLjAyNSAgIGMwLjQxOCwwLjQxOSwwLjg5MiwwLjc0NSwxLjQyLDAuOTc3aDAuMDIzYzAuNTU4LDAuMjQ5LDEuMTQ4LDAuMzczLDEuNzY5LDAuMzczaDcuMjg3YzAuNjIsMCwxLjIwOS0wLjEyNCwxLjc2OS0wLjM3MyAgIGMwLjU0My0wLjIzMSwxLjAyMy0wLjU1OCwxLjQ0My0wLjk3N2MwLjQxOC0wLjQxOSwwLjc0My0wLjksMC45NzgtMS40NDNjMC4yNDgtMC41NzQsMC4zNzEtMS4xNzIsMC4zNzEtMS43OTJ2LTEuMDQ4ICAgYzAtMC41MjcsMC4wMjMtMC45NzksMC4wNzEtMS4zNWMwLjAyOS0wLjM0MiwwLjA5Mi0wLjYzNywwLjE4Ni0wLjg4NWMwLjEwOC0wLjIzMywwLjI2NC0wLjQ3MywwLjQ2Ni0wLjcyMnYtMC4wMjMgICBjMC4xODctMC4yMzMsMC40MDMtMC40NjYsMC42NTEtMC42OTljMC4wMTYtMC4wMTYsMC4wMzEtMC4wMywwLjA0Ny0wLjA0NmMwLjU3NC0wLjYwNSwxLjEwMy0xLjIxLDEuNTgzLTEuODE2ICAgYzAuODY4LTEuMTAyLDEuNTkxLTIuMjE5LDIuMTY1LTMuMzUyYzEuMTE3LTIuMTczLDEuNjY3LTQuMjkxLDEuNjUyLTYuMzU1QzYyLjYwNSwyOS44NTksNjIuMjQsMjcuOTY2LDYxLjUxMSwyNi4xNXogICAgTTgxLjc4NSw0My4xNDJjMCw2Ljg3NS0xLjc1MywxMy4wMi01LjI2MSwxOC40MzZjLTEuMzgxLDIuMTQxLTMuMDMyLDQuMTY3LTQuOTU4LDYuMDc1Yy02Ljc1LDYuNzk3LTE0LjkxMywxMC4xOTUtMjQuNDg2LDEwLjE5NSAgIGMtNi40NTcsMC0xMi4yOTItMS41NDQtMTcuNTA1LTQuNjMyYy0wLjI0OSwwLjI5NS0wLjU2LDAuNTI3LTAuOTMyLDAuNjk4bC0xNi4xMzEsNy42NThjLTAuNTEyLDAuMjMzLTEuMDQ3LDAuMzAzLTEuNjA2LDAuMjEgICBjLTAuNTU5LTAuMDk0LTEuMDQtMC4zNDItMS40NDMtMC43NDVjLTAuNDA0LTAuNDAzLTAuNjUyLTAuODg2LTAuNzQ2LTEuNDQzYy0wLjA5My0wLjU2LTAuMDIzLTEuMDk0LDAuMjEtMS42MDVsNy42NTgtMTYuMjcxICAgYzAuMTQtMC4zMTEsMC4zMzQtMC41NzQsMC41ODMtMC43OTJjLTMuMTk3LTUuMjYxLTQuNzk2LTExLjE4OC00Ljc5Ni0xNy43ODRjMC05LjYyMSwzLjM3Ni0xNy44MDcsMTAuMTI1LTI0LjU1OCAgIGMwLjUyOC0wLjUyNywxLjA3MS0xLjA0LDEuNjMtMS41MzZjMi4yMDQtMS45NTYsNC41MzktMy41Nyw3LjAwNi00Ljg0MkMzNS45NDUsOS42OTIsNDEuMjYsOC40MzYsNDcuMDgsOC40MzYgICBjOS41NzMsMCwxNy43MzYsMy4zODIsMjQuNDg2LDEwLjE0OGM2LjQyNiw2LjM3OCw5LjgyNCwxNC4wMjksMTAuMTk1LDIyLjk1MkM4MS43NzgsNDIuMDYzLDgxLjc4NSw0Mi41OTksODEuNzg1LDQzLjE0MnogICAgTTUxLjM4NiwyNS4yNjZjLTAuNzE0LTAuMzI2LTEuNDU5LTAuNTEzLTIuMjM1LTAuNTU5Yy0wLjQ4LTAuMDMxLTAuODc2LTAuMjI1LTEuMTg4LTAuNTgzYy0wLjMxMS0wLjM0LTAuNDU3LTAuNzUyLTAuNDQxLTEuMjMzICAgYzAuMDMxLTAuNDY2LDAuMjI1LTAuODU0LDAuNTgyLTEuMTY1YzAuMzU3LTAuMzEsMC43NjktMC40NTcsMS4yMzQtMC40NDFjMS4yMjYsMC4wNzcsMi4zOTcsMC4zOCwzLjUxNSwwLjkwNyAgIGMxLjA2OSwwLjQ5NywyLjAxOCwxLjE3OSwyLjg0LDIuMDQ5YzAuODA3LDAuODY5LDEuNDM1LDEuODU0LDEuODg0LDIuOTU2YzAuNDY2LDEuMTMzLDAuNjk5LDIuMzIsMC42OTksMy41NjIgICBjMCwwLjQ2NS0wLjE3MSwwLjg2OS0wLjUxMiwxLjIxYy0wLjMyNSwwLjMyNi0wLjcyMiwwLjQ4OS0xLjE4OCwwLjQ4OWMtMC40OCwwLTAuODg0LTAuMTYzLTEuMjEtMC40ODkgICBjLTAuMzQyLTAuMzQxLTAuNTEzLTAuNzQ2LTAuNTEzLTEuMjFjMC0wLjc5Mi0wLjE0Ni0xLjU1Mi0wLjQ0MS0yLjI4MWMtMC4yNzktMC42OTktMC42ODMtMS4zMjctMS4yMTEtMS44ODYgICBTNTIuMDY3LDI1LjU5MSw1MS4zODYsMjUuMjY2eiBNNTcuNzg3LDM1LjM2OGMwLDAuNTEyLTAuMTg4LDAuOTU0LTAuNTYsMS4zMjZjLTAuMzU2LDAuMzU3LTAuOCwwLjUzNi0xLjMyNiwwLjUzNiAgIGMtMC41MTIsMC0wLjk0Ni0wLjE3OS0xLjMwMy0wLjUzNmMtMC4zNzQtMC4zNzItMC41Ni0wLjgxNC0wLjU2LTEuMzI2YzAtMC41MTMsMC4xODYtMC45NTYsMC41Ni0xLjMyNyAgIGMwLjM1Ni0wLjM1NywwLjc5MS0wLjUzNiwxLjMwMy0wLjUzNmMwLjUyNiwwLDAuOTcsMC4xNzgsMS4zMjYsMC41MzZDNTcuNiwzNC40MTMsNTcuNzg3LDM0Ljg1NSw1Ny43ODcsMzUuMzY4eiBNNTEuODk3LDU0LjcxMSAgIEg0My40Yy0wLjcxMiwwLTEuMzE4LDAuMjU2LTEuODE1LDAuNzY5Yy0wLjUxMiwwLjQ5Ny0wLjc2OSwxLjA5NC0wLjc2OSwxLjc5MmMwLDAuNzE0LDAuMjQ5LDEuMzE5LDAuNzQ2LDEuODE1bDAuMDIzLDAuMDI0ICAgYzAuNDk3LDAuNDk2LDEuMTAzLDAuNzQ0LDEuODE1LDAuNzQ0aDguNDk3YzAuNzE1LDAsMS4zMTgtMC4yNDgsMS44MTUtMC43NDRjMC40OTctMC41MTMsMC43NDUtMS4xMjYsMC43NDUtMS44NCAgIGMwLTAuNjk4LTAuMjQ4LTEuMjk1LTAuNzQ1LTEuNzkydi0wLjAyM0M1My4yMDEsNTQuOTU5LDUyLjU5Niw1NC43MTEsNTEuODk3LDU0LjcxMXogTTQyLjcyNiw2Mi40MzhoLTAuMDIzICAgYy0wLjQ5NywwLjQ5Ny0wLjc0NSwxLjEwMy0wLjc0NSwxLjgxNnMwLjI1NywxLjMxOCwwLjc2OSwxLjgxNWMwLjQ5NywwLjQ5NywxLjEwMiwwLjc0NSwxLjgxNiwwLjc0NWg2LjEyMiAgIGMwLjY5NywwLDEuMjk1LTAuMjQ4LDEuNzkyLTAuNzQ1aDAuMDIyYzAuNDk3LTAuNDk3LDAuNzQ2LTEuMTAyLDAuNzQ2LTEuODE1cy0wLjI0OS0xLjMxOS0wLjc0Ni0xLjgxNiAgIGMtMC41MTItMC41MTItMS4xMTctMC43NjgtMS44MTQtMC43NjhoLTYuMTIyQzQzLjgyOCw2MS42NzEsNDMuMjIzLDYxLjkyNyw0Mi43MjYsNjIuNDM4eiIvPjwvZz48L3N2Zz4=);
}
<div class="icon">
  <span></span>
</div>
<div class="icon green">
  <span></span>
</div>
<div class="icon red">
  <span></span>
</div>
Community
  • 1
  • 1
Kaiido
  • 123,334
  • 13
  • 219
  • 285
29

I started with this answer using a svg filter and made the following modifications:

SVG filter from data url

If you don't want to define the SVG filter somewhere in your the markup, you can use a data url instead (replace R, G, B and A with the desired color):

filter: url('data:image/svg+xml;utf8,\
  <svg xmlns="http://www.w3.org/2000/svg">\
    <filter id="recolor" color-interpolation-filters="sRGB">\
      <feColorMatrix type="matrix" values="\
        0 0 0 0 R\
        0 0 0 0 G\
        0 0 0 0 B\
        0 0 0 A 0\
      "/>\
    </filter>\
  </svg>\
  #recolor');

Grayscale fallback

If the version above does not work, you could also add a grayscale fallback.

The saturate and brightness functions turn any color to black (you don't have to include that if the color is already black), invert then brightens it with the desired lightness (L) and optionally you can also specify the opacity (A).

filter: saturate(0%) brightness(0%) invert(L) opacity(A);

SCSS mixin

If you want to specify the color dynamically, you could use the following SCSS mixin:

@mixin recolor($color: #000, $opacity: 1) {
  $r: red($color) / 255;
  $g: green($color) / 255;
  $b: blue($color) / 255;
  $a: $opacity;

  // grayscale fallback if SVG from data url is not supported
  $lightness: lightness($color);
  filter: saturate(0%) brightness(0%) invert($lightness) opacity($opacity);

  // color filter
  $svg-filter-id: "recolor";
  filter: url('data:image/svg+xml;utf8,\
    <svg xmlns="http://www.w3.org/2000/svg">\
      <filter id="#{$svg-filter-id}" color-interpolation-filters="sRGB">\
        <feColorMatrix type="matrix" values="\
          0 0 0 0 #{$r}\
          0 0 0 0 #{$g}\
          0 0 0 0 #{$b}\
          0 0 0 #{$a} 0\
        "/>\
      </filter>\
    </svg>\
    ##{$svg-filter-id}');
}

Example usage:

.icon-green {
  @include recolor(#00fa86, 0.8);
}

Advantages:

  • No Javascript.
  • No additional HTML elements.
  • If CSS filters are supported, but the SVG filter does not work, there is a grayscale fallback.
  • If you use the mixin, the usage is pretty straightforward (see example above).
  • The color is more readable and easier to modify than the sepia trick (RGBA components in pure CSS and you can even use HEX colors in SCSS).
  • Avoids the weird behavior of hue-rotate.

Caveats:

  • Not all browsers support SVG filters from a data url (especially the id hash), but it works in current Firefox and Chromium browsers (and maybe others).
  • If you want to specify the color dynamically, you have to use a SCSS mixin.
  • Pure CSS version is a bit ugly, if you want many different colors you have to include the SVG multiple times.
David Dostal
  • 711
  • 13
  • 17
  • 1
    oh that is perfect, this is exactly what I was looking which was to use everything in SASS awesome thanks a lot! – ghiscoding Aug 11 '20 at 14:30
  • 1
    @ghiscoding I'm glad it helped! – David Dostal Aug 13 '20 at 15:28
  • I tried adding this through the context of the canvas: ctx.filter='url()', but it is not working, if I log it, it shows filter:'none'. It works with other types of filters. Would this code work with jpeg? (Thanks by the way for the solution!) – semyd Oct 13 '20 at 19:10
  • @DSz I don't know if adding SVG filters even works for canvas (it might, but I don't know), most solutions here are specifically for recoloring external SVG images via an image tag. If your goal is to recolor an image on your canvas, you could look into this answer: https://stackoverflow.com/a/45710008/2690032 – David Dostal Oct 14 '20 at 11:59
  • @DavidDostal is there any way to use that mixin with css variable – Reza Nov 03 '20 at 03:59
  • @Reza I'm not sure I fully understand your question. If you still use SCSS, I think you can replace the SCSS variables wit CSS variables. If you want to avoid SCSS altogether, then the problem is you cannot use var() inside an url() and therefore can't interpolate the colors in the URL. If it is a question specific to SCSS or CSS variables and the problem is not in recoloring an icon, I suggest asking a new question (or searching if it isn't already answered). Hope this helps! – David Dostal Nov 04 '20 at 17:15
  • @DavidDostal if you call mixing like `@mixin recolor(var(--brand-color), 1)` doesn't work – Reza Nov 04 '20 at 19:53
  • 1
    @Reza Unfortunately I don't think that is possible, because the `url()` function in CSS doesn't support interpolation. The RGB values are interpolated in the mixin at compile time, so this is possible with SCSS variables. And because the values of CSS variables are available at runtime, you cannot get their value from SCSS at compile time either. So you either have to: get the color from a variable and interpolate it in the url both at compile or both at runtime, which is not possible. – David Dostal Nov 05 '20 at 13:34
  • 3
    Seems not to work on Safari (Apple MacBook). – fabpico Feb 01 '22 at 08:22
  • @FabianPicone See the **Caveats** section of the answer: "...Not all browsers support SVG filters from a data url...". That's also why it is a good idea to have a fallback. – David Dostal Feb 01 '22 at 13:24
  • 1
    @DavidDostal I know, just to add a specific info. – fabpico Feb 01 '22 at 14:56
27

You can make this all very simple by just using a SVG filter referenced from CSS. You only need a single feColorMatrix to do a recolor. This one recolors to yellow. The fifth column in the feColorMatrix holds the RGB target values on the unit scale. (for yellow - it's 1,1,0)

.icon {
  filter: url(#recolorme); 
}
<svg height="0px" width="0px">
<defs>
  #ffff00
  <filter id="recolorme" color-interpolation-filters="sRGB">
    <feColorMatrix type="matrix" values="0 0 0 0 1
                                         0 0 0 0 1
                                         0 0 0 0 0
                                         0 0 0 1 0"/>
  </filter>
</defs>
</svg>


<img class="icon" src="https://www.nouveauelevator.com/image/black-icon/android.png">
Michael Mullany
  • 30,283
  • 6
  • 81
  • 105
  • 1
    An interesting solution but it seems that it does not allow controlling the target color via CSS. – glebm Mar 03 '19 at 19:26
  • 1
    You have to define a new filter for each color you want to apply. But it's fully accurate. hue-rotate is an approximation that clips certain colors - meaning that you can't achieve certain colors accurately using it - as the answers above attest. What we really need is a recolor() CSS filter shorthand. – Michael Mullany Mar 04 '19 at 20:59
  • MultiplyByZer0's answer calculates a series of filters that achieve with very high accuracy, without modifying HTML. A true `hue-rotate` in browsers would be nice yeah. – glebm Mar 08 '19 at 02:02
  • 3
    it seems this only produces accurate RGB colors for black source images when you add "color-interpolation-filters"="sRGB" to the feColorMatrix. – John Smith Apr 16 '19 at 07:34
  • Edge 12-18 are left out as they are not supporting `url` function https://caniuse.com/#search=svg%20filter – Volker E. Jun 05 '19 at 18:38
5

To expand on David Dostals SCSS Mixin I removed the opacity parameter and updated the syntax to match the new SASS division syntax.

Removing the opacity parameter and taking opacity directly from the color value allows me to take any given hex/rgba color (e.g. from SASS variable) and apply the filter accordingly.

@use "sass:math";

@mixin recolor($color: #000) {
  $r: math.div(red($color), 255);
  $g: math.div(green($color), 255);
  $b: math.div(blue($color), 255);
  $a: alpha($color);
 
  // grayscale fallback if SVG from data url is not supported
  $lightness: lightness($color);
  filter: saturate(0%) brightness(0%) invert($lightness) opacity($a);

  // color filter
  $svg-filter-id: "recolor";
  filter: url('data:image/svg+xml;utf8,\
  <svg xmlns="http://www.w3.org/2000/svg">\
    <filter id="#{$svg-filter-id}" color-interpolation-filters="sRGB">\
      <feColorMatrix type="matrix" values="\
       0 0 0 0 #{$r}\
       0 0 0 0 #{$g}\
       0 0 0 0 #{$b}\
       0 0 0 #{$a} 0\
      "/>\
    </filter>\
  </svg>\
  ##{$svg-filter-id}');
}
// applied with
@include recolor($arbitrary-color);
silvan
  • 51
  • 2
  • 3
3

I noticed that the example of the treatment via an SVG filter was incomplete, I wrote mine (which works perfectly): (see Michael Mullany answer) so here is the way to get any color you want :

PickColor.onchange=()=>{
    RGBval.textContent = PickColor.value;

    let 
    HexT = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(PickColor.value),
    r = parseInt(HexT[1], 16),
    g = parseInt(HexT[2], 16),
    b = parseInt(HexT[3], 16);

    FilterVal.textContent =  SetFilter( r, g, b);
}
function SetFilter( r, g, b )
{
    const Matrix  = document.querySelector('#FilterSVG feColorMatrix');
    r = r/255;
    g = g/255;
    b = b/255;

    Matrix.setAttribute("values",  "0 0 0 0 "+r+" 0 0 0 0 "+g+ " 0 0 0 0 "+b+" 0 0 0 1 0");

    return "\n 0 0 0 0 "+r+"\n 0 0 0 0 "+g+ "\n 0 0 0 0 "+b+"\n 0 0 0 1 0"
}
#RGBval    { text-transform: uppercase }
#PickColor { height: 50px; margin: 0 20px }
th         { background-color: lightblue; padding: 5px 20px }
pre        { margin: 0 15px }
#ImgTest   { filter: url(#FilterSVG) }
<svg height="0px" width="0px">
    <defs>
      <filter id="FilterSVG" color-interpolation-filters="sRGB">
        <feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0"/>
      </filter>
    </defs>
  </svg>

  <table>
    <caption>SVG method</caption>
    <tr> <th>Image</th> <th>Color</th> </tr>
    <tr>
      <td><img src="https://upload.wikimedia.org/wikipedia/commons/thumb/8/8c/Doom_%E2%80%93_Game%E2%80%99s_logo.svg/120px-Doom_%E2%80%93_Game%E2%80%99s_logo.svg.png" id="ImgTest" /></td> 
      <td><input type="color" value="#000000"  id="PickColor" ></td>
    </tr>
    <tr> <td>.</td> <td>.</td> </tr>
    <tr> <th>Filter value </th> <th>#RBG target</th> </tr>
    <tr>
      <td><pre id="FilterVal">
    0 0 0 0 0
    0 0 0 0 0
    0 0 0 0 0
    0 0 0 1 0</pre></td>
        <td id="RGBval">#000000</td>
    </tr>
  </table>

Here is a second solution, by using SVG Filter only in code => URL.createObjectURL

const
  SVG_Filter = {
    init(ImgID) 
    {
      this.Img = document.getElementById(ImgID);
      let
        NS = 'http://www.w3.org/2000/svg';

      this.SVG    = document.createElementNS(NS,'svg'),
      this.filter = document.createElementNS(NS,'filter'),
      this.matrix = document.createElementNS(NS,'feColorMatrix');

      this.filter.setAttribute( 'id', 'FilterSVG');
      this.filter.setAttribute( 'color-interpolation-filters', 'sRGB');

      this.matrix.setAttribute( 'type', 'matrix');
      this.matrix.setAttribute('values', '0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0');

      this.filter.appendChild(this.matrix);
      this.SVG.appendChild(this.filter);

      this.xXMLs = new XMLSerializer();
    },
    SetColor( r, g, b )
    {
      r = r/255;
      g = g/255;
      b = b/255;

      this.matrix.setAttribute('values', '0 0 0 0 '+r+' 0 0 0 0 '+g+ ' 0 0 0 0 '+b+' 0 0 0 1 0');

      let
        xBlob = new Blob( [ this.xXMLs.serializeToString(this.SVG) ], { type: 'image/svg+xml' });
        xURL  = URL.createObjectURL(xBlob);

      this.Img.style.filter = 'url(' + xURL + '#FilterSVG)';

      return '\n 0 0 0 0 '+r+'\n 0 0 0 0 '+g+ '\n 0 0 0 0 '+b+'\n 0 0 0 1 0';
    }
  }

SVG_Filter.init('ImgTest');

PickColor.onchange=()=>{
  RGBval.textContent = PickColor.value;

  let 
    HexT = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(PickColor.value),
    r = parseInt(HexT[1], 16),
    g = parseInt(HexT[2], 16),
    b = parseInt(HexT[3], 16);

  FilterVal.textContent = SVG_Filter.SetColor( r, g, b );
}
#RGBval    { text-transform: uppercase }
#PickColor { height: 50px; margin: 0 20px }
th         { background-color: lightblue; padding: 5px 20px }
pre        { margin: 0 15px }
#PickColor { width:90px; height:28px; }
<table>
  <caption>SVG method</caption>
  <tr> <th>Image</th> <th>Color</th> </tr>
  <tr>
    <td><img src="https://upload.wikimedia.org/wikipedia/commons/thumb/8/8c/Doom_%E2%80%93_Game%E2%80%99s_logo.svg/120px-Doom_%E2%80%93_Game%E2%80%99s_logo.svg.png" id="ImgTest" /></td> 
    <td><input type="color" value="#E2218A" id="PickColor" ></td>
  </tr>
  <tr> <td>.</td> <td>.</td> </tr>
  <tr> <th>Filter value </th> <th>#RBG target</th> </tr>
  <tr>
    <td><pre id="FilterVal">
  0 0 0 0 0
  0 0 0 0 0
  0 0 0 0 0
  0 0 0 1 0</pre></td>
      <td id="RGBval">#000000</td>
  </tr>
</table>
Mister Jojo
  • 20,093
  • 6
  • 21
  • 40
3

"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)}. <b>${lossMsg}</b>`);
});
.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 <code>background-color</code>:</p>
<div class="pixel realPixel"></div>

<p>Filtered pixel, color applied through CSS <code>filter</code>:</p>
<div class="pixel filterPixel"></div>

<p class="filterDetail"></p>
<p class="lossDetail"></p>
Microfelix
  • 31
  • 1
  • 1
  • 1
    Welcome to SO and thanks for taking the time to answer to one question for the first time! Just a small tip: it's nice to add some comments to describe what the code does, in order to help the answer to be complete and easier to understand. – juagicre Mar 09 '22 at 16:30
2

Based on previous, amazing answers, I've tried to make the code a bit easier to understand for me.

I've made it more functional, added TypeScript typing where I was feeling confident enough and also renamed some variables when I understood what is happening.

import ColorParser from 'color';

function parseColorToRgb(input: string) {
  const colorInstance = new ColorParser(input);

  return new RgbColor(
    colorInstance.red(),
    colorInstance.green(),
    colorInstance.blue(),
  );
}

function clampRgbPart(value: number): number {
  if (value > 255) {
    return 255;
  }

  if (value < 0) {
    return 0;
  }

  return value;
}

class RgbColor {
  constructor(public red: number, public green: number, public blue: number) {}

  toString() {
    return `rgb(${Math.round(this.red)}, ${Math.round(
      this.green,
    )}, ${Math.round(this.blue)})`;
  }

  set(r: number, g: number, b: number) {
    this.red = clampRgbPart(r);
    this.green = clampRgbPart(g);
    this.blue = clampRgbPart(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.14,
      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: number[]) {
    const newR = clampRgbPart(
      this.red * matrix[0] + this.green * matrix[1] + this.blue * matrix[2],
    );
    const newG = clampRgbPart(
      this.red * matrix[3] + this.green * matrix[4] + this.blue * matrix[5],
    );
    const newB = clampRgbPart(
      this.red * matrix[6] + this.green * matrix[7] + this.blue * matrix[8],
    );
    this.red = newR;
    this.green = newG;
    this.blue = newB;
  }

  brightness(value = 1) {
    this.linear(value);
  }

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

  linear(slope = 1, intercept = 0) {
    this.red = clampRgbPart(this.red * slope + intercept * 255);
    this.green = clampRgbPart(this.green * slope + intercept * 255);
    this.blue = clampRgbPart(this.blue * slope + intercept * 255);
  }

  invert(value = 1) {
    this.red = clampRgbPart((value + (this.red / 255) * (1 - 2 * value)) * 255);
    this.green = clampRgbPart(
      (value + (this.green / 255) * (1 - 2 * value)) * 255,
    );
    this.blue = clampRgbPart(
      (value + (this.blue / 255) * (1 - 2 * value)) * 255,
    );
  }

  applyFilters(filters: Filters) {
    this.set(0, 0, 0);
    this.invert(filters[0] / 100);
    this.sepia(filters[1] / 100);
    this.saturate(filters[2] / 100);
    this.hueRotate(filters[3] * 3.6);
    this.brightness(filters[4] / 100);
    this.contrast(filters[5] / 100);
  }

  hsl(): HSLData {
    // Code taken from https://stackoverflow.com/a/9493060/2688027, licensed under CC BY-SA.
    const r = this.red / 255;
    const g = this.green / 255;
    const b = this.blue / 255;
    const max = Math.max(r, g, b);
    const min = Math.min(r, g, b);
    let h: number,
      s: number,
      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,
    };
  }
}

interface HSLData {
  h: number;
  s: number;
  l: number;
}

interface ColorFilterSolveResult {
  loss: number;
  filters: Filters;
}

const reusedColor = new RgbColor(0, 0, 0);

function formatFilterValue(value: number, multiplier = 1) {
  return Math.round(value * multiplier);
}

type Filters = [
  invert: number,
  sepia: number,
  saturate: number,
  hueRotate: number,
  brightness: number,
  contrast: number,
];

function convertFiltersListToCSSFilter(filters: Filters) {
  function fmt(idx: number, multiplier = 1) {
    return Math.round(filters[idx] * multiplier);
  }
  const [invert, sepia, saturate, hueRotate, brightness, contrast] = filters;
  return `filter: invert(${formatFilterValue(
    invert,
  )}%) sepia(${formatFilterValue(sepia)}%) saturate(${formatFilterValue(
    saturate,
  )}%) hue-rotate(${formatFilterValue(
    hueRotate,
    3.6,
  )}deg) brightness(${formatFilterValue(
    brightness,
  )}%) contrast(${formatFilterValue(contrast)}%);`;
}

function calculateLossForFilters(
  filters: Filters,
  targetColor: RgbColor,
  targetHSL: HSLData,
) {
  reusedColor.applyFilters(filters);
  const actualHSL = reusedColor.hsl();

  return (
    Math.abs(reusedColor.red - targetColor.red) +
    Math.abs(reusedColor.green - targetColor.green) +
    Math.abs(reusedColor.blue - targetColor.blue) +
    Math.abs(actualHSL.h - targetHSL.h) +
    Math.abs(actualHSL.s - targetHSL.s) +
    Math.abs(actualHSL.l - targetHSL.l)
  );
}



export function solveColor(input: string) {
  const targetColor = parseColorToRgb(input);
  const targetHSL = targetColor.hsl();

  function improveInitialSolveResult(initialResult: ColorFilterSolveResult) {
    const A = initialResult.loss;
    const c = 2;
    const A1 = A + 1;
    const a: Filters = [
      0.25 * A1,
      0.25 * A1,
      A1,
      0.25 * A1,
      0.2 * A1,
      0.2 * A1,
    ];
    return findColorFilters(A, a, c, initialResult.filters, 500);
  }

  function findColorFilters(
    initialLoss: number,
    filters: Filters,
    c: number,
    values: Filters,
    iterationsCount: number,
  ): ColorFilterSolveResult {
    const alpha = 1;
    const gamma = 0.16666666666666666;

    let best = null;
    let bestLoss = Infinity;
    const deltas = new Array(6);
    const highArgs = new Array(6) as Filters;
    const lowArgs = new Array(6) as Filters;

    for (
      let iterationIndex = 0;
      iterationIndex < iterationsCount;
      iterationIndex++
    ) {
      const ck = c / Math.pow(iterationIndex + 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 =
        calculateLossForFilters(highArgs, targetColor, targetHSL) -
        calculateLossForFilters(lowArgs, targetColor, targetHSL);

      for (let i = 0; i < 6; i++) {
        const g = (lossDiff / (2 * ck)) * deltas[i];
        const ak =
          filters[i] / Math.pow(initialLoss + iterationIndex + 1, alpha);
        values[i] = fix(values[i] - ak * g, i);
      }

      const loss = calculateLossForFilters(values, targetColor, targetHSL);
      if (loss < bestLoss) {
        best = values.slice(0) as Filters;
        bestLoss = loss;
      }
    }
    return { filters: best!, loss: bestLoss };

    function fix(value: number, idx: 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;
    }
  }

  function solveInitial(): ColorFilterSolveResult {
    const A = 5;
    const c = 15;
    const a: Filters = [60, 180, 18000, 600, 1.2, 1.2];

    let best: ColorFilterSolveResult = {
      loss: Infinity,
      filters: [0, 0, 0, 0, 0, 0],
    };
    for (let i = 0; best.loss > 25 && i < 3; i++) {
      const initial: Filters = [50, 20, 3750, 50, 100, 100];
      const result = findColorFilters(A, a, c, initial, 1000);
      if (result.loss < best.loss) {
        best = result;
      }
    }
    return best;
  }

  const result = improveInitialSolveResult(solveInitial());

  return convertFiltersListToCSSFilter(result.filters)
}

I'm also using npm color package so the main function will accept pretty much any valid color input string (hex, rgb etc)


A few notes I would add:

  • you probably want to add some solid caching layer to that
  • if you have limited count of colors you need filters for, it could probably be good idea to 'pre-calculate' filters for them and make some hard-coded map of those colors so end user will never have to run calculation for them as those are quite heavy

Here is my caching layer


const colorFiltersCache = new Map<string, string>();

export function cachedSolveColor(input: string) {
  const existingResult = colorFiltersCache.get(input);

  if (existingResult) {
    return existingResult;
  }

  const newResult = solveColor(input);

  colorFiltersCache.set(input, newResult);

  return newResult;
}
Adam Pietrasiak
  • 12,773
  • 9
  • 78
  • 91
1

@David Dostal's answer and @silvan's answer are great solutions and don't have this issue of loss like some other answers do. However, I wanted to use this with LESS instead of SASS, so I converted the mixin. Here's the LESS version if anyone is interested:

.recolor(@color: #333) {
    @r: red(@color) / 255;
    @g: green(@color) / 255;
    @b: blue(@color) / 255;
    @a: alpha(@color);
  
    // grayscale fallback if SVG from data url is not supported
    @lightness: lightness(@color);
    filter: saturate(0%) brightness(0%) invert(@lightness) opacity(@a);
  
    // color filter
    @svg-filter-id: "recolor";
    filter: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg"><filter id="@{svg-filter-id}" color-interpolation-filters="sRGB"><feColorMatrix type="matrix" values="0 0 0 0 @{r} 0 0 0 0 @{g} 0 0 0 0 @{b} 0 0 0 @{a} 0"/></filter></svg> #@{svg-filter-id}');
}

Example Usage:

.icon-green {
  .recolor(rgba(0, 250, 134, 0.8));
}
kuenzign
  • 111
  • 2
  • 4
0

Issue with background-color: If you set a Background color, which is inherited by the svg, then set the background of the svg to transparent, before apply the filter (otherwise invert will modifiy your background)

Routerdieb
  • 39
  • 5
-1

just use

fill: #000000

The fill property in CSS is for filling in the color of a SVG shape. The fill property can accept any CSS color value.

Lyzard Kyng
  • 1,518
  • 1
  • 9
  • 14
  • 12
    This might work with CSS internal to an SVG image, but it doesn't work as CSS applied externally to an `img` element by the browser. – David Moles Jan 30 '20 at 23:40
-1
    -webkit-filter: invert(100%); /* Safari/Chrome */
    filter: invert(100%) brightness(0%);

This is the easiet method

  • This does not provide an answer to the question. Once you have sufficient [reputation](https://stackoverflow.com/help/whats-reputation) you will be able to [comment on any post](https://stackoverflow.com/help/privileges/comment); instead, [provide answers that don't require clarification from the asker](https://meta.stackexchange.com/questions/214173/why-do-i-need-50-reputation-to-comment-what-can-i-do-instead). - [From Review](/review/late-answers/34341549) – Moritz Ringler May 09 '23 at 15:28
  • That transforms any color to black, the question is the opposite of what this answers. – glebm May 10 '23 at 20:53