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 pastinginspiration 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:
rgb(0,255,0)
, @Dave's producesrgb(29,218,34)
and producesrgb(1,255,0)
rgb(0,0,255)
, @Dave's producesrgb(37,39,255)
and mine producesrgb(5,6,255)
rgb(19,11,118)
, @Dave's producesrgb(36,27,102)
and mine producesrgb(20,11,112)
Demo
Usage
Explanation
We'll begin by writing some Javascript.
Explanation:
Color
class represents a RGB color.toString()
function returns the color in a CSSrgb(...)
color string.hsl()
function returns the color, converted to HSL.clamp()
function ensures that a given color value is within bounds (0-255).Solver
class will attempt to solve for a target color.css()
function returns a given filter in a CSS filter string.Implementing
grayscale()
,sepia()
, andsaturate()
The heart of CSS/SVG filters are filter primitives, which represent low-level modifications to an image.
The filters
grayscale()
,sepia()
, andsaturate()
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:
1
. There is no point of calculating or storing it.A
) either, since we are dealing with RGB, not RGBA.<feColorMatrix>
filters leave columns 4 and 5 as zeroes. Therefore, we can further reduce the filter matrix to 3x3.Implementation:
(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 implementgrayscale()
,sepia()
, andsaturate()
, which simply invoke it with a given filter matrix: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:
Math.sin()
orMath.cos()
.Math.sin(angle)
andMath.cos(angle)
should be computed once and then cached.Implementation:
Implementing
brightness()
andcontrast()
The
brightness()
andcontrast()
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:This is easy to implement:
Once this is implemented,
brightness()
andcontrast()
can be implemented as well:Implementing
invert()
The
invert()
filter is implemented by<feComponentTransfer>
with<feFuncX type="table" />
.The spec states:
An explanation of this formula:
invert()
filter defines this table: [value, 1 - value]. This is tableValues or v.Thus, we can simplify the formula to:
Inlining the table's values, we are left with:
One more simplification:
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:
Interlude: @Dave's brute-force algorithm
@Dave's code generates 176,660 filter combinations, including:
invert()
filters (0%, 10%, 20%, ..., 100%)sepia()
filters (0%, 10%, 20%, ..., 100%)saturate()
filters (5%, 10%, 15%, ..., 100%)hue-rotate()
filters (0deg, 5deg, 10deg, ..., 360deg)It calculates filters in the following order:
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:
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:
Implementation:
We will try to minimize the loss function, such that:
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:
I made some modifications/optimizations to SPSA:
deltas
,highArgs
,lowArgs
), instead of recreating them with each iteration.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:
Implementation:
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
infix()
, 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:
Now tune the constants to your heart's content.
Some tips:
--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!
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
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)
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 :