前端抠图-颜色容差法与洪泛法

3,211 阅读6分钟

无边界抠图

如 利用canvas的getImageData,我们可以获取到一张图片每一个像素的信息,而通过对每一个像素信息的对比,我们就可以找到需要消去的像素点。比如下面这一张图片,如果我们想要扣去白色部分(粉色是body的背景颜色)。

let canvas = document.querySelector('#canvas');
let context = canvas.getContext('2d');
let img = document.createElement('img');
img.src = './head2.png';
img.onload = function () {
    canvas.height = img.height;
    canvas.width = img.width;
    context.drawImage(img, 0, 0);

    cutout(canvas, [255, 255, 255], 0.2); // 对白色进行抠除,容差为0.2
}

function cutout(canvas, color, range = 0) {
    let context = canvas.getContext('2d');
    let imageInfo = context.getImageData(0, 0, canvas.width, canvas.height);
    // pixiArr是一个数组,每四个数组元素代表一个像素点,这四个数组元素分别对应一个像素的r,g,b,a值。
    let pixiArr = imageInfo.data;
    for (let i = 0; i < pixiArr.length; i += 4) {
    // 匹配到目标像素就将目标像素的alpha设为0
        if (testColor([pixiArr[i], pixiArr[i + 1], pixiArr[i + 2]], color, range)) pixiArr[i + 3] = 0;
    }

    context.putImageData(imageInfo, 0, 0);
}

function testColor(current, target, range) {
    for (let i = 0; i < 3; i++) {
        if (!((1 - range) * target[i] <= current[i] && (1 + range) * target[i] >= current[i])) return false;
    }

    return true;
}

testColor(current, target, range) 方法三个参数分别为待检测像素点的rgb数组目标像素点的rgb数组容差范围,这里的容差只是简单用r、g、b的值分别乘以(1 + range)和(1 - range)来计算并对比,不同的容差参数会得到不同的效果↓

range = 0.095

range = 0.1

range = 0.2

当然对于底色是标准的纯色的图片就不需要容差了。

边界处理

但是有时候我们希望有一个边界,让抠图操作不对边界内部的像素造成影响。比如上面的图片,我们希望不会对人物头像内部的像素造成影响。 如果我们一行一行来看,是不是只要在碰到不是边界像素的时候停止操作,就可以达到效果了呢?

我们对每一行分别进行扫描,定义一个左指针left指向这一行的第一个像素,定义一个右指针right指向这一行的最后一个像素,并用一个leftF标识左边是否碰到边界,一个rightF标识右边是否碰到边界,当没碰到边界时指针就一直向内收缩,直到两个指针都碰到边界或者左右指针重合就跳到下一行,直到所有行都扫描完毕。

function cutout(canvas, color, range = 0) {
    let context = canvas.getContext('2d');
    let imageInfo = context.getImageData(0, 0, canvas.width, canvas.height);
    let pixiArr = imageInfo.data;
    
    for (let row = 0; row < canvas.height; row++) {
        let left = row * 4 * canvas.width; // 指向行首像素
        let right = left + 4 * canvas.width - 1 - 3; // 指向行尾像素
        let leftF = false; // 左指针是否碰到边界的标识
        let rightF = false; // 右指针是否碰到边界的标识

        while (!leftF || !rightF) { // 当左右指针都为true,即都碰到边界时结束
            if (!leftF) {
                if (testColor([pixiArr[left], pixiArr[left + 1], pixiArr[left + 2]], color, range)) {
                    pixiArr[left + 3] = 0; // 此像素的alpha设为0
                    left += 4; // 移到下一个像素
                } else leftF = true; // 碰到边界
            }
            if (!rightF) {
                if (testColor([pixiArr[right], pixiArr[right + 1], pixiArr[right + 2]], color, range)) {
                    pixiArr[right + 3] = 0;
                    right -= 4;
                } else rightF = true;
            }

            if (left == right) { // 左右指针重合
                leftF = true;
                rightF = true;
            };
        }
    }

    context.putImageData(imageInfo, 0, 0);
}

