如何仅使用 CSS 过滤器将黑色转换为任何给定颜色 [英] How to transform black into any given color using only CSS filters

查看:28
本文介绍了如何仅使用 CSS 过滤器将黑色转换为任何给定颜色的处理方法,对大家解决问题具有一定的参考价值,需要的朋友们下面随着小编来一起学习吧!

问题描述

我的问题是:给定目标 RGB 颜色,仅使用 为此.我们可以自己实现矩阵乘法算法.

实施:

函数乘法(矩阵){让 newR = this.clamp(this.r * matrix[0] + this.g * matrix[1] + this.b * matrix[2]);让 newG = this.clamp(this.r * matrix[3] + this.g * matrix[4] + this.b * matrix[5]);让 newB = this.clamp(this.r * matrix[6] + this.g * matrix[7] + this.b * matrix[8]);this.r = newR;this.g = newG;this.b = newB;}

(我们使用临时变量来保存每一行乘法的结果,因为我们不希望改变this.r等影响后续的计算.)

现在我们已经实现了,我们可以实现grayscale()sepia()saturate(),它只是用给定的过滤矩阵调用它:

函数灰度(值= 1){this.multiply([0.2126 + 0.7874 * (1 - 值), 0.7152 - 0.7152 * (1 - 值), 0.0722 - 0.0722 * (1 - 值),0.2126 - 0.2126 * (1 - 值), 0.7152 + 0.2848 * (1 - 值), 0.0722 - 0.0722 * (1 - 值),0.2126 - 0.2126 * (1 - 值), 0.7152 - 0.7152 * (1 - 值), 0.0722 + 0.9278 * (1 - 值)]);}函数棕褐色(值 = 1){this.multiply([0.393 + 0.607 * (1 - 值), 0.769 - 0.769 * (1 - 值), 0.189 - 0.189 * (1 - 值),0.349 - 0.349 * (1 - 值), 0.686 + 0.314 * (1 - 值), 0.168 - 0.168 * (1 - 值),0.272 - 0.272 * (1 - 值), 0.534 - 0.534 * (1 - 值), 0.131 + 0.869 * (1 - 值)]);}函数饱和(值 = 1){this.multiply([0.213 + 0.787 * 值,0.715 - 0.715 * 值,0.072 - 0.072 * 值,0.213 - 0.213 * 值,0.715 + 0.285 * 值,0.072 - 0.072 * 值,0.213 - 0.213 * 值,0.715 - 0.715 * 值,0.072 + 0.928 * 值]);}

实现hue-rotate()

例如,元素 a00 的计算方式如下:

注意事项:

  • 旋转角度以度为单位,在传递给 Math.sin()Math.cos() 之前必须将其转换为弧度.
  • Math.sin(angle)Math.cos(angle) 应该计算一次然后缓存.

实施:

函数hueRotate(angle = 0) {角度 = 角度/180 * Math.PI;让 sin = Math.sin(angle);让 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]);}

实现brightness()contrast()

brightness()contrast() 过滤器由 <feFuncX type="linear";/>.

每个 元素接受 slopeintercept 属性.然后它通过一个简单的公式计算每个新的颜色值:

值 = 斜率 * 值 + 截距

这很容易实现:

函数线性(斜率= 1,截距= 0){this.r = this.clamp(this.r * 斜率 + 截距 * 255);this.g = this.clamp(this.g * 斜率 + 截距 * 255);this.b = this.clamp(this.b * 斜率 + 截距 * 255);}

一旦实现,brightness()contrast() 也可以实现:

function Brightness(value = 1) { this.linear(value);}函数对比度(值 = 1){ this.linear(值,-(0.5 * 值)+ 0.5);}

实现invert()

invert() 过滤器是由 实现href="https://www.w3.org/TR/filter-effects/#element-attrdef-fecomponenttransfer-type" rel="noreferrer"><feFuncX type="table";/>.

规范规定:

<块引用>

下面,C为初始组件,C'为重映射组件;都在闭区间[0,1]内.

对于table",函数由属性tableValues 中给出的值之间的线性插值定义.该表有 n + 1 个值(即 v0 到 vn)指定 n 大小均匀的插值区域.插值使用以下公式:

对于一个值 C 找到 k 使得:

k/n ≤ C <(k + 1)/n

结果 C' 由下式给出:

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

对这个公式的解释:

  • invert() 过滤器定义了这个表:[value, 1 - value].这是 tableValuesv.
  • 公式定义了n,这样n + 1 就是表格的长度.由于表的长度为 2,n = 1.
  • 公式定义了kkk + 1 是表的索引.由于表格有 2 个元素,k = 0.

因此,我们可以将公式简化为:

<块引用>

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

内联表的值,我们剩下:

<块引用>

C' = 值 + C * (1 - 值 - 值)

再简化一点:

<块引用>

