5

How can I pinch/pucker some area of an image in canvas?

I've made a solar system animation some time ago, and I started rewriting it. Now, I want to add gravity effect to masses. To make the effect visible, I turned the background into a grid and I'll be modifying it.

Desired effect is something like this (made in PS)

enter image description here

enter image description here


context.background("rgb(120,130,145)");
context.grid(25, "rgba(255,255,255,.1)");

var sun = {
    fill        : "rgb(220,210,120)",
    radius      : 30,
    boundingBox : 30*2 + 3*2,
    position    : {
        x       : 200,
        y       : 200,
    },
};
sun.img = saveToImage(sun);

context.drawImage(sun.img, sun.position.x - sun.boundingBox/2, sun.position.y - sun.boundingBox/2);

jsFiddle


Update: I've done some googling and found some resources, but since I've never done pixel manipulation before, I can't put these together.

Pixel Distortions with Bilinear Filtration in HTML5 Canvas | Splashnology.com (functions only)

glfx.js (WebGL library with demos)

JSFiddle (spherize, zoom, twirl examples)

The spherize effect in inverted form would be good for the job, I guess.

Syscall
  • 19,327
  • 10
  • 37
  • 52
akinuri
  • 10,690
  • 10
  • 65
  • 102
  • Canvas only does affine transformations (all transforms result in parallelograms) so pinch-warping is not possible with native canvas transformations. To pinch-warp a rectangular section of an image you can divide that unwarped rectangular into 2 (or more) warped triangles. Here's a previous post that unwarps a warped image, but you can use it as a starting spot to warp an unwarped section: http://stackoverflow.com/questions/30565987/cropping-images-with-html5-canvas-in-a-non-rectangular-shape-and-transforming/30566272#30566272 – markE Oct 29 '15 at 03:52

3 Answers3

1

UPDATED answer I have improved the performance significantly but reduced the flexibility.

To get a pinch effect you need to use a mask and then redraw the image with the mask. In this case you use a circular mask that you shrink as you draw zoomed in or out copies of the original. The effect is a buldge or pinch.

There is a quality setting that will give you from sub pixel rendering up to very rough. As with these things you sacrifice speed for quality.

I would not recommend this as a final solution to your requirements because of the inconsistent rendering speed between hardware and browsers.

For consistent results you need to use webGL. If I get time I will write a shader to do that if there is not already on on ShaderToy

So this is a pure canvas 2d solution. Canvas 2d can do anything, it just cant do it as quickly as webGL but it can come close.

UPDATE: Have re written example to improve the speed. Now runs a lot faster using clip rather than a pixel mask. Though new version is limited to pinch bulge on both axis at the same time.

See code comments for more info. I have tried to explain it best I can, if you have question do ask. I wish I could have given you a perfect answer but canvas 2d API needs to grow up some more before things like this can be more reliable.

var canvas = document.getElementById("canV");
var ctx = canvas.getContext("2d");



var createImage= function(w,h){ // create a image of requier size
    var image = document.createElement("canvas"); 
    image.width = w;
    image.height =h;
    image.ctx = image.getContext("2d");  // tack the context onto the image
    return image;
}

