Borderless matting

By using canvas getImageData, we can obtain the information of each pixel of a picture, and by comparing the information of each pixel, we can find the pixels to be eliminated. In the image below, for example, if we want to button out the white part (pink is the background color of the 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); // Remove white with a tolerance of 0.2
}

function cutout(canvas, color, range = 0) {
    let context = canvas.getContext('2d');
    let imageInfo = context.getImageData(0.0, canvas.width, canvas.height);
    // pixiArr is an array with four elements representing one pixel of r, g, b, and a.
    let pixiArr = imageInfo.data;
    for (let i = 0; i < pixiArr.length; i += 4) {
    // Set alpha of the target pixel to 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;
}
Copy the code

The three parameters of testColor(current, target, range) method are RGB array of pixels to be detected, RGB array of target pixels and tolerance range respectively. The tolerance here is simply calculated and compared by multiplying the values of R, G and b respectively by (1 + range) and (1-range). Different tolerance parameters will get different effects ↓

Boundary processing

But sometimes we want to have a boundary where matting doesn’t affect the pixels inside the boundary. For example, in the image above, we hope that the pixels inside the avatar will not be affected. If we go line by line, would we just stop when we hit a non-boundary pixel?

For each line scanning respectively, we define a left left pointer pointing to the line of the first pixel, define a right right pointer pointing to the line of the last one pixel, and whether a leftF logo on the left border, whether a rightF logo on the right side of the border, when not touching the border pointer has been inward contraction, Skip to the next line until both Pointers touch the boundary or the left and right Pointers overlap until all rows are scanned.

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; // point to the first pixel of the line
        let right = left + 4 * canvas.width - 1 - 3; // Point to the end of the line pixel
        let leftF = false; // The left pointer touches the boundary
        let rightF = false; // Whether the right pointer touches the boundary flag

        while(! leftF || ! rightF) {// End when both left and right Pointers are true
            if(! leftF) {if (testColor([pixiArr[left], pixiArr[left + 1], pixiArr[left + 2]], color, range)) {
                    pixiArr[left + 3] = 0; // This pixel's alpha is set to 0
                    left += 4; // Move to the next pixel
                } else leftF = true; // meet the boundary
            }
            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) { // the left and right Pointers overlap
                leftF = true;
                rightF = true;
            };
        }
    }

    context.putImageData(imageInfo, 0.0);
}
Copy the code

Although it probably fulfilled our requirements, let’s see why there is an extra white patch on the top hair

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; }; }}// Do the same for column scanning
    for (let col = 0; col < canvas.width; col++) {
        let top = col * 4; // point to the column header
        let bottom = top + (canvas.height - 2) * canvas.width * 4 + canvas.width * 4; // point to the end of the column
        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);
}
Copy the code

Let’s draw a matrix to figure out why top and bottom are computed that way.

Actually, you can do it firstpixiArrThe wrapper is a matrix in units of one pixel

[
    [{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}]
]
Copy the code

After processing, it is easier to calculate the pixel subscript. When the column is scanned, the matrix is rotated directly and then the row scan is processed again. This treatment of pixiArr also facilitates further optimization of the algorithm.

Although the above method probably accomplishes the matting effect, there are still many cases that this simple treatment does not take into account.

For example, the hair on the right is the area that line scanning and column scanning cannot touch ↓

Flooding method

Detect whether the current pixel meets the conditions of keying. If the current pixel meets the conditions, the current pixel will be keying, and the surrounding 8 pixels will be recursively processed. But be aware that this creates a lot of overlap (for example, a point that is one of the eight surrounding a point above it is also one of the eight surrounding a point below it), and be careful not to duplicate.

Pseudo code, the idea is very simple.

floodFill8(x, y)
{
    // Determine whether the coordinates of x and y are within the range of the graph and the pixel conforms to the deduction condition, and also determine whether the pixel has been processed.
    if(x >= 0 && x < width && y >= 0&& y < height && testColor(x, y) && alpha(x, y) ! =0) 
    { 
        alpha(x, y) = 0;
        
        // Recursively process around 8 pixels
        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); }}Copy the code

However, the level of recursion is very deep (how many pixels are there in a 500 * 500 image), and it will report stack overflow errors when implemented in JS. We need to use loops and stacks instead of recursive ↓

// Notice that the method has two extra parameters' startX 'and' startY ', which represent the starting coordinates of the flood.
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 directions
        let dx = [0.1.1.1.0.- 1.- 1.- 1];
        let dy = [- 1.- 1.0.1.1.1.0.- 1];
        
        let map = {}; // Identify the pixels that have been processed to prevent repeated processing

        // If the start pixel meets the criteria, it is put on the stack and marked as processed
        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}' is a unique id that will not be repeated
            map[firstPixi] = true;
            stack.push({
                x,
                y
            });
        } else return; // If not, end

        let p; // position
        while (p = stack.pop()) { // Get the x and y values of the eligible pixels at the top of the stack
            cell = (p.x + p.y * canvas.width) * 4;
            pixiArr[cell + 3] = 0;
            // Test whether the surrounding 8 meet the condition of cutting
            for (let i = 0; i < 8; i++) {
                let nx = p.x + dx[i];
                let ny = p.y + dy[i];
                // Whether it is in range and has not been processed
                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; // Indicates that the pixel has been processed
                        // if not, put it on the stack
                        stack.push({
                            x: nx,
                            y: ny
                        });
                    }
                }
            }
        }
    }

    floodFill8(startX, startY);
    context.putImageData(imageInfo, 0.0);
}
Copy the code

I’m hesitant to maintain the map here because it takes up a lot of space, and it’s possible to add the same pixel to the stack multiple times when I remove the map and just use pixiArr[cell + 3] == 0 to determine if a pixel is out of process. Since adding a pixel to the stack does not immediately change the alpha value), it generally runs about 10ms faster.

The last

For images with a large distinction between subject and background, the initial method is sufficient. Combine the flooding code with click events, and you can easily do the “click to eliminate a lump” interactive matting. All the code is in my Github repository.