C' = 值 + C * (1 - 2 * 值)

规范将 CC' 定义为 RGB 值,范围为 0-1(而不是 0-255).因此,我们必须在计算前按比例缩小值,然后再按比例放大.

这样我们就实现了:

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

插曲:@Dave 的蛮力算法

@Dave 的代码生成 176,660 个过滤器组合,包括:

  • 11 invert() 过滤器(0%、10%、20%、...、100%)
  • 11 sepia() 过滤器(0%、10%、20%、...、100%)
  • 20 个 saturate() 过滤器(5%、10%、15%、...、100%)
  • 73 hue-rotate() 过滤器(0deg、5deg、10deg、...、360deg)

它按以下顺序计算过滤器:

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

然后它遍历所有计算出的颜色.一旦发现生成的颜色在容差范围内(所有 RGB 值都在目标颜色的 5 个单位以内),它就会停止.

然而,这是缓慢且低效的.因此,我提出了我自己的答案.

实施 SPSA

首先,我们必须定义一个损失函数,它返回由过滤器组合和目标颜色.如果过滤器是完美的,损失函数应该返回0.

我们将用两个指标的总和来衡量色差:

  • RGB 差异,因为目标是产生最接近的 RGB 值.
  • HSL 差异,因为许多 HSL 值对应于过滤器(例如,色调与 hue-rotate() 大致相关,饱和度与 saturate() 相关,等等)指导算法.

损失函数将采用一个参数 - 一组过滤器百分比.

我们将使用以下过滤顺序:

filter: invert(a%) sepia(b%) saturate(c%) 色调旋转(θdeg) 亮度(e%) 对比度(f%);

实施:

功能损失(过滤器){让颜色 = 新颜色(0, 0, 0);color.invert(过滤器[0]/100);color.sepia(过滤器[1]/100);color.saturate(过滤器[2]/100);color.hueRotate(过滤器[3] * 3.6);color.brightness(过滤器[4]/100);颜色对比(过滤器 [5]/100);让 colorHSL = color.hsl();返回 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);}

我们将尝试最小化损失函数,使得:

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

SPSA 算法(网站更多信息论文实施文件参考代码)在这方面非常擅长.它旨在优化具有局部最小值、噪声/非线性/多元损失函数等的复杂系统.它已被用于调优国际象棋引擎.与许多其他算法不同的是,描述它的论文实际上是可以理解的(尽管需要付出很大的努力).

实施:

function spsa(A, a, c, values, iters) {常量阿尔法 = 1;常量伽马 = 0.16666666666666666;让最好=空;让 bestLoss = 无穷大;让 deltas = new Array(6);让 highArgs = new Array(6);让lowArgs = new Array(6);for(let k = 0; k 0.5 ?1:-1;highArgs[i] = values[i] + ck * deltas[i];lowArgs[i] = values[i] - ck * deltas[i];}让 lossDiff = this.loss(highArgs) - this.loss(lowArgs);for(让我= 0;我<6;我++){让 g = lossDiff/(2 * ck) * deltas[i];让 ak = a[i]/Math.pow(A + k + 1, alpha);values[i] = fix(values[i] - ak * g, i);}让损失 = this.loss(values);if(loss < bestLoss) { best = values.slice(0);bestLoss = 损失;}} return { values: best, loss: bestLoss };功能修复(值,idx){让最大值 = 100;if(idx === 2/* 饱和 */) { max = 7500;}else if(idx === 4/* 亮度 */|| idx === 5/* 对比度 */) { max = 200;}if(idx === 3/* 色调旋转 */) {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;}返回值;}}

我对 SPSA 进行了一些修改/优化:

  • 使用最好的结果,而不是最后一个.
  • 重用所有数组(deltashighArgslowArgs),而不是在每次迭代时重新创建它们.
  • a 使用一组值,而不是单个值.这是因为所有滤波器都不同,因此它们应该以不同的速度移动/收敛.
  • 在每次迭代后运行一个 fix 函数.它将所有值限制在 0% 和 100% 之间,除了 saturate(其中最大值为 7500%)、brightnesscontrast(其中最大值为 200%)和 hueRotate(其中值被环绕而不是被限制).

我在两阶段过程中使用 SPSA:

  1. 宽"阶段,试图探索"搜索空间.如果结果不令人满意,它将对 SPSA 进行有限的重试.
  2. 狭隘"阶段,从宽阶段中取最好的结果,并试图提炼".它.它使用 Aa 的动态值.

实施:

