Academic Block

Canvas Image Manipulation Get Certified

The <canvas> element gives JavaScript direct access to individual pixels. That is the foundation behind filters like grayscale, invert, brightness, and pixelation.

Creating a Canvas and Drawing Shapes

A canvas starts blank. Its 2D context gives you drawing methods like fillRect(), arc(), and gradients. Every filter in this lesson starts with this same setup.

What this code does: Creates a 300×200 canvas in memory, fills it with a dark blue background, paints an orange gradient band across the top, and draws a yellow circle in the lower half. It then prints the canvas size and shows the finished picture below the text output. Use it to learn the three basic steps of every canvas program: create the canvas, get the context, draw.

// Helper: shows a copy of a canvas below the text output
function showCanvas(label, source) {
    const copy = document.createElement("canvas");
    copy.width = source.width;
    copy.height = source.height;
    copy.getContext("2d").drawImage(source, 0, 0);
    copy.style.maxWidth = "100%";
    setTimeout(() => {
        const box = document.createElement("div");
        box.className = "demo-canvas-box";
        box.innerHTML = "<p class='demo-canvas-label'>" + label + "</p>";
        box.appendChild(copy);
        document.getElementById("previewContent").appendChild(box);
    }, 0);
}

const canvas = document.createElement("canvas");
canvas.width = 300;
canvas.height = 200;
const ctx = canvas.getContext("2d");

// Background
ctx.fillStyle = "#1e3a5f";
ctx.fillRect(0, 0, 300, 200);

// Gradient band
const gradient = ctx.createLinearGradient(0, 0, 300, 0);
gradient.addColorStop(0, "#ff7e5f");
gradient.addColorStop(1, "#feb47b");
ctx.fillStyle = gradient;
ctx.fillRect(20, 20, 260, 60);

// Circle
ctx.beginPath();
ctx.arc(150, 130, 40, 0, Math.PI * 2);
ctx.fillStyle = "#ffe66d";
ctx.fill();

console.log("Canvas created:", canvas.width + "x" + canvas.height);
console.log("Scene drawn: gradient band + yellow circle on dark background");
showCanvas("Result: shapes drawn on the canvas", canvas);

Grayscale With getImageData() / putImageData()

getImageData() returns the raw pixel array (four numbers per pixel: red, green, blue, alpha). Averaging the red, green, and blue values of each pixel removes colour.

What this code does: Draws five coloured vertical stripes, then reads every pixel with getImageData(). For each pixel it averages the R, G, and B values and writes that same number back to all three channels, so every colour becomes a shade of gray. The result is written back with putImageData(). The output shows the picture before and after the filter, plus how many pixels were processed (300 × 200 = 60,000).

// Helper: shows a copy of a canvas below the text output
function showCanvas(label, source) {
    const copy = document.createElement("canvas");
    copy.width = source.width;
    copy.height = source.height;
    copy.getContext("2d").drawImage(source, 0, 0);
    copy.style.maxWidth = "100%";
    setTimeout(() => {
        const box = document.createElement("div");
        box.className = "demo-canvas-box";
        box.innerHTML = "<p class='demo-canvas-label'>" + label + "</p>";
        box.appendChild(copy);
        document.getElementById("previewContent").appendChild(box);
    }, 0);
}

const canvas = document.createElement("canvas");
canvas.width = 300;
canvas.height = 200;
const ctx = canvas.getContext("2d");

// Draw five coloured stripes, each 60px wide
const colors = ["#e63946", "#f1c453", "#2a9d8f", "#264653", "#e76f51"];
colors.forEach((color, i) => {
    ctx.fillStyle = color;
    ctx.fillRect(i * 60, 0, 60, 200);
});

function toGrayscale(ctx, width, height) {
    const imageData = ctx.getImageData(0, 0, width, height);
    const pixels = imageData.data;
    let count = 0;

    for (let i = 0; i < pixels.length; i += 4) {
        const avg = (pixels[i] + pixels[i + 1] + pixels[i + 2]) / 3;
        pixels[i] = avg;
        pixels[i + 1] = avg;
        pixels[i + 2] = avg;
        count++;
    }

    ctx.putImageData(imageData, 0, 0);
    return count;
}

