其实我们的绿幕抠图,可以更完美🌟

1,089 阅读8分钟

前言

之前我写过一篇关于Fabric.js实时播放视频并扣除绿幕的文章,里面的视频绿幕抠图功能是通过fabricjs内置的removeColor滤镜实现的;但是removeColor滤镜实现绿幕抠图的效果其实并不是很好,有明显的颜色残留:

image.png 而且在我们认知中相近的颜色,也不能一起去除:

image.png

其实绿幕抠图,可以更完美,本篇就来通过fabricjs自定义滤镜,实现更好的抠图效果。

Demo示例在此,可以自行体验,使用方式1即可:fabricjs-demo.videocovert.online/#/removebg

removeColor滤镜实现

实现自定义滤镜之前,我们先来讨论下removeColor滤镜的实现方案。

function(options) {
  var imageData = options.imageData,
      data = imageData.data, i,
      distance = this.distance * 255,
      r, g, b,
      source = new fabric.Color(this.color).getSource(),
      lowC = [
        source[0] - distance,
        source[1] - distance,
        source[2] - distance,
      ],
      highC = [
        source[0] + distance,
        source[1] + distance,
        source[2] + distance,
      ];


  for (i = 0; i < data.length; i += 4) {
    r = data[i];
    g = data[i + 1];
    b = data[i + 2];

    if (r > lowC[0] &&
        g > lowC[1] &&
        b > lowC[2] &&
        r < highC[0] &&
        g < highC[1] &&
        b < highC[2]) {
      data[i + 3] = 0;
    }
  }
}

这是fabricjs removeColor滤镜canvas实现的源码,从这段代码中,我们可以了解到removeColor滤镜的原理其实比较简单,就是比较像素点的RGB色值是否在需要移除的色值范围内,如果在,则将像素点的透明度设置为0。

该方法抠图存在两个缺陷:

  1. 色彩范围问题,代码只是简单地比较RGB值的上下限,没有考虑色彩空间转换或感知色差。RGB色彩空间中的距离不一定是视觉上最合理的,说简单点就是RGB色值不符合人对颜色的感官,我们认为是相近的颜色,但是在RGB中并不接近。
  2. 透明图统一设置为0,代码将符合条件的像素透明度(data[i + 3])直接设置为0,完全透明。这样会导致无法保留原有的透明度信息,也无法进行部分透明的处理,图形显示不够顺滑。

所以我们需要更好的处理方式。

自定义实现

完整实现代码:

import { fabric } from 'fabric';

