Jin's blog

实现图像智能配色

标题党了,说是智能配色,其实只是简单的色相变更而已。只不过在实际生产阶段,还会根据不同的场景自动计算色相值,所以就叫智能配色了 🥹。

本文需要特别感谢 ChatGPT 提供的技术支持(笑晕)。

最近在做模板配色相关的事情,简单来说就是一个模板可以快速更换成不同颜色主题的模板。例如下图所示

这是默认的模板样式: screenshot-20240116-200341-il6t.png

选一个想要的配色: 53846ce681854c8d62fb667a52d6187bac15a43e3a9f4820cdbe4a13d5bcec33-humr.png

这是选择了主题之后更新的样式: screenshot-20240116-200424-9mgx.png

截图所示的功能是京东玲珑编辑器中的智能配色功能。

因为业务上也需要实现相似的能力,所以就简单分析了一下它的实现原理。

关于 HSB 色彩模型

想要了解智能更换配色我们需要先了解一下 HSB 色彩模型。

HSB 指的是色相(Hue)、饱和度(Saturation)、明度(Brightness)。

而图片中的每一个像素都可以用 HSB 模型来表示。

20240116201351_rec_.gif

所以我们可以通过调整 HSB 模型中的色相、饱和度、明度来改变像素点的颜色。

智能配色的原理

在清楚了 HSB 配色后,我们就可以来分析一下智能配色的原理了。

我们可以只调整 HSB 模型中的色相(Hue)值,来改变像素点的颜色。

我们可以通过 sharp (opens in a new tab) 这个库来实现 HSB 模型的转换。

由于 sharp 无法直接将图片的像素点转换为 hsb 模型,所以我们需要先将图片转换为 raw 格式,然后再转换为 hsb 模型。

先找地方抄两个 hsb 转换的函数:

function rgbToHsb(r, g, b) {
  (r /= 255), (g /= 255), (b /= 255);
  let max = Math.max(r, g, b),
    min = Math.min(r, g, b);
  let h,
    s,
    v = max;
 
  let d = max - min;
  s = max === 0 ? 0 : d / max;
 
  if (max === min) {
    h = 0; // achromatic
  } else {
    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 * 360, s * 100, v * 100];
}
 
// HSB 到 RGB 的转换函数
function hsbToRgb(h, s, v) {
  let r, g, b;
 
  let i = Math.floor(h * 6);
  let f = h * 6 - i;
  let p = v * (1 - s);
  let q = v * (1 - f * s);
  let t = v * (1 - (1 - f) * s);
 
  switch (i % 6) {
    case 0:
      (r = v), (g = t), (b = p);
      break;
    case 1:
      (r = q), (g = v), (b = p);
      break;
    case 2:
      (r = p), (g = v), (b = t);
      break;
    case 3:
      (r = p), (g = q), (b = v);
      break;
    case 4:
      (r = t), (g = p), (b = v);
      break;
    case 5:
      (r = v), (g = p), (b = q);
      break;
  }
 
  return [r * 255, g * 255, b * 255];
}

然后我们就可以通过 sharp 来实现智能配色了。

function changeHue(imageBuffer, newHue) {
  return sharp(imageBuffer)
    .toColourspace("srgb")
    .raw()
    .toBuffer({ resolveWithObject: true })
    .then(({ data, info }) => {
      for (let i = 0; i < data.length; i += info.channels) {
        let [r, g, b] = [data[i], data[i + 1], data[i + 2]];
        let [h, s, v] = rgbToHsb(r, g, b);
 
        // 设置新的色相值
        h = newHue;
 
        // 转换回 RGB
        [r, g, b] = hsbToRgb(h / 360, s / 100, v / 100);
 
        [data[i], data[i + 1], data[i + 2]] = [r, g, b];
      }
      return sharp(data, {
        raw: {
          width: info.width,
          height: info.height,
          channels: info.channels,
        },
      }).toColourspace("srgb");
    });
}
 