// amountX amountY the amount of the effect
// centerX,centerY the center of the effect
// quality the quality of the effect. The smaller the vall the higher the quallity but the slower the processing
// image, the input image
// mask an image to hold the mask. Can be a different size but that will effect quality
// result, the image onto which the effect is rendered
var pinchBuldge = function(amountX,quality,image,result){
    var w = image.width;
    var h = image.height;
    var easeW = (amountX/w)*4; // down unit 0 to 4 top to bottom
    var wh = w/2;   // half size for lazy coder
    var hh = h/2;            
    var stepUnit = (0.5/(wh))*quality;
    result.ctx.drawImage(image,0,0);
    for(i = 0; i < 0.5; i += stepUnit){  // all done in normalised size                                             
        var r = i*2;  // normalise i
        var x = r*wh;  // get the clip x destination pos relative to center
        var y = r*hh;  // get the clip x  destination pos relative to center
        var xw = w-(x*2);  // get the clip  destination width
        var rx = (x)*easeW;   // get the image source pos
        var ry = (y)*easeW;
        var rw = w-(rx*2);     // get the image source size
        var rh = h-(ry*2);
        result.ctx.save();
        result.ctx.beginPath();
        result.ctx.arc(wh,hh,xw/2,0,Math.PI*2);
        result.ctx.clip();
        result.ctx.drawImage(image,rx,ry,rw,rh,0,0,w,h);
        result.ctx.restore();
    }        
    // all done;

}
// create the requiered images
var imageSize = 256; // size of image
var image = createImage(imageSize,imageSize);  // the original image
var result = createImage(imageSize,imageSize); // the result image
image.ctx.fillStyle = "#888";  // add some stuff to the image
image.ctx.fillRect(0,0,imageSize,imageSize);  // fil the background
// draw a grid  Dont need to comment this I hope it is self evident
var gridCount = 16;
var grid = imageSize/gridCount;
var styles = [["black",8],["white",2]];
styles.forEach(function(st){
    image.ctx.strokeStyle = st[0];
    image.ctx.lineWidth = st[1];
    for(var i = 0; i < 16; i++){
        image.ctx.moveTo(i*grid,0);
        image.ctx.lineTo(i*grid,imageSize)
        image.ctx.moveTo(0,i*grid);
        image.ctx.lineTo(imageSize,i*grid)
    }
    image.ctx.moveTo(0,imageSize-1);  
    image.ctx.lineTo(imageSize,imageSize-1)
    image.ctx.moveTo(imageSize-1,0);
    image.ctx.lineTo(imageSize-1,imageSize)
    image.ctx.stroke()
});


var timer = 0;
var rate = 0.05
// Quality 0.5 is sub pixel high quality
//         1 is pixel quality
//         2 is every 2 pixels
var quality = 1.5; // quality at OK

