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
Brute-force search for parameters of a fixed filter list: https://stackoverflow.com/a/43959856/181228
Cons: inefficient, only generates some of the 16,777,216 possible colors (676,248 withhueRotateStep=1
).A faster search solution using SPSA: https://stackoverflow.com/a/43960991/181228 Bounty awarded
A
drop-shadow
solution: https://stackoverflow.com/a/43959853/181228
Cons: Does not work on Edge. Requires non-filter
CSS changes and minor HTML changes.
You can still get an Accepted answer by submitting a non brute-force solution!
Resources
How
hue-rotate
andsepia
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 thehue-rotate
function non-linear.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/181228A 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 producesrgb(29,218,34)
and producesrgb(1,255,0)
- For
rgb(0,0,255)
, @Dave's producesrgb(37,39,255)
and mine producesrgb(5,6,255)
- For
rgb(19,11,118)
, @Dave's producesrgb(36,27,102)
and mine producesrgb(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.css;
Explanation
We'll begin by writing 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 CSSrgb(...)
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).
- Its
- The
Solver
class will attempt to solve for a target color.- Its
css()
function returns a given filter in a CSS filter string.
- Its
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:
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:
Some notes:
- The angle of rotation is given in degrees. It must be converted to radians before passed to
Math.sin()
orMath.cos()
. Math.sin(angle)
andMath.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 withsaturate()
, 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%, exceptsaturate
(where the maximum is 7500%),brightness
andcontrast
(where the maximum is 200%), andhueRotate
(where the values are wrapped around instead of clamped).
I use SPSA in a two-stage process:
- The "wide" stage, that tries to "explore" the search space. It will make limited retries of SPSA if the results are not satisfactory.
- 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
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:
where
a = hue-rotation b = saturation c = sepia d = invert
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>
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">
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://www.nouveauelevator.com/image/black-icon/android.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://www.nouveauelevator.com/image/black-icon/android.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>
来源:https://stackoverflow.com/questions/42966641/how-to-transform-black-into-any-given-color-using-only-css-filters