Search code examples
javascriptimage-thresholding

Is there a faster way than a for loop for thresholding an image in javascript?


I want to do client side real-time interactive thresholding of large images using a slider. Is it possible to threshold an image in javascript to produce a binary image without using a for-loop over all pixels? And if so, is it faster?


Solution

  • This can be done using only globalCompositeOperations, in two stages.

    1. Set all pixels below threshold to 0 (black).
    2. 'Divide' this image by itself, using an algorithm that defines 0/0 = 0

    Define three canvases, one on screen, one to hold the greyscale image off screen, and another off screen 'working' canvas.

        //--- on-screen canvas
        var onScreenCanvas=document.getElementById("canvasTest");
        var ctxOnScreen=onScreenCanvas.getContext("2d");
    
        //--- off-screen working canvas
        var drawingCanvas = document.createElement('canvas');
        var ctx=drawingCanvas.getContext("2d");
    
        //--- off-screen canvas to store the greyscale image
        var greyscaleImageCanvas = document.createElement('canvas');
        var ctxGreyscaleImage=greyscaleImageCanvas.getContext("2d");
    

    Load the greyscale image onto the greyscaleImageCanvas, then the following two operations achieve step 1, where thresh_str is a hex string for the threshold value between 0-FF for each of RGB

            //(1a) Threshold the image on the offscreen working canvas, 
            // reducing values above threshold to have threshold value
            ctx.drawImage(greyscaleImageCanvas, 0, 0);
            ctx.globalCompositeOperation='darken';
            ctx.fillStyle=thresh_str;
            ctx.fillRect(0,0, drawingCanvas.width, drawingCanvas.height);
    
            //(1b) Set everything *below* threshold to 0 (black) since that part is unchanged
            // from the original image. Pixels above threshold are all non-zero.
            ctx.globalCompositeOperation='difference';
            ctx.drawImage(greyscaleImageCanvas, 0, 0);
    

    There is no straight 'divide' operation defined for HTML globalCompositeOperations, but there is a 'color-dodge', which divides the bottom layer by the inverted top layer. So the desired result is achieved by first making an inverted copy of the output of step 1, and then using the color-dodge operation (which does define 0/0=0) to 'un-invert' it before dividing. The result is that non-zero (above-threshold) pixels become 1, zero (sub-threshold) pixels stay zero.

            //(2a) Copy the result of (1b) to the onscreen canvas
            ctxOnScreen.globalCompositeOperation='copy';
            ctxOnScreen.drawImage(drawingCanvas, 0, 0);
    
            //(2b) Invert the result of step (1b) so that it can be 'un-inverted' by color dodge
            ctx.globalCompositeOperation='difference';
            ctx.fillStyle='white';
            ctx.fillRect(0,0,onScreenCanvas.width,onScreenCanvas.height);
    
            //(2c) 'color-dodge' the results of (1b) with it's own inverse (2b) 
            ctxOnScreen.globalCompositeOperation='color-dodge';
            ctxOnScreen.drawImage(drawingCanvas, 0, 0);
    

    This method appears to be 3-5 times faster than a for-loop, at least on Chrome 79 on a Mac and android (Huawei P10) JSPerf

       function img2grey(canvasContext) {
            canvasContext.globalCompositeOperation='color';
            canvasContext.fillStyle='white';
            canvasContext.fillRect(0,0,onScreenCanvas.width,onScreenCanvas.height);
        }
        
        //--- on-screen canvas
        var onScreenCanvas=document.getElementById("canvasTest");
        var ctxOnScreen=onScreenCanvas.getContext("2d");
        
        //--- off-screen working canvas
        var drawingCanvas = document.createElement('canvas');
        var ctx=drawingCanvas.getContext("2d");
    
        //--- off-screen canvas to store the greyscale image
        var greyscaleImageCanvas = document.createElement('canvas');
        var ctxGreyscaleImage=greyscaleImageCanvas.getContext("2d");
    
        var image = new Image();
    
    
        function thresholdImage(thresh_val) {
            
            if(thresh_val.length == 1){
                thresh_val = '0' + thresh_val;
            }
            thresh_str = '#'+thresh_val+thresh_val+thresh_val;
    
            ctxOnScreen.clearRect(0, 0, onScreenCanvas.width, onScreenCanvas.height);
            ctx.clearRect(0, 0, drawingCanvas.width, drawingCanvas.height);
    
            //----- (1) Threshold the image on the offscreen working canvas, 
            // reducing values above threshold to have threshold value
            ctx.drawImage(greyscaleImageCanvas, 0, 0);
            ctx.globalCompositeOperation='darken';
            ctx.fillStyle=thresh_str;
            ctx.fillRect(0,0,onScreenCanvas.width,onScreenCanvas.height);
            
            //----- (2) Set everything *below* threshold to 0 (black) since that part is unchanged
            // from the original image
            ctx.globalCompositeOperation='difference';
            ctx.drawImage(greyscaleImageCanvas, 0, 0);
    
            //----- (3) Copy the result to the onscreen canvas
            ctxOnScreen.globalCompositeOperation='copy';
            ctxOnScreen.drawImage(drawingCanvas, 0, 0);
    
            //----- (4) Invert the result of step (2) so that it can be 'un-inverted' by color dodge
            ctx.globalCompositeOperation='difference';
            ctx.fillStyle='white';
            ctx.fillRect(0,0,onScreenCanvas.width,onScreenCanvas.height);
            
            //----- (5) 'color-dodge' the results of (2) with it's own inverse (4) 
            //----- This makes use of 0/0 defined as 0 in this globalCompositeOperation,
            //----- so that non-zero (suprathreshold) pixels become 1, zero (sub-threshold) pixels stay zero
            //~ ctxOnScreen.globalCompositeOperation='color-dodge';
            ctxOnScreen.globalCompositeOperation='color-dodge';
            ctxOnScreen.drawImage(drawingCanvas, 0, 0);
    
        }
    
        image.onload = function() {
            onScreenCanvas.width = image.width;
            onScreenCanvas.height = image.height;
            drawingCanvas.width = image.width;
            drawingCanvas.height = image.height;
            greyscaleImageCanvas.width = image.width;
            greyscaleImageCanvas.height = image.height;
            //!!NB Doesn't work on chrome for local files, use firefox
            // https://stackoverflow.com/questions/45444097/the-canvas-has-been-tainted-by-cross-origin-data-local-image
            ctxGreyscaleImage.drawImage(image, 0, 0);
            img2grey(ctxGreyscaleImage);
    
            thresholdImage((Math.round(rng.value)).toString(16));
        };
    
        var rng = document.querySelector("input");
    
        var listener = function() {
          window.requestAnimationFrame(function() {
            thresholdImage( (Math.round(rng.value)).toString(16) );
          });
        };
    
        rng.addEventListener("mousedown", function() {
            listener();
            rng.addEventListener("mousemove", listener);
        });
        
        rng.addEventListener("mouseup", function() {
            rng.removeEventListener("mousemove", listener);
        });
    
        image.src = "https://i.imgur.com/vN0NbVu.jpg";
    .slider-width100 {
      width: 255px;
    }
    <html>
    
    <head>
    </head>
    
    <body>
    <canvas id="canvasTest"></canvas>
    <input class="slider-width100" type="range" min="0" max="254" value="122" />
    </body>
    
    </html>