Distort Image into Isosceles Trapezoid with Canvas

Recently I wanted to programmatically distort an image into an isosceles trapezoid using JavaScript to get a simple 3D perspective effect. This might seem like a simple task but when you only have 2D rotate and skew transforms, there isn’t a straightforward way to make such a transform.

One option might be to use the 3D transforms of WebGL in a browser, but that’s a really heavy solution and there currently aren’t any WebGL canvas libraries for Node.JS. It would be better if we could use just a 2D canvas drawing context.

So how can we accomplish this without 3D transforms? Read on to find out!

End Result

It’s best begin with the end in mind, and a pictures worth a thousand works, so essentially we’re looking to be able to turn an image like this:

checkered.png

checkered.png

Into an image like this:

checkered-skewed.png

checkered-skewed.png

First Attempt

In theory, an isosceles trapezoid transform seems simple enough. Take a canvas, make another canvas of equal size, and draw the first canvas to the second canvas row-by-row, calculating the width and x position of each row based on the y position in the image. Let’s try that.

Note: In these examples, I’m using Automattic’s canvas library on NPM, but this can easily be adapted to work in a browser.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
'use strict';

const fs = require('fs').promises;
const {createCanvas, loadImage} = require('canvas');

function canvasSkewV(canvas, t, b) {
const {width, height} = canvas;
const r = createCanvas(width, height);
const ctx = r.getContext('2d');
for (let y = 0; y < height; y++) {
const pY = y / height;
const sX = ((1 - pY) * t + pY * b);
const w = ((width / 2) * sX) * 2;
const x = (width / 2) - (w / 2);
ctx.drawImage(canvas, 0, y, width, 1, x, y, w, 1);
}
return r;
}

async function main() {
const img = await loadImage('checkered.png');
const canvas = createCanvas(img.width, img.height);
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);

const cvs = canvasSkewV(canvas, 0.5, 1);
await fs.writeFile('checkered-skewed.png', cvs.toBuffer('image/png'));
}
main().catch(err => {
process.exitCode = 1;
console.error(err);
});

Simple enough, just copy and resize row-by-row, making sure to keep in the center.

checkered-skewed.png

checkered-skewed.png

Easy right? Not on closer inspection…

checkered-skewed.png zoomed in

checkered-skewed.png zoomed in

See that dashed line down the middle? Seems we’re having trouble drawing the image exactly centered. Darn floating point precision errors…

Looks like we’re going to have to find a way around this.

Second Attempt

We can draw to whole integer offsets like x=0 with perfect precision. So let’s split the job in-half, right down the middle, and reflect the left half before and after we skew it.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
'use strict';

const fs = require('fs').promises;
const {createCanvas, loadImage} = require('canvas');

function canvasSkewVR(canvas, t, b) {
const {width, height} = canvas;
const r = createCanvas(width, height);
const ctx = r.getContext('2d');
for (let y = 0; y < height; y++) {
const pY = y / height;
const sX = ((1 - pY) * t + pY * b);
ctx.drawImage(canvas, 0, y, width, 1, 0, y, width * sX, 1);
}
return r;
}

function canvasSkewV(canvas, t, b) {
const {width, height} = canvas;
const r = createCanvas(width, height);
const ctx = r.getContext('2d');
for (const i of [-1, 1]) {
const cvs = createCanvas(width, height);
const c = cvs.getContext('2d');
c.scale(i, 1);
c.drawImage(canvas, -(width / 2), 0, width, height);
ctx.save();
ctx.scale(i, 1);
ctx.drawImage(canvasSkewVR(cvs, t, b), (width / 2) * i, 0);
ctx.restore();
}
return r;
}

async function main() {
const img = await loadImage('checkered.png');
const canvas = createCanvas(img.width, img.height);
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);

const cvs = canvasSkewV(canvas, 0.5, 1);
await fs.writeFile('checkered-skewed.png', cvs.toBuffer('image/png'));
}
main().catch(err => {
process.exitCode = 1;
console.error(err);
});
checkered-skewed.png

checkered-skewed.png

checkered-skewed.png zoomed in

checkered-skewed.png zoomed in

Now isn’t that nicer? A nice clean line right down the middle, and no weird distortion throughout the image.

Add a small vertical resize and you could have a really nice 2.5D effect on the cheap.

Comments