const hueArr = [360];
 
hueArr.forEach((hue) => {
  sharp("input.png")
    .toBuffer()
    .then((buffer) => {
      return changeHue(buffer, hue);
    })
    .then((data) => {
      return data.toFile(`${hue}_output.png`);
    })
    .catch((err) => {
      console.log(err);
    });
});

看看效果:

Group 2036083931.png

可以看到,图片中的色相已经被变更了,awesome!

我们再找一张实际生产环境中图片看看效果:

原图default_img-oazb.png

效果图Group 2036083930-jr4t.png

请忽略中间人物图的色相变更,因为在实际生产场景中,我们只会变更背景图&装饰图的色相。

更进一步,渐变色

仔细看玲珑的智能配色,其实还支持渐变色的替换,也就是说不是直接更换 Hue 值就可以来,还需要根据渐变色的配置入参。

const params = {
  // 渐变色的色相值
  hueArr: [360, 180, 90, 0],
  // 角度
  angle: 90,
}

所以我们就需要再实现一个 changeHueWithGradient 函数,来实现渐变色的替换。

但是这个函数转换听起来好像很复杂,因为需要进行一些数学换算,得到当前像素点的色相值。但是问题不大,因为我们有 ChatGPT。把我们的诉求告诉 ChatGPT 就好,他会帮我们实现:

先看最简单的两种色相渐变如何实现:

// 两种色相的渐变
function calculateGradientHue(startHue, endHue, x, y, width, height, angle) {
  // 将角度转换为弧度
  const radians = angle * (Math.PI / 180);
  // 计算渐变的线性插值
  const ratio =
    (x * Math.cos(radians) + y * Math.sin(radians)) /
    (width * Math.cos(radians) + height * Math.sin(radians));
  // 计算并返回当前位置的色相
  return startHue + ratio * (endHue - startHue);
}

但是如果能支持多种色相渐变,且支持定义每个色相在图片中的位置,那就更好了。

function calculateGradientHue(hues, positions, x, y, width, height, angle) {
  // 将角度转换为弧度
  const radians = angle * (Math.PI / 180);
  // 计算渐变的线性插值
  const ratio =
    (x * Math.cos(radians) + y * Math.sin(radians)) /
    (width * Math.cos(radians) + height * Math.sin(radians));
 
  // 确定当前像素所处的渐变段
  for (let i = 0; i < positions.length - 1; i++) {
    if (ratio >= positions[i] && ratio <= positions[i + 1]) {
      // 计算当前段内的相对位置
      const segmentRatio =
        (ratio - positions[i]) / (positions[i + 1] - positions[i]);
      // 计算并返回当前位置的色相
      return hues[i] + segmentRatio * (hues[i + 1] - hues[i]);
    }
  }
 
  // 如果比例超出范围,则返回最后一个色相值
  return hues[hues.length - 1];
}

完成!

再简单改一下 changeHue 的实现,让它能够处理渐变色:

function changeHue(imageBuffer, startHue, positions, angle) {
  // ...
  if (Array.isArray(startHue)) {
    // 计算当前像素的色相
    h = calculateGradientHue(
      startHue,
      positions,
      x,
      y,
      info.width,
      info.height,
      angle,
    );
  } else {
    // 设置新的色相值
    h = startHue;
  }
  //...
}
 
sharp("input.png")
  .toBuffer()
  .then((buffer) => {
    return changeHue(buffer, [50, 100, 200, 300], [0, 0.3, 0.5, 1], 90);
  })
  .then((data) => {
    return data.toFile(`output.png`);
  })
  .catch((err) => {
    console.log(err);
  });

渐变色效果-e3mp.png

打完收工!

可惜的是 sharp 暂时只能在 node 环境下执行,因为底层依赖了 https://github.com/libvips/libvips (opens in a new tab) 这个库,所以在浏览器环境下是无法使用的。