showCanvas("Before: original colours", canvas);
const processed = toGrayscale(ctx, canvas.width, canvas.height);
showCanvas("After: grayscale", canvas);
console.log("Grayscale filter applied");
console.log("Pixels processed:", processed);
console.log("Expected pixels:", canvas.width * canvas.height);

Inverting Colors

Inverting is simple: subtract each colour channel from 255. Bright colours become dark and the other way round.

What this code does: Draws a row of six overlapping circles, each with a different hue on a cream background. It then replaces every red, green, and blue value with 255 minus the old value, which creates the “photo negative” look. The output shows the picture before and after, and prints two sample calculations so you can see the arithmetic.

// Helper: shows a copy of a canvas below the text output
function showCanvas(label, source) {
    const copy = document.createElement("canvas");
    copy.width = source.width;
    copy.height = source.height;
    copy.getContext("2d").drawImage(source, 0, 0);
    copy.style.maxWidth = "100%";
    setTimeout(() => {
        const box = document.createElement("div");
        box.className = "demo-canvas-box";
        box.innerHTML = "<p class='demo-canvas-label'>" + label + "</p>";
        box.appendChild(copy);
        document.getElementById("previewContent").appendChild(box);
    }, 0);
}

const canvas = document.createElement("canvas");
canvas.width = 300;
canvas.height = 200;
const ctx = canvas.getContext("2d");

ctx.fillStyle = "#f4f1de";
ctx.fillRect(0, 0, 300, 200);

for (let i = 0; i < 6; i++) {
    ctx.beginPath();
    ctx.arc(50 + i * 45, 100, 25, 0, Math.PI * 2);
    ctx.fillStyle = `hsl(${i * 60}, 70%, 55%)`;
    ctx.fill();
}

function invertColors(ctx, width, height) {
    const imageData = ctx.getImageData(0, 0, width, height);
    const pixels = imageData.data;

    for (let i = 0; i < pixels.length; i += 4) {
        pixels[i]     = 255 - pixels[i];
        pixels[i + 1] = 255 - pixels[i + 1];
        pixels[i + 2] = 255 - pixels[i + 2];
    }

    ctx.putImageData(imageData, 0, 0);
}

showCanvas("Before: original colours", canvas);
invertColors(ctx, canvas.width, canvas.height);
showCanvas("After: inverted", canvas);
console.log("Invert filter applied");
console.log("Example: white (255) becomes", 255 - 255, "and black (0) becomes", 255 - 0);

Adjusting Brightness

Brightening or darkening means adding (or subtracting) a fixed amount from every colour channel, then keeping the result between 0 and 255.

What this code does: Draws a dark gradient with a red rectangle in the middle, then adds 70 to the red, green, and blue value of every pixel. Math.min and Math.max keep each result between 0 and 255. The output shows the scene before and after, so you can compare how much lighter it becomes. Try changing 70 to a negative number such as -70 to darken it instead.

// Helper: shows a copy of a canvas below the text output
function showCanvas(label, source) {
    const copy = document.createElement("canvas");
    copy.width = source.width;
    copy.height = source.height;
    copy.getContext("2d").drawImage(source, 0, 0);
    copy.style.maxWidth = "100%";
    setTimeout(() => {
        const box = document.createElement("div");
        box.className = "demo-canvas-box";
        box.innerHTML = "<p class='demo-canvas-label'>" + label + "</p>";
        box.appendChild(copy);
        document.getElementById("previewContent").appendChild(box);
    }, 0);
}

const canvas = document.createElement("canvas");
canvas.width = 300;
canvas.height = 200;
const ctx = canvas.getContext("2d");

const gradient = ctx.createLinearGradient(0, 0, 300, 200);
gradient.addColorStop(0, "#2b2d42");
gradient.addColorStop(1, "#8d99ae");
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, 300, 200);

ctx.fillStyle = "#ef233c";
ctx.fillRect(100, 60, 100, 80);