fabric.Image.filters.RemoveGreen = fabric.util.createClass(fabric.Image.filters.BaseFilter, /** @lends fabric.Image.filters.RemoveGreen.prototype */ {

  /**
   * Filter type
   * @param {String} type
   * @default
   */
  type: 'RemoveGreen',

  /**
   * Color to remove, in any format understood by fabric.Color.
   * @param {String} type
   * @default
   */
  color: '#00FF00',

  /**
   * Fragment source for the brightness program
   */
  fragmentSource: `precision highp float;
varying vec2 vTexCoord;

uniform sampler2D uTexture;
uniform vec3 keyColor;

// 色度的相似度计算
uniform float similarity;
// 透明度的平滑度计算
uniform float smoothness;
// 降低绿幕饱和度,提高抠图准确度
uniform float spill;

vec2 RGBtoUV(vec3 rgb) {
  return vec2(
    rgb.r * -0.169 + rgb.g * -0.331 + rgb.b *  0.5    + 0.5,
    rgb.r *  0.5   + rgb.g * -0.419 + rgb.b * -0.081  + 0.5
  );
}

void main() {
  // 获取当前像素的rgba值
  vec4 rgba = texture2D(uTexture, vTexCoord);
  // 计算当前像素与绿幕像素的色度差值
  vec2 chromaVec = RGBtoUV(rgba.rgb) - RGBtoUV(keyColor);
  // 计算当前像素与绿幕像素的色度距离(向量长度), 越相像则色度距离越小
  float chromaDist = sqrt(dot(chromaVec, chromaVec));
  // 设置了一个相似度阈值,baseMask为负,则表明是绿幕,为正则表明不是绿幕
  float baseMask = chromaDist - similarity;
  // 如果baseMask为负数,fullMask等于0;baseMask为正数,越大,则透明度越低
  float fullMask = pow(clamp(baseMask / smoothness, 0., 1.), 1.5);
  rgba.a = fullMask; // 设置透明度
  // 如果baseMask为负数,spillVal等于0;baseMask为整数,越小,饱和度越低
  float spillVal = pow(clamp(baseMask / spill, 0., 1.), 1.5);
  float desat = clamp(rgba.r * 0.2126 + rgba.g * 0.7152 + rgba.b * 0.0722, 0., 1.); // 计算当前像素的灰度值
  rgba.rgb = mix(vec3(desat, desat, desat), rgba.rgb, spillVal);
  gl_FragColor = rgba;
}`,
  similarity: 0.02,
  smoothness: 0.02,
  spill: 0.02,

  /**
   * distance to actual color, as value up or down from each r,g,b
   * between 0 and 1
   **/
  distance: 0.02,

  /**
   * For color to remove inside distance, use alpha channel for a smoother deletion
   * NOT IMPLEMENTED YET
   **/
  useAlpha: false,

  /**
   * Constructor
   * @memberOf fabric.Image.filters.RemoveWhite.prototype
   * @param {Object} [options] Options object
   * @param {Number} [options.color=#RRGGBB] Threshold value
   * @param {Number} [options.distance=10] Distance value
   */

  /**
   * Applies filter to canvas element
   * @param {Object} canvasEl Canvas element to apply filter to
   */
  applyTo2d: function(options) {
    var imageData = options.imageData,
        data = imageData.data, i,
        distance = this.distance * 255,
        r, g, b,
        source = new fabric.Color(this.color).getSource(),
        lowC = [
          source[0] - distance,
          source[1] - distance,
          source[2] - distance,
        ],
        highC = [
          source[0] + distance,
          source[1] + distance,
          source[2] + distance,
        ];


    for (i = 0; i < data.length; i += 4) {
      r = data[i];
      g = data[i + 1];
      b = data[i + 2];

      if (r > lowC[0] &&
          g > lowC[1] &&
          b > lowC[2] &&
          r < highC[0] &&
          g < highC[1] &&
          b < highC[2]) {
        data[i + 3] = 0;
      }
    }
  },

  /**
   * Return WebGL uniform locations for this filter's shader.
   *
   * @param {WebGLRenderingContext} gl The GL canvas context used to compile this filter's shader.
   * @param {WebGLShaderProgram} program This filter's compiled shader program.
   */
  getUniformLocations: function(gl, program) {
    return {
      similarity: gl.getUniformLocation(program, 'similarity'),
      smoothness: gl.getUniformLocation(program, 'smoothness'),
      spill: gl.getUniformLocation(program, 'spill'),
      keyColor: gl.getUniformLocation(program, 'keyColor'),
    };
  },

  /**
   * Send data from this filter to its shader program's uniforms.
   *
   * @param {WebGLRenderingContext} gl The GL canvas context used to compile this filter's shader.
   * @param {Object} uniformLocations A map of string uniform names to WebGLUniformLocation objects
   */
  sendUniformData: function(gl, uniformLocations) {
    // var source = new fabric.Color(this.color).getSource(),
    //     distance = parseFloat(this.distance),
    //     lowC = [
    //       0 + source[0] / 255 - distance,
    //       0 + source[1] / 255 - distance,
    //       0 + source[2] / 255 - distance,
    //       1
    //     ],
    //     highC = [
    //       source[0] / 255 + distance,
    //       source[1] / 255 + distance,
    //       source[2] / 255 + distance,
    //       1
    //     ];
    gl.uniform3fv(
      uniformLocations.keyColor,
      (new fabric.Color(this.color).getSource()).slice(0, 3).map((v) => v / 255),
    );
    gl.uniform1f(uniformLocations.similarity, this.similarity);
    gl.uniform1f(uniformLocations.smoothness, this.smoothness);
    gl.uniform1f(uniformLocations.spill, this.spill);
  },

  /**
   * Returns object representation of an instance
   * @return {Object} Object representation of an instance
   */
  toObject: function() {
    return fabric.util.object.extend(this.callSuper('toObject'), {
      color: this.color,
      similarity: this.similarity,
      smoothness: this.smoothness,
      spill: this.spill,
    });
  }
});

/**
 * Returns filter instance from an object representation
 * @static
 * @param {Object} object Object to create an instance from
 * @param {Function} [callback] to be invoked after filter creation
 * @return {fabric.Image.filters.RemoveGreen} Instance of fabric.Image.filters.RemoveWhite
 */
fabric.Image.filters.RemoveGreen.fromObject = fabric.Image.filters.BaseFilter.fromObject;

使用UV色值

代码中的WebGL滤镜部分就是我们的实现了,在这段代码中,我们将RGB色值转换为UV色值。

