Document scanner apps that automatically straighten photos are doing something mathematically interesting: they're computing a homography transform to map an arbitrarily-angled quadrilateral back to a rectangle.
This post covers the math and implementation we used for ToolZip's perspective correction tool.
What Is a Homography?
A homography is a 3×3 matrix that maps points in one projective plane to another. For perspective correction, we want to map four corner points of a document (which appear as a trapezoid in the photo) to four corners of a rectangle.
┌─────────────────────┐ ┌────────────────────┐
│ │ │ │
│ Perspective │ H │ Corrected │
│ distortion ─────┼──────►│ rectangle │
│ (trapezoid) │ │ │
│ │ │ │
└─────────────────────┘ └────────────────────┘
The transform is "projective" (preserves lines but not angles or distances), which is exactly what we need — a tilted rectangle projects to a trapezoid, and we want to reverse that.
The Math: Solving for the Homography Matrix
Given four source points (corners of the document in the photo) and four destination points (corners of the output rectangle), we need to find the 3×3 matrix H such that:
[x'] [h00 h01 h02] [x]
[y'] = [h10 h11 h12] × [y]
[w'] [h20 h21 h22] [1]
With output coordinates: x_out = x'/w', y_out = y'/w'
This gives us 8 unknowns (h22 = 1 by convention). Each point correspondence gives 2 equations, so 4 points = 8 equations = exactly solvable.
Setting up the linear system (DLT algorithm):
function computeHomography(srcPoints, dstPoints) {
// Build 8x8 matrix A and vector b for Ax = b
const A = [];
const b = [];
for (let i = 0; i < 4; i++) {
const [sx, sy] = srcPoints[i];
const [dx, dy] = dstPoints[i];
// Two equations per point
A.push([sx, sy, 1, 0, 0, 0, -dx*sx, -dx*sy]);
b.push(dx);
A.push([0, 0, 0, sx, sy, 1, -dy*sx, -dy*sy]);
b.push(dy);
}
// Solve using Gaussian elimination
const h = gaussianElimination(A, b);
// Reshape to 3x3 matrix (h8 = 1 by convention)
return [
[h[0], h[1], h[2]],
[h[3], h[4], h[5]],
[h[6], h[7], 1]
];
}
Gaussian Elimination
function gaussianElimination(A, b) {
const n = A.length;
const augmented = A.map((row, i) => [...row, b[i]]);
for (let col = 0; col < n; col++) {
// Find pivot
let maxRow = col;
for (let row = col + 1; row < n; row++) {
if (Math.abs(augmented[row][col]) > Math.abs(augmented[maxRow][col])) {
maxRow = row;
}
}
[augmented[col], augmented[maxRow]] = [augmented[maxRow], augmented[col]];
// Eliminate below
for (let row = col + 1; row < n; row++) {
const factor = augmented[row][col] / augmented[col][col];
for (let k = col; k <= n; k++) {
augmented[row][k] -= factor * augmented[col][k];
}
}
}
// Back substitution
const x = new Array(n);
for (let i = n - 1; i >= 0; i--) {
x[i] = augmented[i][n];
for (let j = i + 1; j < n; j++) {
x[i] -= augmented[i][j] * x[j];
}
x[i] /= augmented[i][i];
}
return x;
}
Applying the Transform: Inverse Mapping
The naive approach (forward mapping) maps source pixels to destination pixels. Problem: you get holes — some destination pixels never get a value.
The correct approach is inverse mapping: for each pixel in the destination image, compute where it came from in the source image.
function applyHomography(H, x, y) {
const [h0, h1, h2] = H[0];
const [h3, h4, h5] = H[1];
const [h6, h7, h8] = H[2];
const w = h6*x + h7*y + h8;
return [
(h0*x + h1*y + h2) / w,
(h3*x + h4*y + h5) / w
];
}
function invertHomography(H) {
// 3x3 matrix inverse
const det =
H[0][0] * (H[1][1]*H[2][2] - H[1][2]*H[2][1]) -
H[0][1] * (H[1][0]*H[2][2] - H[1][2]*H[2][0]) +
H[0][2] * (H[1][0]*H[2][1] - H[1][1]*H[2][0]);
return [
[(H[1][1]*H[2][2]-H[1][2]*H[2][1])/det, (H[0][2]*H[2][1]-H[0][1]*H[2][2])/det, (H[0][1]*H[1][2]-H[0][2]*H[1][1])/det],
[(H[1][2]*H[2][0]-H[1][0]*H[2][2])/det, (H[0][0]*H[2][2]-H[0][2]*H[2][0])/det, (H[0][2]*H[1][0]-H[0][0]*H[1][2])/det],
[(H[1][0]*H[2][1]-H[1][1]*H[2][0])/det, (H[0][1]*H[2][0]-H[0][0]*H[2][1])/det, (H[0][0]*H[1][1]-H[0][1]*H[1][0])/det]
];
}
Putting It Together
function perspectiveCorrect(srcCanvas, srcPoints) {
const outputWidth = Math.max(
distance(srcPoints[0], srcPoints[1]),
distance(srcPoints[2], srcPoints[3])
);
const outputHeight = Math.max(
distance(srcPoints[0], srcPoints[3]),
distance(srcPoints[1], srcPoints[2])
);
const dstPoints = [
[0, 0],
[outputWidth, 0],
[outputWidth, outputHeight],
[0, outputHeight]
];
// Compute H (source → destination)
const H = computeHomography(srcPoints, dstPoints);
// Invert for destination → source mapping
const Hinv = invertHomography(H);
const dstCanvas = document.createElement('canvas');
dstCanvas.width = outputWidth;
dstCanvas.height = outputHeight;
const dstCtx = dstCanvas.getContext('2d');
const dstImageData = dstCtx.createImageData(outputWidth, outputHeight);
const srcCtx = srcCanvas.getContext('2d');
const srcImageData = srcCtx.getImageData(0, 0, srcCanvas.width, srcCanvas.height);
// For each destination pixel, find its source
for (let y = 0; y < outputHeight; y++) {
for (let x = 0; x < outputWidth; x++) {
const [srcX, srcY] = applyHomography(Hinv, x, y);
// Bilinear interpolation for smooth result
const pixel = bilinearSample(srcImageData, srcX, srcY);
const dstIdx = (y * outputWidth + x) * 4;
dstImageData.data[dstIdx] = pixel.r;
dstImageData.data[dstIdx+1] = pixel.g;
dstImageData.data[dstIdx+2] = pixel.b;
dstImageData.data[dstIdx+3] = pixel.a;
}
}
dstCtx.putImageData(dstImageData, 0, 0);
return dstCanvas;
}
Bilinear Interpolation
Simply taking the nearest pixel produces blocky results. Bilinear interpolation blends the four surrounding pixels:
function bilinearSample(imageData, x, y) {
const { width, height, data } = imageData;
// Clamp to image bounds
x = Math.max(0, Math.min(width - 1, x));
y = Math.max(0, Math.min(height - 1, y));
const x0 = Math.floor(x), x1 = Math.min(x0 + 1, width - 1);
const y0 = Math.floor(y), y1 = Math.min(y0 + 1, height - 1);
const fx = x - x0, fy = y - y0;
function getPixel(px, py) {
const i = (py * width + px) * 4;
return { r: data[i], g: data[i+1], b: data[i+2], a: data[i+3] };
}
const p00 = getPixel(x0, y0), p10 = getPixel(x1, y0);
const p01 = getPixel(x0, y1), p11 = getPixel(x1, y1);
return {
r: lerp(lerp(p00.r, p10.r, fx), lerp(p01.r, p11.r, fx), fy),
g: lerp(lerp(p00.g, p10.g, fx), lerp(p01.g, p11.g, fx), fy),
b: lerp(lerp(p00.b, p10.b, fx), lerp(p01.b, p11.b, fx), fy),
a: lerp(lerp(p00.a, p10.a, fx), lerp(p01.a, p11.a, fx), fy)
};
}
function lerp(a, b, t) { return a + (b - a) * t; }
function distance(p1, p2) { return Math.hypot(p2[0]-p1[0], p2[1]-p1[1]); }
Performance: This Is Slow in Pure JS
For a 1200×900 output image, you're doing 1.08 million inverse transform lookups + bilinear interpolations. In pure JavaScript, that takes several seconds.
Options for better performance:
- WebGL shader: The transform is trivially parallelizable. A fragment shader can do each pixel simultaneously on the GPU.
- WebAssembly: Compile OpenCV's warpPerspective to WASM (~10x speedup over JS)
- OffscreenCanvas + Worker: At least keep the UI responsive
We use a combination of Web Workers (to avoid blocking UI) and canvas drawImage with CSS transforms for the preview, then full-quality processing only on export.
Try It
ToolZip Perspective Correction — drag the four corners to define your document, get a straightened result.
If you're implementing something similar, the full homography math is well worth understanding — it shows up in AR, panorama stitching, and anywhere you're mapping between two views of a planar surface.
Tags: #javascript #computerscience #webdev #canvas