function adjustBrightness(ctx, width, height, amount) {
    const imageData = ctx.getImageData(0, 0, width, height);
    const pixels = imageData.data;

    for (let i = 0; i < pixels.length; i += 4) {
        pixels[i]     = Math.min(255, Math.max(0, pixels[i] + amount));
        pixels[i + 1] = Math.min(255, Math.max(0, pixels[i + 1] + amount));
        pixels[i + 2] = Math.min(255, Math.max(0, pixels[i + 2] + amount));
    }

    ctx.putImageData(imageData, 0, 0);
}

showCanvas("Before: dark scene", canvas);
adjustBrightness(ctx, canvas.width, canvas.height, 70);
showCanvas("After: brightness +70", canvas);
console.log("Brightness adjusted by +70");
console.log("Each channel clamped between 0 and 255");

A Pixelation Effect

Pixelation divides the image into square blocks, averages the colour inside each block, then fills the whole block with that average colour.

What this code does: Draws a three-colour gradient with a yellow circle, then splits the canvas into 15×15 pixel blocks. For each block it adds up the red, green, and blue values of all its pixels, divides by the pixel count to get an average colour, and paints the block with that single colour. The output shows the smooth picture before and the blocky picture after, and compares the number of blocks created with the number you would expect.

// Helper: shows a copy of a canvas below the text output
function showCanvas(label, source) {
    const copy = document.createElement("canvas");
    copy.width = source.width;
    copy.height = source.height;
    copy.getContext("2d").drawImage(source, 0, 0);
    copy.style.maxWidth = "100%";
    setTimeout(() => {
        const box = document.createElement("div");
        box.className = "demo-canvas-box";
        box.innerHTML = "<p class='demo-canvas-label'>" + label + "</p>";
        box.appendChild(copy);
        document.getElementById("previewContent").appendChild(box);
    }, 0);
}

const canvas = document.createElement("canvas");
canvas.width = 300;
canvas.height = 200;
const ctx = canvas.getContext("2d");

const gradient = ctx.createLinearGradient(0, 0, 300, 200);
gradient.addColorStop(0, "#06d6a0");
gradient.addColorStop(0.5, "#118ab2");
gradient.addColorStop(1, "#ef476f");
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, 300, 200);

ctx.beginPath();
ctx.arc(150, 100, 60, 0, Math.PI * 2);
ctx.fillStyle = "#ffd166";
ctx.fill();

function pixelate(ctx, width, height, blockSize) {
    let blocks = 0;

    for (let y = 0; y < height; y += blockSize) {
        for (let x = 0; x < width; x += blockSize) {
            const w = Math.min(blockSize, width - x);
            const h = Math.min(blockSize, height - y);
            const blockData = ctx.getImageData(x, y, w, h).data;

            let r = 0, g = 0, b = 0, count = 0;
            for (let i = 0; i < blockData.length; i += 4) {
                r += blockData[i];
                g += blockData[i + 1];
                b += blockData[i + 2];
                count++;
            }

            ctx.fillStyle = `rgb(${Math.round(r / count)}, ${Math.round(g / count)}, ${Math.round(b / count)})`;
            ctx.fillRect(x, y, w, h);
            blocks++;
        }
    }
    return blocks;
}

showCanvas("Before: smooth image", canvas);
const totalBlocks = pixelate(ctx, canvas.width, canvas.height, 15);
showCanvas("After: 15px pixelation", canvas);
console.log("Pixelation applied with 15px blocks");
console.log("Total blocks created:", totalBlocks);
console.log("Expected:", Math.ceil(300 / 15) * Math.ceil(200 / 15));

Quick Reference

Method / Property Purpose
getContext("2d") Gets the 2D drawing context
fillRect(x, y, w, h) Draws a filled rectangle
arc(x, y, r, start, end) Draws a circle or arc
createLinearGradient(...) Creates a gradient fill
getImageData(x, y, w, h) Reads raw pixel data as a flat array
putImageData(data, x, y) Writes modified pixel data back

