Skip to content

Why is canvas messing with my image’s colors?

I’m developing an app that has a painting feature. The user can paint on an image that is initially made of only pure black and pure white pixels. Later, after the user has finished painting, I need to do some processing on that image based on the colors of each pixel.

However, I realized that by the time I processed the image, the pixels weren’t purely black/white anymore, but there were lots of greys in between, even if the user didn’t paint anything. I wrote some code to check it and found out there were over 250 different colors on the image, while I was expecting only two (black and white). I suspect canvas is messing with my colors somehow, but I can’t figure out why.

I hosted a demo on GitHub, showcasing the problem.

The image

This is the image. It is visibly made of only black and white pixels, but if you want to check by yourself you can use this website. It’s source code is available on GitHub and I used it as a reference for my own color counting implementation.

My code

Here is the code where I load the image and count the unique colors. You can get the full source here.

class AppComponent {
  /* ... */

  // Rendering the image
  ngAfterViewInit() {
    this.context = this.canvas.nativeElement.getContext('2d');

    const image = new Image();
    image.src = 'assets/image.png';

    image.onload = () => {
      if (!this.context) return;

      this.context.globalCompositeOperation = 'source-over';
      this.context.drawImage(image, 0, 0, this.width, this.height);
    };
  }

  // Counting unique colors
  calculate() {
    const imageData = this.context?.getImageData(0, 0, this.width, this.height);
    const data = imageData?.data || [];

    const uniqueColors = new Set();

    for (let i = 0; i < data?.length; i += 4) {
      const [red, green, blue, alpha] = data.slice(i, i + 4);
      const color = `rgba(${red}, ${green}, ${blue}, ${alpha})`;
      uniqueColors.add(color);
    }

    this.uniqueColors = String(uniqueColors.size);
  }

This is the implementation from the other site:

function countPixels(data) {   
    const colorCounts = {};
    for(let index = 0; index < data.length; index += 4) {
        const rgba = `rgba(${data[index]}, ${data[index + 1]}, ${data[index + 2]}, ${(data[index + 3] / 255)})`;

        if (rgba in colorCounts) {
            colorCounts[rgba] += 1;
        } else {
            colorCounts[rgba] = 1;
        }
    }    
    return colorCounts;
}

As you can see, besides the implementations being similar, they output very different results – my site says I have 256 unique colors, while the other says there’s only two. I also tried to just copy and paste the implementation but I got the same 256. That’s why I imagine the problem is in my canvas, but I can’t figure out what’s going on.

Answer

You are scaling your image, and since you didn’t tell which interpolation algorithm to use, a default smoothing one is being used.

This will make all the pixels that were on fixed boundaries and should now span on multiple pixels to be “mixed” with their white neighbors and produce shades of gray.

There is an imageSmoothingEnabled property that tells the browser to use a closest-neighbor algorithm, which will improve the situation, but even then you may not have a perfect result:

const canvas = document.querySelector("canvas");
const width = canvas.width = innerWidth;
const height = canvas.height = innerHeight;
const ctx = canvas.getContext("2d");
const img = new Image();
img.crossOrigin = "anonymous";
img.src = "https://raw.githubusercontent.com/ajsaraujo/unique-color-count-mre/master/src/assets/image.png";
img.decode().then(() => {
  ctx.imageSmoothingEnabled = false;
  ctx.drawImage(img, 0, 0, width, height);
  const data = ctx.getImageData(0, 0, width, height).data;
  const pixels = new Set(new Uint32Array(data.buffer));
  console.log(pixels.size);
});
<canvas></canvas>

So the best would be to not scale your image, or to do so in a computer friendly fashion (using a factor that is a multiple of 2).