DEV Community

toolzip
toolzip

Posted on Originally published at toolzip.app

Perspective Correction in the Browser: Implementing Homography Transforms with Canvas API

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)       │       │                    │
│                     │       │                    │
└─────────────────────┘       └────────────────────┘
Enter fullscreen mode Exit fullscreen mode

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]
Enter fullscreen mode Exit fullscreen mode

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]
  ];
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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]
  ];
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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]); }
Enter fullscreen mode Exit fullscreen mode

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:

  1. WebGL shader: The transform is trivially parallelizable. A fragment shader can do each pixel simultaneously on the GPU.
  2. WebAssembly: Compile OpenCV's warpPerspective to WASM (~10x speedup over JS)
  3. 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.

Top comments (0)