function update(){
    timer += rate;
    var effectX = Math.sin(timer)*(imageSize/4);
    pinchBuldge(effectX,quality,image,result);
    ctx.drawImage(result,0,0);
    setTimeout(update,10); // do the next one in 100 milliseconds
}
update();
.canC {
    width:256px;
    height:256px;
}
<canvas class="canC" id="canV" width=256 height=256></canvas>
Blindman67
  • 51,134
  • 11
  • 73
  • 136
  • Why is this so slow? I've found some demos that run faster. Could you check them out and maybe write a simple demo? Preferably with long variable names so that I can keep track of what's going on. I've updated the question. – akinuri Oct 29 '15 at 13:09
  • @akinuri This is slow because 'quality' variable determines the number of calculations as a function of the canvas's width. These calculation are effectively at the sub-pixel level, but the the canvas only draws at the pixel level, so there's a lot of redundancy here - and even though a lover 'quality' value seems to negate the moiré – Brian Peacock May 28 '16 at 16:06
  • (oops) .... the moiré effect (and thus appears to improve image quality) it simply slows the whole process down by incrementing in smaller units, and thus makes the moiré effect appear less noticeable to the eye. See this Fiddle: https://jsfiddle.net/BnPck/7fzxnfan/1/ – Brian Peacock May 28 '16 at 16:29
  • @Blindman67 Sorry for the really late reply, but I had to take a break (school and other things). Didn't have time to work on this. Revisiting the old works now, and I've just created a [repository](https://github.com/akinuri/canvas-pinch) on github for this particular issue. I'd really appreciate if we could talk there and maybe come up with a solution. – akinuri Nov 11 '16 at 20:34
1

I've had time to revisit this problem and came up with a solution. Instead of solving the problem directly, first, I needed to understand how the math behind the calculation and pixel manipulation works.

So, instead of using an image/pixels, I decided to use particles. A JavaScript object is something I'm much more familiar with, so it was easy to manipulate.

I'll not try to explain the method because I think it's self-explanatory, and I tried to keep it as simple as it can get.

var canvas = document.getElementById("canvas");
var context = canvas.getContext("2d");

canvas.width  = 400;
canvas.height = 400;

var particles = [];

function Particle() {
    this.position = {
        actual : {
            x : 0,
            y : 0
        },
        affected : {
            x : 0,
            y : 0
        },
    };
}

// space between particles
var gridSize = 25;

var columns  = canvas.width / gridSize;
var rows     = canvas.height / gridSize;

// create grid using particles
for (var i = 0; i < rows+1; i++) {
    for (var j = 0; j < canvas.width; j += 2) {
        var p = new Particle();
        p.position.actual.x = j;
        p.position.actual.y = i * gridSize;
        p.position.affected = Object.create(p.position.actual);
        particles.push(p);
    }
}
for (var i = 0; i < columns+1; i++) {
    for (var j = 0; j < canvas.height; j += 2) {
        var p = new Particle();
        p.position.actual.x = i * gridSize;
        p.position.actual.y = j;
        p.position.affected = Object.create(p.position.actual);
        particles.push(p);
    }
}

// track mouse coordinates as it is the source of mass/gravity
var mouse = {
    x : -100,
    y : -100,
};

var effectRadius = 75;
var effectStrength = 50;

function draw() {
    context.clearRect(0, 0, canvas.width, canvas.height);
    
    particles.forEach(function (particle) {
        // move the particle to its original position
        particle.position.affected = Object.create(particle.position.actual);
        
        // calculate the effect area
        var a = mouse.y - particle.position.actual.y;
        var b = mouse.x - particle.position.actual.x;
        var dist = Math.sqrt(a*a + b*b);
        
        // check if the particle is in the affected area
        if (dist < effectRadius) {
            
            // angle of the mouse relative to the particle
            var a = angle(particle.position.actual.x, particle.position.actual.y, mouse.x, mouse.y);
            
            // pull is stronger on the closest particle
            var strength = dist.map(0, effectRadius, effectStrength, 0);
            
            if (strength > dist) {
                strength = dist;
            }
            
            // new position for the particle that's affected by gravity
            var p = pos(particle.position.actual.x, particle.position.actual.y, a, strength);
            
            particle.position.affected.x = p.x;
            particle.position.affected.y = p.y;
        }
        
        context.beginPath();
        context.rect(particle.position.affected.x -1, particle.position.affected.y -1, 2, 2);
        context.fill();
    });
}

draw();

window.addEventListener("mousemove", function (e) {
    mouse.x = e.x - canvas.offsetLeft;
    mouse.y = e.y - canvas.offsetTop;
    requestAnimationFrame(draw);
});

function angle(originX, originY, targetX, targetY) {
    var dx = targetX - originX;
    var dy = targetY - originY;
    var theta = Math.atan2(dy, dx) * (180 / Math.PI);
    if (theta < 0) theta = 360 + theta;
    return theta;
}

Number.prototype.map = function (in_min, in_max, out_min, out_max) {
    return (this - in_min) * (out_max - out_min) / (in_max - in_min) + out_min;
};

function pos(x, y, angle, length) {
    angle *= Math.PI / 180;
    return {
        x : Math.round(x + length * Math.cos(angle)),
        y : Math.round(y + length * Math.sin(angle)),
    };
}
* {
  margin: 0;
  padding: 0;
  box-sizing: inherit;
  line-height: inherit;
  font-size: inherit;
  font-family: inherit;
}

body {
  font-family: sans-serif;
  box-sizing: border-box;
  background-color: hsl(0, 0%, 90%);
}

canvas {
  display: block;
  background: white;
  box-shadow: 0 0 2px rgba(0, 0, 0, .2), 0 1px 1px rgba(0, 0, 0, .1);
  margin: 20px auto;
}

canvas:hover {
  cursor: none;
}
<canvas id="canvas"></canvas>

I might try to create twirl effect some other time, and move these into WebGL for better performance.


Update:

Now, I'm working on the twirl effect, and I've made it work to some degree.

var canvas = document.getElementById("canvas");
var context = canvas.getContext("2d");

canvas.width  = 400;
canvas.height = 400;

var particles = [];

function Particle() {
    this.position = {
        actual : {
            x : 0,
            y : 0
        },
        affected : {
            x : 0,
            y : 0
        },
    };
}

// space between particles
var gridSize = 25;

var columns  = canvas.width / gridSize;
var rows     = canvas.height / gridSize;

// create grid using particles
for (var i = 0; i < rows+1; i++) {
    for (var j = 0; j < canvas.width; j += 2) {
        var p = new Particle();
        p.position.actual.x = j;
        p.position.actual.y = i * gridSize;
        p.position.affected = Object.create(p.position.actual);
        particles.push(p);
    }
}
for (var i = 0; i < columns+1; i++) {
    for (var j = 0; j < canvas.height; j += 2) {
        var p = new Particle();
        p.position.actual.x = i * gridSize;
        p.position.actual.y = j;
        p.position.affected = Object.create(p.position.actual);
        particles.push(p);
    }
}

// track mouse coordinates as it is the source of mass/gravity
var mouse = {
    x : -100,
    y : -100,
};

var effectRadius = 75;
var twirlAngle   = 90;

function draw(e) {
    context.clearRect(0, 0, canvas.width, canvas.height);
    
    particles.forEach(function (particle) {
        // move the particle to its original position
        particle.position.affected = Object.create(particle.position.actual);
        
        // calculate the effect area
        var a = mouse.y - particle.position.actual.y;
        var b = mouse.x - particle.position.actual.x;
        var dist = Math.sqrt(a*a + b*b);
        
        // check if the particle is in the affected area
        if (dist < effectRadius) {
            
            // angle of the particle relative to the mouse
            var a = angle(mouse.x, mouse.y, particle.position.actual.x, particle.position.actual.y);
            
            var strength = dist.map(0, effectRadius, twirlAngle, 0);
            
            // twirl
            a += strength;
            
            // new position for the particle that's affected by gravity
            var p = rotate(a, dist, mouse.x, mouse.y);
            
            particle.position.affected.x = p.x;
            particle.position.affected.y = p.y;
        }
        
        context.beginPath();
        context.rect(particle.position.affected.x -1, particle.position.affected.y -1, 2, 2);
        context.fillStyle = "black";
        context.fill();
    });
}

draw();

window.addEventListener("mousemove", function (e) {
    mouse.x = e.x - canvas.offsetLeft;
    mouse.y = e.y - canvas.offsetTop;
    requestAnimationFrame(draw);
});

function angle(originX, originY, targetX, targetY) {
    var dx = targetX - originX;
    var dy = targetY - originY;
    var theta = Math.atan2(dy, dx) * (180 / Math.PI);
    if (theta < 0) theta = 360 + theta;
    return theta;
}

Number.prototype.map = function (in_min, in_max, out_min, out_max) {
    return (this - in_min) * (out_max - out_min) / (in_max - in_min) + out_min;
};

function pos(x, y, angle, length) {
    angle *= Math.PI / 180;
    return {
        x : Math.round(x + length * Math.cos(angle)),
        y : Math.round(y + length * Math.sin(angle)),
    };
}

function rotate(angle, distance, originX, originY) {
    return {
        x : originX + Math.cos(angle * Math.PI/180) * distance,
        y : originY + Math.sin(angle * Math.PI/180) * distance,
    }
}
* {
  margin: 0;
  padding: 0;
  box-sizing: inherit;
  line-height: inherit;
  font-size: inherit;
  font-family: inherit;
}

body {
  font-family: sans-serif;
  box-sizing: border-box;
  background-color: hsl(0, 0%, 90%);
}

canvas {
  display: block;
  background: white;
  box-shadow: 0 0 2px rgba(0, 0, 0, .2), 0 1px 1px rgba(0, 0, 0, .1);
  margin: 20px auto;
}
<canvas id="canvas"></canvas>

There is a slight issue with the mapping of strength of the twirl. I've used the same function map that I've used with pinch effect, but I think twirl doesn't use linear mapping, but eased mapping. Compare the JS version with the PS filter. PS filter is smoother. I need to rewrite the map function.

enter image description here

Update 2:

I've managed to make it work the same way PS filter does. Using an ease function, i.e., easeOutQuad solved the problem. Enjoy :)

var canvas = document.getElementById("canvas");
var context = canvas.getContext("2d");

canvas.width  = 400;
canvas.height = 400;

var particles = [];

function Particle() {
    this.position = {
        actual : {
            x : 0,
            y : 0
        },
        affected : {
            x : 0,
            y : 0
        },
    };
}

// space between particles
var gridSize = 25;

var columns  = canvas.width / gridSize;
var rows     = canvas.height / gridSize;

// create grid using particles
for (var i = 0; i < rows+1; i++) {
    for (var j = 0; j < canvas.width; j+=2) {
        var p = new Particle();
        p.position.actual.x = j;
        p.position.actual.y = i * gridSize;
        p.position.affected = Object.create(p.position.actual);
        particles.push(p);
    }
}
for (var i = 0; i < columns+1; i++) {
    for (var j = 0; j < canvas.height; j+=2) {
        var p = new Particle();
        p.position.actual.x = i * gridSize;
        p.position.actual.y = j;
        p.position.affected = Object.create(p.position.actual);
        particles.push(p);
    }
}

// track mouse coordinates as it is the source of mass/gravity
var mouse = {
    x : -100,
    y : -100,
};

var effectRadius = 75;
var twirlAngle   = 90;

function draw(e) {
    context.clearRect(0, 0, canvas.width, canvas.height);
    
    particles.forEach(function (particle) {
        // move the particle to its original position
        particle.position.affected = Object.create(particle.position.actual);
        
        // calculate the effect area
        var a = mouse.y - particle.position.actual.y;
        var b = mouse.x - particle.position.actual.x;
        var dist = Math.sqrt(a*a + b*b);
        
        // check if the particle is in the affected area
        if (dist < effectRadius) {
            
            // angle of the particle relative to the mouse
            var a = angle(mouse.x, mouse.y, particle.position.actual.x, particle.position.actual.y);
            
            var strength = twirlAngle - easeOutQuad(dist, 0, twirlAngle, effectRadius);
            
            // twirl
            a += strength;
            
            // new position for the particle that's affected by gravity
            var p = rotate(a, dist, mouse.x, mouse.y);
            
            particle.position.affected.x = p.x;
            particle.position.affected.y = p.y;
        }
        
        context.beginPath();
        context.rect(particle.position.affected.x-1, particle.position.affected.y-1, 2, 2);
        context.fillStyle = "black";
        context.fill();
    });
}

draw();

window.addEventListener("mousemove", function (e) {
    mouse.x = e.x - canvas.offsetLeft;
    mouse.y = e.y - canvas.offsetTop;
    requestAnimationFrame(draw);
});

function easeOutQuad(t, b, c, d) {
    t /= d;
    return -c * t*(t-2) + b;
};

function angle(originX, originY, targetX, targetY) {
    var dx = targetX - originX;
    var dy = targetY - originY;
    var theta = Math.atan2(dy, dx) * (180 / Math.PI);
    if (theta < 0) theta = 360 + theta;
    return theta;
}

Number.prototype.map = function (in_min, in_max, out_min, out_max) {
    return (this - in_min) / (in_max - in_min) * (out_max - out_min) + out_min;
};

function pos(x, y, angle, length) {
    angle *= Math.PI / 180;
    return {
        x : Math.round(x + length * Math.cos(angle)),
        y : Math.round(y + length * Math.sin(angle)),
    };
}

function rotate(angle, distance, originX, originY) {
    return {
        x : originX + Math.cos(angle * Math.PI/180) * distance,
        y : originY + Math.sin(angle * Math.PI/180) * distance,
    }
}
* {
  margin: 0;
  padding: 0;
  box-sizing: inherit;
  line-height: inherit;
  font-size: inherit;
  font-family: inherit;
}

body {
  font-family: sans-serif;
  box-sizing: border-box;
  background-color: hsl(0, 0%, 90%);
}

canvas {
  display: block;
  background: white;
  box-shadow: 0 0 2px rgba(0, 0, 0, .2), 0 1px 1px rgba(0, 0, 0, .1);
  margin: 20px auto;
}
<canvas id="canvas"></canvas>
akinuri
  • 10,690
  • 10
  • 65
  • 102
0

OP mentions glfx.js in an update at the bottom of the question, but I figured I'd call it out in an answer since I missed it, and it was a perfect solution for me. Here's a demo:

https://evanw.github.io/glfx.js/demo/#bulgePinch

let canvas = fx.canvas();

// convert the image to a texture
let image = document.querySelector('#input-image');
let texture = canvas.texture(image);

// apply the bulge/pinch
canvas.draw(texture);
canvas.bulgePinch(centerX, centerY, radius, strength);
canvas.update();

// replace the image with the canvas
image.parentNode.insertBefore(canvas, image);
image.parentNode.removeChild(image);

// or get canvas as data url
let dataUrl = canvas.toDataUrl("image/png");

From the docs:

Bulges or pinches the image in a circle.

centerX   The x coordinate of the center of the circle of effect.
centerY   The y coordinate of the center of the circle of effect.
radius    The radius of the circle of effect.
strength  -1 to 1 (-1 is strong pinch, 0 is no effect, 1 is strong bulge)
joe
  • 3,752
  • 1
  • 32
  • 41