Common Mistakes

  • Forgetting the pixel array is flat and grouped in fours (step by 4)
  • Forgetting that values must stay in the 0–255 range (ImageData.data clamps for you, but a normal array does not)
  • Calling getImageData() inside a tight per-pixel loop
  • Accidentally modifying the alpha channel
  • Forgetting putImageData(), so the canvas never changes
  • Running heavy filters on every animation frame without optimization

Best Practices

  • Read pixel data once, process the array, then write it back once
  • Keep filter functions small and single-purpose
  • Clamp channel values after arithmetic
  • Use OffscreenCanvas + Web Workers for heavy processing
  • Draw a real photo with drawImage() before applying the same filters

Quick Summary

  • Canvas drawing methods ultimately produce a grid of pixel colours
  • getImageData() / putImageData() let you read and write that grid
  • Grayscale averages RGB; invert uses 255 − value; brightness adds a clamped amount
  • Pixelation averages colours inside square blocks
  • The same techniques work on real photos after drawing them onto the canvas

Try It Yourself

What this code does: Draws an orange gradient with a blue circle, then chains two filters: first grayscale (removes colour), then +40 brightness (lightens the gray). Because each filter reads the canvas and writes it back, the second filter works on the result of the first. The output shows three pictures (original, after grayscale, after brightness) so you can see each step of the pipeline. Experiment: swap the order of the two filters, or change 40 to 100, and run it again.

// Helper: shows a copy of a canvas below the text output
function showCanvas(label, source) {
    const copy = document.createElement("canvas");
    copy.width = source.width;
    copy.height = source.height;
    copy.getContext("2d").drawImage(source, 0, 0);
    copy.style.maxWidth = "100%";
    setTimeout(() => {
        const box = document.createElement("div");
        box.className = "demo-canvas-box";
        box.innerHTML = "<p class='demo-canvas-label'>" + label + "</p>";
        box.appendChild(copy);
        document.getElementById("previewContent").appendChild(box);
    }, 0);
}

const canvas = document.createElement("canvas");
canvas.width = 300;
canvas.height = 200;
const ctx = canvas.getContext("2d");

const gradient = ctx.createLinearGradient(0, 0, 300, 0);
gradient.addColorStop(0, "#ff7e5f");
gradient.addColorStop(1, "#feb47b");
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, 300, 200);

ctx.beginPath();
ctx.arc(150, 100, 50, 0, Math.PI * 2);
ctx.fillStyle = "#118ab2";
ctx.fill();

function toGrayscale(ctx, width, height) {
    const imageData = ctx.getImageData(0, 0, width, height);
    const pixels = imageData.data;
    for (let i = 0; i < pixels.length; i += 4) {
        const avg = (pixels[i] + pixels[i + 1] + pixels[i + 2]) / 3;
        pixels[i] = avg;
        pixels[i + 1] = avg;
        pixels[i + 2] = avg;
    }
    ctx.putImageData(imageData, 0, 0);
}

function adjustBrightness(ctx, width, height, amount) {
    const imageData = ctx.getImageData(0, 0, width, height);
    const pixels = imageData.data;
    for (let i = 0; i < pixels.length; i += 4) {
        pixels[i]     = Math.min(255, Math.max(0, pixels[i] + amount));
        pixels[i + 1] = Math.min(255, Math.max(0, pixels[i + 1] + amount));
        pixels[i + 2] = Math.min(255, Math.max(0, pixels[i + 2] + amount));
    }
    ctx.putImageData(imageData, 0, 0);
}

showCanvas("Step 0: original", canvas);
console.log("Applying grayscale...");
toGrayscale(ctx, canvas.width, canvas.height);
showCanvas("Step 1: grayscale", canvas);

console.log("Then applying +40 brightness...");
adjustBrightness(ctx, canvas.width, canvas.height, 40);
showCanvas("Step 2: grayscale + brightness", canvas);

console.log("Filter pipeline complete: grayscale → brightness");
Tip: Build filters as small reusable functions that take (ctx, width, height). This shape makes it easy to chain several filters into a pipeline.

🧪 Test Your JavaScript Code

Edit the code on the left and click “Run Code” to see the console output on the right.

📝 JavaScript Code
👁 Console Output

Click “Run Code” to see the output here.

Ctrl + Enter to run code Esc to close