0

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?

Will
  • 198
  • 1
  • 6

1 Answers1

0

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>
Will
  • 198
  • 1
  • 6
  • How did you understand the OP's context? Because i'm lost on context. – GetSet Jan 24 '20 at 08:30
  • @GetSet Edited the question (which I asked) for clarity. – Will Jan 24 '20 at 08:36
  • I missed that. You posted an answer to your original question. Are you looking for *best practices* than what you have shown? I don't know this subject. Nonetheless if it works why bother? – GetSet Jan 24 '20 at 08:38
  • 1
    Yes why bother. It's running on the client side correct. Doesn't appear to be in a loop correct? Why bother? A progress bar should suffice. Okay if it is tying up the browser with a loop, then optimize. But same token, couldn't you simply make this a *step tasking* operation if it indeed does tie up the UI stack? Some things simply take a long time to execute. – GetSet Jan 24 '20 at 09:30