Image to Sketch: Gaussian Blur and Color Dodge Blending with Canvas#

I was building an image processing toolkit and needed to convert photos into pencil sketch style. After some research, I found the core principle is surprisingly simple: it’s all about combining Gaussian blur with color dodge blending.

The Theory Behind Sketch Effect#

What characterizes a traditional pencil sketch? Clear lines, strong contrast, white background. In digital image processing, edge detection methods like Sobel operator or Canny algorithm are common. But they produce results that are too “hard”, lacking the soft feel of hand-drawn art.

Another approach simulates the actual sketching process: draw outlines first, then add shading. In image processing terms:

  1. Grayscale: Remove color, keep only luminance
  2. Invert: Flip black and white, like a photo negative
  3. Blur: Soften the inverted image, simulating pencil stroke diffusion
  4. Color Dodge Blend: Mix the blurred inverted image with original to extract lines

Step 1: Grayscale and Inversion#

The grayscale formula uses human perception of luminance:

const gray = data[i] * 0.299 + data[i + 1] * 0.587 + data[i + 2] * 0.114

These weights come from ITU-R BT.601 standard. Human eyes are most sensitive to green, then red, then blue.

Inversion is straightforward—subtract from 255:

for (let i = 0; i < data.length; i += 4) {
  const gray = data[i] * 0.299 + data[i + 1] * 0.587 + data[i + 2] * 0.114
  const g = Math.round(gray)
  grayData[i] = grayData[i + 1] = grayData[i + 2] = g

  const inv = 255 - g
  invertedData[i] = invertedData[i + 1] = invertedData[i + 2] = inv
}

Step 2: Gaussian Blur#

Why blur? Because real pencil strokes naturally diffuse on paper. Without blurring the inverted image, the blend would result in pure white—a mathematical certainty.

The core of Gaussian blur is the 2D normal distribution:

function applyGaussianBlur(data: Uint8ClampedArray, width: number, height: number, radius: number) {
  const kernelSize = radius * 2 + 1
  const kernel = new Float32Array(kernelSize)
  const sigma = radius / 2  // standard deviation

  // Calculate Gaussian kernel
  let sum = 0
  for (let i = 0; i < kernelSize; i++) {
    const x = i - radius
    const val = Math.exp(-(x * x) / (2 * sigma * sigma))
    kernel[i] = val
    sum += val
  }
  for (let i = 0; i < kernelSize; i++) {
    kernel[i] /= sum  // Normalize so weights sum to 1
  }
  // ... Horizontal + vertical two-pass 1D convolution
}

Here’s a performance trick: Gaussian blur is separable. A 2D Gaussian kernel can be split into two 1D kernels—horizontal convolution first, then vertical. Complexity drops from O(n²) to O(2n).

The blur radius controls line thickness. Larger radius = bolder strokes; smaller radius = more detail. The tool uses a slider (1-10) for user adjustment.

Step 3: Color Dodge Blending#

This is the crucial step. Color Dodge is defined as:

Result = Base / (255 - Blend)

Where Base is the grayscale image and Blend is the blurred inverted image.

When Blend approaches 255, the denominator nears 0, and the result approaches infinity (clamped to 255 = white). This is why edges brighten—edges have high contrast, inversion amplifies it, blur “bleeds” into neighboring areas, and blending creates intense highlights.

for (let i = 0; i < data.length; i += 4) {
  for (let c = 0; c < 3; c++) {
    const base = grayData[i + c]
    const blend = blurredInverted[i + c]
    const denom = 255 - blend
    if (denom === 0) {
      output[i + c] = 255
    } else {
      const val = (base * 255) / denom
      output[i + c] = Math.min(255, Math.max(0, Math.round(val)))
    }
  }
}

Note the denom === 0 boundary check. When blend equals exactly 255, return white directly to avoid division by zero.

Performance Considerations#

For an 800×600 image, that’s 480,000 pixels. Grayscale and inversion are O(n). Gaussian blur is O(n × radius). With radius=5, each pixel processes 11 neighbors—about 5.3M multiply-add operations total.

In the browser, the main bottleneck is memory access. Using Uint8ClampedArray instead of regular arrays leverages Typed Array’s contiguous memory layout for better cache hits.

Also, between the two 1D convolutions, store intermediate results in a Float32Array to avoid recomputation.

The Result#

Based on this principle, I built: Image Sketch Effect

Features:

  • Drag-and-drop upload or Ctrl+V paste
  • Real-time preview with adjustable line intensity
  • Side-by-side original vs sketch comparison
  • One-click PNG download

The core code is under 100 lines, but the principles span image processing, color science, and mathematics. Implementing it yourself gives you a deeper understanding than just calling a library.


Related: Image Filter | Image Pixelate