function solve() {让结果 = this.solveNarrow(this.solveWide());返回 {值:result.values,损失:结果损失,过滤器:this.css(result.values)};}函数solveWide() {常量 A = 5;常量 c = 15;const a = [60, 180, 18000, 600, 1.2, 1.2];让最好= {损失:无限};for(let i = 0; best.loss > 25 && i <3; i++) {让初始 = [50, 20, 3750, 50, 100, 100];let result = this.spsa(A, a, c, initial, 1000);if(result.loss < best.loss) { best = result;}} 回报最好;}函数solveNarrow(宽){const A = 宽.损失;常量 c = 2;const A1 = A + 1;const a = [0.25 * A1, 0.25 * A1, A1, 0.25 * A1, 0.2 * A1, 0.2 * A1];返回 this.spsa(A, a, c,wide.values, 500);}

调整 SPSA

警告:不要弄乱 SPSA 代码,尤其是它的常量,除非您确定知道自己在做什么.

重要的常量有Aac、初始值、重试阈值、max<fix() 中的/code>,以及每个阶段的迭代次数.所有这些值都经过仔细调整以产生良好的结果,而随意乱用它们几乎肯定会降低算法的实用性.

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.4768521401985275Average 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

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

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

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

Example

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

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

(demo)

Non-goals

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

Results so far

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

Resources

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

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

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

    Browser implementations: Chromium, Firefox.

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

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

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

解决方案

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

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

Furthermore, for some colors, my algorithm performs better:

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

Demo

"use strict";

class Color {
    constructor(r, g, b) { this.set(r, g, b); }
    toString() { return `rgb(${Math.round(this.r)}, ${Math.round(this.g)}, ${Math.round(this.b)})`; }

    set(r, g, b) {
        this.r = this.clamp(r);
        this.g = this.clamp(g);
        this.b = this.clamp(b);
    }

    hueRotate(angle = 0) {
        angle = angle / 180 * Math.PI;
        let sin = Math.sin(angle);
        let cos = Math.cos(angle);

        this.multiply([
            0.213 + cos * 0.787 - sin * 0.213, 0.715 - cos * 0.715 - sin * 0.715, 0.072 - cos * 0.072 + sin * 0.928,
            0.213 - cos * 0.213 + sin * 0.143, 0.715 + cos * 0.285 + sin * 0.140, 0.072 - cos * 0.072 - sin * 0.283,
            0.213 - cos * 0.213 - sin * 0.787, 0.715 - cos * 0.715 + sin * 0.715, 0.072 + cos * 0.928 + sin * 0.072
        ]);
    }

    grayscale(value = 1) {
        this.multiply([
            0.2126 + 0.7874 * (1 - value), 0.7152 - 0.7152 * (1 - value), 0.0722 - 0.0722 * (1 - value),
            0.2126 - 0.2126 * (1 - value), 0.7152 + 0.2848 * (1 - value), 0.0722 - 0.0722 * (1 - value),
            0.2126 - 0.2126 * (1 - value), 0.7152 - 0.7152 * (1 - value), 0.0722 + 0.9278 * (1 - value)
        ]);
    }

    sepia(value = 1) {
        this.multiply([
            0.393 + 0.607 * (1 - value), 0.769 - 0.769 * (1 - value), 0.189 - 0.189 * (1 - value),
            0.349 - 0.349 * (1 - value), 0.686 + 0.314 * (1 - value), 0.168 - 0.168 * (1 - value),
            0.272 - 0.272 * (1 - value), 0.534 - 0.534 * (1 - value), 0.131 + 0.869 * (1 - value)
        ]);
    }

    saturate(value = 1) {
        this.multiply([
            0.213 + 0.787 * value, 0.715 - 0.715 * value, 0.072 - 0.072 * value,
            0.213 - 0.213 * value, 0.715 + 0.285 * value, 0.072 - 0.072 * value,
            0.213 - 0.213 * value, 0.715 - 0.715 * value, 0.072 + 0.928 * value
        ]);
    }

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

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

    linear(slope = 1, intercept = 0) {
        this.r = this.clamp(this.r * slope + intercept * 255);
        this.g = this.clamp(this.g * slope + intercept * 255);
        this.b = this.clamp(this.b * slope + intercept * 255);
    }

    invert(value = 1) {
        this.r = this.clamp((value + (this.r / 255) * (1 - 2 * value)) * 255);
        this.g = this.clamp((value + (this.g / 255) * (1 - 2 * value)) * 255);
        this.b = this.clamp((value + (this.b / 255) * (1 - 2 * value)) * 255);
    }

    hsl() { // Code taken from https://stackoverflow.com/a/9493060/2688027, licensed under CC BY-SA.
        let r = this.r / 255;
        let g = this.g / 255;
        let b = this.b / 255;
        let max = Math.max(r, g, b);
        let min = Math.min(r, g, b);
        let h, s, l = (max + min) / 2;

        if(max === min) {
            h = s = 0;
        } else {
            let d = max - min;
            s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
            switch(max) {
                case r: h = (g - b) / d + (g < b ? 6 : 0); break;
                case g: h = (b - r) / d + 2; break;
                case b: h = (r - g) / d + 4; break;
            } h /= 6;
        }

        return {
            h: h * 100,
            s: s * 100,
            l: l * 100
        };
    }