虽然大概完成了我们的需求,但是看一下上面头发那为啥会多了一块白色

因为我们仅仅只进行了行扫描,当左指针碰到头发时就会停止扫描,但是头发弧度里面的就无法被扫描到了,我们还需要进行列扫描,改造一下上面的方法:

function cutout(canvas, color, range = 0) {
    let context = canvas.getContext('2d');
    let imageInfo = context.getImageData(0, 0, canvas.width, canvas.height);
    let pixiArr = imageInfo.data;
    
    for (let row = 0; row < canvas.height; row++) {
        let left = row * 4 * canvas.width;
        let right = left + 4 * canvas.width - 1 - 3;
        let leftF = false;
        let rightF = false;

        while (!leftF || !rightF) {
            if (!leftF) {
                if (testColor([pixiArr[left], pixiArr[left + 1], pixiArr[left + 2]], color, range)) {
                    pixiArr[left + 3] = 0;
                    left += 4;
                } else leftF = true;
            }
            if (!rightF) {
                if (testColor([pixiArr[right], pixiArr[right + 1], pixiArr[right + 2]], color, range)) {
                    pixiArr[right + 3] = 0;
                    right -= 4;
                } else rightF = true;
            }

            if (left == right) {
                leftF = true;
                rightF = true;
            };
        }
    }

    // 同理进行列扫描
    for (let col = 0; col < canvas.width; col++) {
        let top = col * 4; // 指向列头
        let bottom = top + (canvas.height - 2) * canvas.width * 4 + canvas.width * 4; // 指向列尾
        let topF = false;
        let bottomF = false;

        while (!topF || !bottomF) {
            if (!topF) {
                if (testColor([pixiArr[top], pixiArr[top + 1], pixiArr[top + 2]], color, range)) {
                    pixiArr[top + 3] = 0;
                    top += canvas.width * 4;
                } else topF = true;
            }
            if (!bottomF) {
                if (testColor([pixiArr[bottom], pixiArr[bottom + 1], pixiArr[bottom + 2]], color, range)) {
                    pixiArr[bottom + 3] = 0;
                    bottom -= canvas.width * 4;
                } else bottomF = true;
            }

            if (top == bottom) {
                topF = true;
                bottomF = true;
            };
        }
    }

    context.putImageData(imageInfo, 0, 0);
}

至于top和bottom为啥是那样计算画个矩阵图大概就知道了。

处理后↓

其实还可以先将pixiArr包装为以一个像素点为单位的矩阵

[
    [{r: 255, g: 255, b: 255, a: 1}, {r: 255, g: 255, b: 255, a: 1}, {r: 255, g: 255, b: 255, a: 1}],
    [{r: 255, g: 255, b: 255, a: 1}, {r: 255, g: 255, b: 255, a: 1}, {r: 255, g: 255, b: 255, a: 1}]
    [{r: 255, g: 255, b: 255, a: 1}, {r: 255, g: 255, b: 255, a: 1}, {r: 255, g: 255, b: 255, a: 1}]
]

处理后计算像素下标也就会更简单,列扫描时直接先将这个矩阵旋转,再用行扫描处理一遍就行了。这样处理pixiArr也有利于进一步对算法进行优化。

上述方法虽然大概完成了抠图效果,但是这种简单处理还会有许多情况没有考虑到。

比如右边头发这里是行扫描和列扫描都无法触碰到的区域↓ 下面的衣服也因为颜色和底色一样且没有边界在列扫描中被直接抹去了↓

洪泛法

在使用画图软件时,会有一个东西叫做“油漆桶”,点击某一个区域,颜色就会像“洪水”一样漫到其他区域直到碰到边界,它不会出现上面的双向扫描法出现的问题,因为它可以向任何方向延伸而不仅是横和竖。如果我们把“油漆”换成能溶解掉像素颜色的液体。就可以实现利用洪泛法的抠图。大体思路就是:

