Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How to add stroke/outline to transparent PNG image in JavaScript canvas

What is the easiest way to add an outline/stroke effect to a transparent PNG image using JavaScript canvas?

Most popular image effect libraries I found does not have stroke effect. The closest solution on StackOverflow I found is using blur to give it a glow effect instead of outline stroke.

Original picture

Transparent PNG image that can have multiple separated shapes:

enter image description here

Resulting image

Transparent image with outline stroke and shadow applied to it.

enter image description here

The search continues...

I'll update this list as I search for the easiest way to accomplish the stroke effect. Related questions:

  • Bitmap border stroke alogirthm
  • How to produce photoshop stroke effect?
  • How to make canvas outline a transparent png for on hover glow
like image 522
Domas Avatar asked Jun 04 '14 14:06

Domas


1 Answers

Here's one way to add a "sticker effect" on your image...

A Demo: http://jsfiddle.net/m1erickson/Q2j3L/

enter image description here

Start by drawing your original image to the main canvas.

enter image description here

Decompose the image into “discrete elements”.

Discrete elements consist of groups of pixels that are connected to each other but not connected to other elementss. For example, each individual sprite on a spritesheet would be a discrete element.

You can find discrete pixel groups using an edge detection algorithm like "marching squares".

Put each discrete element on its own canvas for further processing. Also erase that discrete element from the main canvas (so it's not processed again).

enter image description here

Detect the outline-path of each discrete element.

You can again use the “marching squares” algorithm to do edge detection. The result of marching squares is an array of x/y coordinates that form the outside outline of the element

Create the “sticker effect”

You can create a sticker effect by putting a stroked white outline around each element. Do this by stroking the outline path which you calculated above. You can optionally add a shadow to the stroke.

Note: canvas strokes are always drawn half-inside & half-outside the path. This means that the sticker-stroke will intrude inside the element. To fix this: After you have drawn the sticker-stroke you should redraw the element back on top. This overwrites the intruding part of the sticker-stroke.

enter image description here

Recompose the final image including the sticker-effect

Recompose the final image by layering each element's canvas onto the main canvas

enter image description here

Here is annotated example code:

<!doctype html>
<html>
<head>
<link rel="stylesheet" type="text/css" media="all" href="css/reset.css" /> <!-- reset css -->
<script src="http://code.jquery.com/jquery.min.js"></script>
<script src="marching squares.js"></script>
<style>
    body{ background-color:silver; }
    canvas{border:1px solid red;}
</style>
<script>
$(function(){

    // canvas related variables
    var canvas=document.getElementById("canvas");
    var ctx=canvas.getContext("2d");

    // variables used in pixel manipulation
    var canvases=[];
    var imageData,data,imageData1,data1;

    // size of sticker outline
    var strokeWeight=8;

    // true/false function used by the edge detection method
    var defineNonTransparent=function(x,y){
        return(data1[(y*cw+x)*4+3]>0);
    }

    // the image receiving the sticker effect
    var img=new Image();
    img.crossOrigin="anonymous";
    img.onload=start;
    img.src="https://dl.dropboxusercontent.com/u/139992952/multple/makeIndividual.png";
    //img.src="https://dl.dropboxusercontent.com/u/139992952/stackoverflow/angryBirds.png";

    function start(){

        // resize the main canvas to the image size
        canvas.width=cw=img.width;
        canvas.height=ch=img.height;

        // draw the image on the main canvas
        ctx.drawImage(img,0,0);

        // Move every discrete element from the main canvas to a separate canvas
        // The sticker effect is applied individually to each discrete element and
        // is done on a separate canvas for each discrete element
        while(moveDiscreteElementToNewCanvas()){}

        // add the sticker effect to all discrete elements (each canvas)
        for(var i=0;i<canvases.length;i++){
            addStickerEffect(canvases[i],strokeWeight);
            ctx.drawImage(canvases[i],0,0);
        }

        // redraw the original image
        //   (necessary because the sticker effect 
        //    slightly intrudes on the discrete elements)
        ctx.drawImage(img,0,0);

    }

    // 
    function addStickerEffect(canvas,strokeWeight){
        var url=canvas.toDataURL();
        var ctx1=canvas.getContext("2d");
        var pts=canvas.outlinePoints;
        addStickerLayer(ctx1,pts,strokeWeight);
        var imgx=new Image();
        imgx.onload=function(){
            ctx1.drawImage(imgx,0,0);
        }
        imgx.src=url;    
    }


    function addStickerLayer(context,points,weight){

        imageData=context.getImageData(0,0,canvas.width,canvas.height);
        data1=imageData.data;

        var points=geom.contour(defineNonTransparent);

        defineGeomPath(context,points)
        context.lineJoin="round";
        context.lineCap="round";
        context.strokeStyle="white";
        context.lineWidth=weight;
        context.stroke();
    }

    // This function finds discrete elements on the image
    // (discrete elements == a group of pixels not touching
    //  another groups of pixels--e.g. each individual sprite on
    //  a spritesheet is a discreet element)
    function moveDiscreteElementToNewCanvas(){

        // get the imageData of the main canvas
        imageData=ctx.getImageData(0,0,canvas.width,canvas.height);
        data1=imageData.data;

        // test & return if the main canvas is empty
        // Note: do this b/ geom.contour will fatal-error if canvas is empty
        var hit=false;
        for(var i=0;i<data1.length;i+=4){
            if(data1[i+3]>0){hit=true;break;}
        }
        if(!hit){return;}

        // get the point-path that outlines a discrete element
        var points=geom.contour(defineNonTransparent);

        // create a new canvas and append it to page
        var newCanvas=document.createElement('canvas');
        newCanvas.width=canvas.width;
        newCanvas.height=canvas.height;
        document.body.appendChild(newCanvas);
        canvases.push(newCanvas);
        var newCtx=newCanvas.getContext('2d');

        // attach the outline points to the new canvas (needed later)
        newCanvas.outlinePoints=points;

        // draw just that element to the new canvas
        defineGeomPath(newCtx,points);
        newCtx.save();
        newCtx.clip();
        newCtx.drawImage(canvas,0,0);
        newCtx.restore();

        // remove the element from the main canvas
        defineGeomPath(ctx,points);
        ctx.save();
        ctx.clip();
        ctx.globalCompositeOperation="destination-out";
        ctx.clearRect(0,0,canvas.width,canvas.height);
        ctx.restore();

        return(true);
    }


    // utility function
    // Defines a path on the canvas without stroking or filling that path
    function defineGeomPath(context,points){
        context.beginPath();
        context.moveTo(points[0][0],points[0][1]);  
        for(var i=1;i<points.length;i++){
            context.lineTo(points[i][0],points[i][1]);
        }
        context.lineTo(points[0][0],points[0][1]);
        context.closePath();    
    }

}); // end $(function(){});
</script>
</head>
<body>
    <canvas id="canvas" width=300 height=300></canvas><br>
</body>
</html>

This is a marching squares edge detection algorithm (from the excellent open-source d3 library):

/** 
 * Computes a contour for a given input grid function using the <a 
 * href="http://en.wikipedia.org/wiki/Marching_squares">marching 
 * squares</a> algorithm. Returns the contour polygon as an array of points. 
 * 
 * @param grid a two-input function(x, y) that returns true for values 
 * inside the contour and false for values outside the contour. 
 * @param start an optional starting point [x, y] on the grid. 
 * @returns polygon [[x1, y1], [x2, y2], ...] 

 */
 (function(){ 

geom = {}; 
geom.contour = function(grid, start) { 
  var s = start || d3_geom_contourStart(grid), // starting point 
      c = [],    // contour polygon 
      x = s[0],  // current x position 
      y = s[1],  // current y position 
      dx = 0,    // next x direction 
      dy = 0,    // next y direction 
      pdx = NaN, // previous x direction 
      pdy = NaN, // previous y direction 
      i = 0; 

  do { 
    // determine marching squares index 
    i = 0; 
    if (grid(x-1, y-1)) i += 1; 
    if (grid(x,   y-1)) i += 2; 
    if (grid(x-1, y  )) i += 4; 
    if (grid(x,   y  )) i += 8; 

    // determine next direction 
    if (i === 6) { 
      dx = pdy === -1 ? -1 : 1; 
      dy = 0; 
    } else if (i === 9) { 
      dx = 0; 
      dy = pdx === 1 ? -1 : 1; 
    } else { 
      dx = d3_geom_contourDx[i]; 
      dy = d3_geom_contourDy[i]; 
    } 

    // update contour polygon 
    if (dx != pdx && dy != pdy) { 
      c.push([x, y]); 
      pdx = dx; 
      pdy = dy; 
    } 

    x += dx; 
    y += dy; 
  } while (s[0] != x || s[1] != y); 

  return c; 
}; 

// lookup tables for marching directions 
var d3_geom_contourDx = [1, 0, 1, 1,-1, 0,-1, 1,0, 0,0,0,-1, 0,-1,NaN], 
    d3_geom_contourDy = [0,-1, 0, 0, 0,-1, 0, 0,1,-1,1,1, 0,-1, 0,NaN]; 

function d3_geom_contourStart(grid) { 
  var x = 0, 
      y = 0; 

  // search for a starting point; begin at origin 
  // and proceed along outward-expanding diagonals 
  while (true) { 
    if (grid(x,y)) { 
      return [x,y]; 
    } 
    if (x === 0) { 
      x = y + 1; 
      y = 0; 
    } else { 
      x = x - 1; 
      y = y + 1; 
    } 
  } 
} 

})();

Note: This code separates the process of applying the sticker outline into a separate function. That's done in case you want to have multiple layers around your discrete element. For example, you might want a second gray border on the outside of the sticker-stroke. If you don't need to apply "layers" then you could apply the sticker-stroke within the moveDiscreteElementToNewCanvas function.

like image 130
markE Avatar answered Nov 01 '22 20:11

markE