vec2 RGBtoUV(vec3 rgb) {
  return vec2(
    rgb.r * -0.169 + rgb.g * -0.331 + rgb.b *  0.5    + 0.5,
    rgb.r *  0.5   + rgb.g * -0.419 + rgb.b * -0.081  + 0.5
  );
}

使用UV色值(UV color space)进行抠图相对于传统的RGB色值有几个显著的优势,尤其在复杂的抠图场景中,这些优势可以帮助实现更精准、更自然的抠图效果。

更好的色彩分离能力:

UV色值是在YUV色彩空间中的两个色度分量(U和V),与亮度(Y)分开。由于UV通道仅承载颜色信息,而不涉及亮度,这使得在抠图时更容易区分和分离颜色相近但亮度不同的像素。对于复杂背景或颜色相似的前景,UV色值可以更有效地分离目标和背景。

减少光照影响:

在RGB色彩空间中,亮度信息混杂在R、G、B通道中,这意味着光照变化会显著影响颜色的表现,进而影响抠图效果。而UV色值分离了亮度信息,因此在光照条件不均匀的场景下,抠图效果更稳定,不易受到光照变化的干扰。

更接近人眼视觉的颜色感知:

YUV色彩空间设计的初衷是更接近人眼对颜色的感知,特别是在低亮度情况下,人眼对色度变化更敏感。使用UV色值抠图,更符合人眼的感知规律,可以得到更自然、更细腻的边缘处理效果。

处理高动态范围场景的优势:

在RGB空间中,高动态范围的场景(例如,高亮度的光源或反光物体)容易出现颜色溢出或失真问题。而在YUV空间中,亮度和色度是独立处理的,UV色值不受亮度极端变化的直接影响,这有助于在高动态范围的场景中实现更稳定的抠图效果。

减少背景复杂度的干扰:

在复杂背景下,RGB色彩空间可能无法很好地区分前景和背景,尤其是在背景颜色与前景颜色相似时。UV色值通过色度信息进行抠图,能够更好地分离前景对象,减少背景颜色的干扰。

更精细的控制:similarity、smoothness、spill

除了目标色值,这段代码我们还提供了三个参数similarity、smoothness、spill,用来更加精准的控制抠图效果。

  • similarity(和removeColor滤镜的distance作用基本一致):

    • 作用: 控制色度差异的阈值,也就是说,用来确定哪些像素应该被视为接近指定的键颜色(通常是绿幕颜色)。
    • 原理: 当计算当前像素与绿幕像素的色度差值时,如果差值小于这个相似度阈值(similarity),则说明该像素的颜色与键颜色非常接近,应被视为需要抠出的部分,即其透明度会被设置为0或接近0。
    • 影响: similarity 的值越小,只有与键颜色非常接近的像素才会被抠出;值越大,则更多的颜色范围会被认为接近键颜色,可能会导致更多的区域被抠出。
  • smoothness(平滑度):

    • 作用: 控制透明度的平滑过渡区域,以避免产生明显的边界。
    • 原理: smoothness 控制的是色度差值转换为透明度时的平滑度。它决定了从完全透明到完全不透明的过渡区域的宽度。较低的 smoothness 值会使过渡更加突然,而较高的值会产生更平滑的透明度过渡。
    • 影响: smoothness 的值越大,透明度的过渡区域越宽,图像的边缘处理会更加平滑,减少硬边现象;值越小,透明度过渡区域会更窄,边缘可能会显得更加锐利。
  • spill(溢出):

    • 作用: 控制与键颜色接近的颜色的饱和度,减少色度溢出的效果。
    • 原理: spill 控制的是对与键颜色相近但不完全相同的颜色的处理,特别是那些受色度溢出影响的像素。在计算色度差值后,spill 用于调整这些像素的饱和度,将其颜色向灰度拉近,以减少颜色溢出的视觉效果。
    • 影响: spill 的值越大,图像中与键颜色相近的部分将更加显著地降低饱和度,减少溢出的绿光效应;值越小,这些区域会保留更多的原始颜色,可能会导致更明显的溢出现象。

效果对比

相比较removeColor滤镜,我们的滤镜在相同的参数下(color是同一个值、distance和similarity是相同值),自定义滤镜没有明显的绿色残留:

image.png 对于颜色的处理,也更接近人类的认知,removeColor滤镜不能处理的颜色,在相同的参数下,自定义滤镜也能进行处理:

image.png

Demo示例在此,可以自行体验,使用方式2即可:fabricjs-demo.videocovert.online/#/removebg

参考

WebGL Chromakey 实时绿幕抠图

Production-ready green screen in the browser