Search code examples
javascriptcanvas

Image from a <canvas> toBlob interpolates edges


I am trying to scale a 1x image up to an arbitrary size using a canvas. However, the image always has some interpolation at the edges of each pixel. I expect the image provided to match how the look canvas exactly. No combination of different scales etc. has worked for me. Even right click -> save image on the canvas produces the same image.

I have also tried using toDataURL on the canvas but I get the same result.

I see this on macOS with a 2x display. The same result in Safari, Chrome, and Firefox.

The 2 side-by-side:

side-by-side with the right image having blurred lines

The produced image:

the final (right side) image

// This function is unimportant; it is used as an example for this question.
function generateImage(width, height, digitOffset) {
  const pixelsCount = width * height
  const imageData = new ImageData(width, height)
  const digits = "0123456789"
    .repeat(30)
    .substring(digitOffset, digitOffset + pixelsCount)

  for (const [index, digit] of [...digits].entries()) {
    const colours = {
      0: [0x00, 0x12, 0x19],
      1: [0x00, 0x5f, 0x73],
      2: [0x0a, 0x93, 0x96],
      3: [0x94, 0xd2, 0xbd],
      4: [0xe9, 0xd8, 0xa6],
      5: [0xee, 0x9b, 0x00],
      6: [0xca, 0x67, 0x02],
      7: [0xbb, 0x3e, 0x03],
      8: [0xae, 0x20, 0x12],
      9: [0x9b, 0x22, 0x26],
    }
    const dataOffset = index * 4

    const color = colours[digit]
    imageData.data[dataOffset] = color[0]
    imageData.data[dataOffset + 1] = color[1]
    imageData.data[dataOffset + 2] = color[2]
    imageData.data[dataOffset + 3] = 0xff
  }

  return imageData
}

/** The width/height of the 1x image. */
const size = 16
const imageData = generateImage(size, size, 0)
const scale = 10
/** The canvas containing the 1x image. */
const unscaledCanvas = document.getElementById("unscaledCanvas")
unscaledCanvas.width = size
unscaledCanvas.height = size
unscaledCanvas.style.width = `${size}px`
unscaledCanvas.style.height = `${size}px`
const unscaledContext = unscaledCanvas.getContext("2d")
unscaledContext.putImageData(imageData, 0, 0)

const scaledCanvas = document.getElementById("increasedScale")
scaledCanvas.width = size * scale
scaledCanvas.height = size * scale
scaledCanvas.style.width = `${size * scale}px`
scaledCanvas.style.height = `${size * scale}px`
scaledCanvas.style.imageRendering = "pixelated"
const scaledContext = scaledCanvas.getContext("2d")
scaledContext.imageSmoothingEnabled = false
scaledContext.mozImageSmoothingEnabled = false
scaledContext.webkitImageSmoothingEnabled = false
scaledContext.drawImage(unscaledCanvas, 0, 0, size * scale, size * scale)

const image = document.getElementById("output")
image.width = size * scale
image.height = size * scale
scaledCanvas.toBlob((blob) => {
  const dataURL = URL.createObjectURL(blob)
  image.onload = () => {
    URL.revokeObjectURL(dataURL)
  }
  image.src = dataURL
})
<canvas id="unscaledCanvas"></canvas>
<canvas id="increasedScale"></canvas>
<img id="output" />


Solution

  • The image does not have the antialiasing artifacts you're seeing encoded in it, your browser creates these when upscaling the image to match your 2x monitor's pixel density.

    We can prove that by drawing this same image upscaled again on another <canvas>:

    (async () => {
      // using OP's produced image
      const resp = await fetch("https://i.sstatic.net/7oZk5oeK.png");
      const blob = await resp.blob();
      const bmp = await createImageBitmap(blob);
      const canvas = document.querySelector("canvas");
      // Make the canvas the correct density
      canvas.width *= devicePixelRatio;
      canvas.height *= devicePixelRatio; 
      const ctx = canvas.getContext("2d");
      ctx.scale(devicePixelRatio, devicePixelRatio);
      // no smoothing
      ctx.imageSmoothingEnabled = false;
      // upscale
      ctx.scale(10, 10);
      ctx.drawImage(bmp, 0, 0);
    })().catch(console.error);
    canvas { width: 300px; height: 150px; }
    <canvas></canvas>

    Note that for this canvas I took care of avoiding the resizing issue by resizing the <canvas>'s width and height based on the current dPR, while downscaling it through CSS.

    To render your image correctly in an <img>, you could prepare multiple versions with different density and then use the srcset attribute of your <img>:

    <img 
      width=160 height=160
      srcset="
        https://i.sstatic.net/7oZk5oeK.png,
        https://i.sstatic.net/OlFcowA1.png 2x,
        https://i.sstatic.net/QsWfkvSn.png 3x,
      ">

    Or you could use the same image-rendering CSS rule you used on your <canvas>:

    img { image-rendering: pixelated }
    <img src="https://i.sstatic.net/7oZk5oeK.png">