    clamp(value) {
        if(value > 255) { value = 255; }
        else if(value < 0) { value = 0; }
        return value;
    }
}

class Solver {
    constructor(target) {
        this.target = target;
        this.targetHSL = target.hsl();
        this.reusedColor = new Color(0, 0, 0); // Object pool
    }

    solve() {
        let result = this.solveNarrow(this.solveWide());
        return {
            values: result.values,
            loss: result.loss,
            filter: this.css(result.values)
        };
    }

    solveWide() {
        const A = 5;
        const c = 15;
        const a = [60, 180, 18000, 600, 1.2, 1.2];

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

    solveNarrow(wide) {
        const A = wide.loss;
        const c = 2;
        const A1 = A + 1;
        const a = [0.25 * A1, 0.25 * A1, A1, 0.25 * A1, 0.2 * A1, 0.2 * A1];
        return this.spsa(A, a, c, wide.values, 500);
    }

    spsa(A, a, c, values, iters) {
        const alpha = 1;
        const gamma = 0.16666666666666666;

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

        for(let k = 0; k < iters; k++) {
            let ck = c / Math.pow(k + 1, gamma);
            for(let i = 0; i < 6; i++) {
                deltas[i] = Math.random() > 0.5 ? 1 : -1;
                highArgs[i] = values[i] + ck * deltas[i];
                lowArgs[i]  = values[i] - ck * deltas[i];
            }

            let lossDiff = this.loss(highArgs) - this.loss(lowArgs);
            for(let i = 0; i < 6; i++) {
                let g = lossDiff / (2 * ck) * deltas[i];
                let ak = a[i] / Math.pow(A + k + 1, alpha);
                values[i] = fix(values[i] - ak * g, i);
            }

            let loss = this.loss(values);
            if(loss < bestLoss) { best = values.slice(0); bestLoss = loss; }
        } return { values: best, loss: bestLoss };

        function fix(value, idx) {
            let max = 100;
            if(idx === 2 /* saturate */) { max = 7500; }
            else if(idx === 4 /* brightness */ || idx === 5 /* contrast */) { max = 200; }

            if(idx === 3 /* hue-rotate */) {
                if(value > max) { value = value % max; }
                else if(value < 0) { value = max + value % max; }
            } else if(value < 0) { value = 0; }
            else if(value > max) { value = max; }
            return value;
        }
    }

    loss(filters) { // Argument is array of percentages.
        let color = this.reusedColor;
        color.set(0, 0, 0);

        color.invert(filters[0] / 100);
        color.sepia(filters[1] / 100);
        color.saturate(filters[2] / 100);
        color.hueRotate(filters[3] * 3.6);
        color.brightness(filters[4] / 100);
        color.contrast(filters[5] / 100);

        let colorHSL = color.hsl();
        return Math.abs(color.r - this.target.r)
            + Math.abs(color.g - this.target.g)
            + Math.abs(color.b - this.target.b)
            + Math.abs(colorHSL.h - this.targetHSL.h)
            + Math.abs(colorHSL.s - this.targetHSL.s)
            + Math.abs(colorHSL.l - this.targetHSL.l);
    }

    css(filters) {
        function fmt(idx, multiplier = 1) { return Math.round(filters[idx] * multiplier); }
        return `filter: invert(${fmt(0)}%) sepia(${fmt(1)}%) saturate(${fmt(2)}%) hue-rotate(${fmt(3, 3.6)}deg) brightness(${fmt(4)}%) contrast(${fmt(5)}%);`;
    }
}

$("button.execute").click(() => {
    let rgb = $("input.target").val().split(",");
    if (rgb.length !== 3) { alert("Invalid format!"); return; }

    let color = new Color(rgb[0], rgb[1], rgb[2]);
    let solver = new Solver(color);
    let result = solver.solve();

    let lossMsg;
    if (result.loss < 1) {
        lossMsg = "This is a perfect result.";
    } else if (result.loss < 5) {
        lossMsg = "The is close enough.";
    } else if(result.loss < 15) {
        lossMsg = "The color is somewhat off. Consider running it again.";
    } else {
        lossMsg = "The color is extremely off. Run it again!";
    }

    $(".realPixel").css("background-color", color.toString());
    $(".filterPixel").attr("style", result.filter);
    $(".filterDetail").text(result.filter);
    $(".lossDetail").html(`Loss: ${result.loss.toFixed(1)}. <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 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:

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

这篇关于如何仅使用 CSS 过滤器将黑色转换为任何给定颜色的文章就介绍到这了,希望我们推荐的答案对大家有所帮助,也希望大家多多支持IT屋!

查看全文
登录 关闭
扫码关注1秒登录
发送“验证码”获取 | 15天全站免登陆