至于怎么在游览器中实现智能配色,还得再想想。

效果展示

纯色 Group 2036083932-egt4.png

渐变色 Group 2036083933-9thw.png

使用 WebGL 实现

1.18 update

又研究了一把,发现可以使用 WebGL 着色器来实现来实现同样的效果。不过由于我对 WebGL 不是很熟悉,所以过程实在太过艰难。

我和 ChatGPT 对谈了几个小时才写出来,不过暂时只实现了纯色的效果,渐变色的效果还没实现,但是原理应该近似。

在这里不得不再感叹一下科技改版世界,如果不是有 ChatGPT 的帮助,我连第一版都要做很长时间的技术调研,到处查 API。 更别谈第二版的 WebGL 实现了,我连 WebGL 的基本概念都不知道,想把 WebGL 的代码直接实现出来不知道要等到何年何月。

我这里是直接通过 fabric.js 的 WebGLRenderer 来实现的,直接贴代码,原理什么的后面再慢慢补吧,先把代码贴出来,有兴趣的可以自己看看。

fabric.Image.filters.CustomHue = fabric.util.createClass(fabric.Image.filters.BaseFilter, {
    type: 'CustomHue',
    // 构造函数,用于初始化 hue 值
    initialize: function(options) {
        options = options || {};
        this.hue = options.hue || 0;
    },
    getUniformLocations: function ( gl, program ) {
        return {
            hue: gl.getUniformLocation( program, 'hue' ),
        };
    },
    sendUniformData: function(gl, uniformLocations) {
        const hue = this.hue;
        gl.uniform1f( uniformLocations.hue, hue );
    },
    fragmentSource: 'precision highp float;\n' +
        'uniform sampler2D uTexture;\n' +
        'uniform float hue;\n' +
        'varying vec2 vTexCoord;\n' +
        'vec3 rgbToHsv(vec3 c) {\n' +
        'vec4 K = vec4(0.0, -1.0/3.0, 2.0/3.0, -1.0);\n' +
        'vec4 p = mix(vec4(c.bg, K.wz), vec4(c.gb, K.xy), step(c.b, c.g));\n' +
        'vec4 q = mix(vec4(p.xyw, c.r), vec4(c.r, p.yzx), step(p.x, c.r));\n' +
        'float d = q.x - min(q.w, q.y);\n' +
        'float e = 1.0e-10;\n' +
        'return vec3(abs(q.z + (q.w - q.y) / (6.0 * d + e)), d / (q.x + e), q.x);\n' +
        '}\n' +
        'vec3 hsvToRgb(vec3 c) {\n' +
        'vec4 K = vec4(1.0, 2.0 / 3.0, 1.0 / 3.0, 3.0);\n' +
        'vec3 p = abs(fract(c.xxx + K.xyz) * 6.0 - K.www);\n' +
        'return c.z * mix(K.xxx, clamp(p - K.xxx, 0.0, 1.0), c.y);\n' +
        '}\n' +
        'void main() {\n' +
        'vec4 color = texture2D(uTexture, vTexCoord);\n' +
        'vec3 hsv = rgbToHsv(color.rgb);\n' +
        'hsv.x = hue / 360.0;\n' + // 将度转换为归一化的色相值
        'vec3 rgb = hsvToRgb(hsv);\n' +
        'gl_FragColor = vec4(rgb, color.a);\n' +
        '}'
});
 
fabric.Image.filters.CustomHue.fromObject = fabric.Image.filters.BaseFilter.fromObject;
 
fabric.Image.fromURL('来个图片链接', function(img) {
    const customHueFilter = new fabric.Image.filters.CustomHue({
        hue: 180
    });
    img.filters.push(customHueFilter);
    img.applyFilters();
},{
    crossOrigin: 'anonymous'
});

今天算是正经第一次认识到 WebGL,还挺好用,后面找个时间好好学习一下,靠谱.jpg。