检测当前像素是否符合抠除条件,如果当前像素符合条件则将当前像素抠除,并递归处理周围8个像素。但是要注意这样会产生大量的重叠(比如一个点同时是它上面一个点的周围8个点之一也同时是下面一个点的周围8个点之一),要注意去重。

伪代码,思路非常简单。

floodFill8(x, y)
{
    // 判断是否x和y的坐标在图形范围内且像素符合扣除条件,还要判断是否是已处理过的像素。
    if(x >= 0 && x < width && y >= 0 && y < height && testColor(x, y) && alpha(x, y) != 0) 
    { 
        alpha(x, y) = 0;
        
        // 递归处理周围8个像素
        floodFill8(x + 1, y);
        floodFill8(x - 1, y);
        floodFill8(x, y + 1);
        floodFill8(x, y - 1);
        floodFill8(x + 1, y + 1);
        floodFill8(x - 1, y - 1);
        floodFill8(x - 1, y + 1);
        floodFill8(x + 1, y - 1);
    }   
}

但是这种递归的层级非常深(光一个500 * 500的图像都有多少个像素了),用js实现时会报栈溢出错误。我们需要用循环与栈来代替递归↓

// 注意这里的方法多了两个参数`startX`与`startY`,代表洪泛的开始坐标。
function cutoutFlood(canvas, startX, startY, color, range = 0) {
    let context = canvas.getContext('2d');
    let imageInfo = context.getImageData(0, 0, canvas.width, canvas.height);
    let pixiArr = imageInfo.data;
    let stack = [];

    function floodFill8(x, y) {

        // 8个方向
        let dx = [0, 1, 1, 1, 0, -1, -1, -1];
        let dy = [-1, -1, 0, 1, 1, 1, 0, -1];
        
        let map = {}; // 标识已经处理过的像素点,防止重复处理

        // 如果开始像素符合条件,则将它放入栈中并标识为已处理
        let cell = (x + y * canvas.width) * 4;
        if(testColor([pixiArr[cell], pixiArr[cell + 1], pixiArr[cell + 2]], color, range)) {
            let firstPixi = `x${x}y${y}`; // `x${x}y${y}`是一个不会重复的唯一标识id
            map[firstPixi] = true;
            stack.push({
                x,
                y
            });
        } else return; // 否则直接结束

        let p; // position
        while (p = stack.pop()) { // 获取栈顶待处理的符合条件的像素的x与y值
            cell = (p.x + p.y * canvas.width) * 4;
            pixiArr[cell + 3] = 0;
            // 测试周围8个是否符合抠除条件
            for (let i = 0; i < 8; i++) {
                let nx = p.x + dx[i];
                let ny = p.y + dy[i];
                // 是否在范围内并且没有被处理过
                if (nx >= 0 && nx < canvas.width && ny >= 0 && ny < canvas.height && !map[`x${nx}y${ny}`]) {
                    cell = (nx + ny * canvas.width) * 4;
                    if (testColor([pixiArr[cell], pixiArr[cell + 1], pixiArr[cell + 2]], color, range)) {
                        map[`x${nx}y${ny}`] = true; // 标识此像素已被处理
                        // 没处理过则放入栈中
                        stack.push({
                            x: nx,
                            y: ny
                        });
                    }
                }
            }
        }
    }

    floodFill8(startX, startY);
    context.putImageData(imageInfo, 0, 0);
}
洪泛从(0, 0)开始的结果(容差为0.109)↓

可以看到效果已经非常不错了,再从右上角开始一次洪泛,基本就能比较完美的抠出头像了。

在这里我在犹豫是否需要去维护这个map,因为这个map占用的空间不小,而且在我去掉map仅仅只用pixiArr[cell + 3] == 0来判断某个像素是否已经处理过时(即使这样会造成测试周围8个点时,有可能多次添加同一个像素点到栈中,因为添加到栈中并不会立即改变像素的alpha值),运行的速度普遍快10ms左右。

最后

对于主体和底色区分度很大的图片来说,最开始的那种方法就已经够用了。将洪泛法的代码与点击事件结合,就可以很容易做到那种“点一下消除一坨”的互动式抠图方式了。