I stumbled upon KoalasToTheMax while reading a blog post about the most exciting web pages built for fun, and I was blown away. Srijan explained how it works, and we were inspired to create something similar.

But first, let’s get into the basics of the data structures used in our project.

Quadtree

A quadtree is a tree-based data structure where each node has exactly four child nodes. Our quadtree represents a partition of space in two dimensions by dividing the region into four equal quadrants. Each quadrant is then subdivided into four equal quadrants, and so on. Each node in the tree has exactly four children or no children at all, which makes it a leaf node. The height of a quadtree depends on the amount of data being decomposed.

quadtree

The root node is the image. Each node is the average value of its children’s pixel values. The tree is recursively subdivided until each leaf node is a single pixel. The tree is then traversed to compress the image. To decompress the image, the tree is traversed again.

Member Functions

Our quadtree has the following member functions:

  • compressImageData : It takes the image data and the compression factor as the input and returns the quadtree.
  • createQTreeOfHeight : It takes the height of the tree and the bounding box as the input and returns the quadtree.
  • populate : It populates the quadtree with the pixel values.
  • getRGBValuesFromCoordinates : It takes the quadtree and the coordinates as the input and returns the pixel value at the given coordinates.

Node

The QTNODE class represents a node in the quadtree. It has the following properties:

  • x : x-coordinate
  • y : y-coordinate
  • w : width of the bounding box.
  • h : height of the bounding box.
  • children : array of four children.
  • rgb : pixel value of the node.

Functions:

  • draw : draws the node. Takes the canvas context as the input and returns nothing.
  • insert: inserts a node into the quadtree. Takes the quadtree and the node as the input and returns the quadtree.
  • drawAtHeight: draws the nodes at a given height. Takes the canvas context, the height of the tree, and the current height as the input and returns nothing.
  • draw: draws the nodes. Takes the canvas context and the height of the tree as the input and returns nothing.
  • reveal: reveals the nodes. Takes the canvas context and the height of the tree as the input and returns nothing.
  • computeAverageColor: computes the average color of the node. Takes the quadtree and the image data as the input and returns the pixel value.

Demonstration


Hover over any part of the canvas to recursively divide it into four quadrants.



The slider controls the depth of the tree.

The slider is set to 0 by default, which means the entire image is compressed into a single pixel. The slider when set to 100 means the image is not compressed at all. The slider can be set to any value between 0 and 100.

Setting up the Canvas

To start with, we need two canvases - one for you to hover over and the other for you to control the depth of the tree using a slider and render the nodes evenly. Initially, we had multiple ways of taking an image as the input form, such as uploading an image, using query parameters, etc. But for this demonstration, we’ll keep it simple and use a static image.

Here’s some JavaScript code that will load the image into a canvas:

const image = new Image();
img.src = "/blog/assets/images/qtree/cryptopunk.jpeg";
img.onload = () => {
    initSliderCanvas(img);
    initMouseCanvas(img);
};

I’ll go ahead and explain the working of the mouse hover canvas and you can explore the slider canvas. The code is available on GitHub, and you can play around with it later if you are interested.

import { compressImageData } from "./qdtree.js";
function initMouseCanvas(img){
	const canvas = document.getElementById("canvas-2");
    const ctx = canvas.getContext("2d");

    const imageData = readImageDataUsingCanvas(canvas, ctx, image);
    const qTree = compressImageData(imageData, 1);
    qTree.draw(ctx);
	...
}

Image Data Compression

Now, let’s get to the fun part - compressing images. We import the compressImageData function from the qdtree.js file. This function takes the image data and the compression factor as the input and returns the quadtree. The height of the tree is calculated by taking the log of the number of pixels in the image and dividing it by the log of 4 (number of children of a node). The log of 4 is 2, and the log of the number of pixels is the height of the tree. The height of the tree is then rounded down to the nearest integer. The tree is then created using the createQTreeOfHeight function that takes the height of the tree and the bounding box as the input and returns the quadtree.

qdtree.js

export function compressImageData(imageData, factor) {
  const { width, height } = imageData;
  const newWidth = Math.ceil(width / factor);
  const newHeight = Math.ceil(height / factor);

  const qTreeHeight = Math.floor(Math.log(newWidth * newHeight) / Math.log(4));

  const qTree = createQTreeOfHeight(qTreeHeight, {
    x: 0,
    y: 0,
    w: width,
    h: height,
  });

  populate(qTree, imageData);
  return qTree;
}

The createQTreeOfHeight function takes in the height of the tree and the bounding box as the input and returns the quadtree.

Bounding Box

The bounding box is the area that the node represents. The bounding box is initially the entire image. The bounding box is then divided into four equal quadrants, and the process is repeated until the height of the tree is 0. The populate function takes the quadtree and the image data as the input and populates the tree with the average pixel values of the children’s pixel values.

 function createQTreeOfHeight(height, aabb) {
  function recursiveCreate(node, height) {
    if (height === 0) return;

    const { x, y, w, h } = node.aabb;
    const halfW = w / 2;
    const halfH = h / 2;


	//top left
    node.tl = new QTNode({
      x,
      y,
      w: halfW,
      h: halfH,
    });
		//top right
    node.tr = new QTNode({
      x: x + halfW,
      y,
      w: halfW,
      h: halfH,
    });
		//bottom left
    node.bl = new QTNode({
      x,
      y: y + halfH,
      w: halfW,
      h: halfH,
    });
		//bottom right
    node.br = new QTNode({
      x: x + halfW,
      y: y + halfH,
      w: halfW,
      h: halfH,
    });

    recursiveCreate(node.tl, height - 1);
    recursiveCreate(node.tr, height - 1);
    recursiveCreate(node.bl, height - 1);
    recursiveCreate(node.br, height - 1);
  }
 }

Image data to Quadtree

Alright, let’s talk about the reveal function, which is like a magician revealing the hidden nodes under the mouse. And then there’s the draw function, which is like an artist sketching the nodes on the canvas. The update function is like your mom constantly cleaning up after you every 30 milliseconds. It clears the canvas and redraws the nodes on the canvas, all to make sure it looks neat and tidy.

function initMouseCanvas(img){
	...
 const mousePos = { x: 0, y: 0 };
    let lastUpdateTime = -Infinity;
    const frameTime = 30;

    function update() {
        const currentTime = Date.now();
        const diff = currentTime - lastUpdateTime;
        if (diff < frameTime) return;

        lastUpdateTime = currentTime;
        ctx.clearRect(0, 0, canvas.width, canvas.height);
        qTree.reveal(mousePos.x, mousePos.y);
        qTree.draw(ctx);
    }

    canvas.addEventListener("mousemove", (e) => {
        const rect = canvas.getBoundingClientRect();
        const x = e.clientX - rect.left;
        const y = e.clientY - rect.top;
        mousePos.x = x;
        mousePos.y = y;
        update();
    });
}

Conclusion

So, what’s the point of all this? Well, we can use a quadtree to compress and decompress images when the mouse hovers over a specific area of the canvas. It’s like a secret code that unlocks a hidden image!

Ref: Wikipedia