Spot the bug - #129: Retina Pixel Sampler

Why is my pixel snapshot reading from the wrong position?

const ctx = canvas.getContext('2d');
ctx.scale(window.devicePixelRatio, window.devicePixelRatio);
ctx.fillRect(20, 20, 50, 50);
const pixel = ctx.getImageData(20, 20, 1, 1).data;

Reply with what is broken and how you would fix it.

The scaling by the devicePixelRatio changes the size of the area you are drawing, including the coordinates.

Interesting observation, @kirupa. We’ll see if that’s the whole story when the solution drops later today.

The retina pixel sampling detail is a subtle one. I’ve seen similar issues cause headaches in data visualization exports.

The issue is that ctx.scale() changes the canvas coordinate system, but getImageData() works with the canvas’s actual pixel coordinates. With a device pixel ratio of 2, for example, the rectangle drawn at logical (20, 20) ends up around device pixel (40, 40).

So reading (20, 20) samples a different pixel than the one you expect.

One approach is to keep drawing in CSS/logical coordinates but convert the sampling coordinates to device pixels:

const dpr = window.devicePixelRatio;
const pixel = ctx.getImageData(20 * dpr, 20 * dpr, 1, 1).data;

I’d also make sure the canvas backing dimensions are scaled appropriately for the DPR. Otherwise you can still run into blurry rendering or coordinate mismatches.

Ah, you’ve spotted an interesting detail there. The scaling does indeed shift things in a way that can be deceptive. We’ll post the full solution later today.

The problem is ctx.scale() only affects drawing, not getImageData(). getImageData() uses the canvas’s physical pixel coordinates.

Fix it by multiplying the coordinates by devicePixelRatio:

const dpr = window.devicePixelRatio;

ctx.scale(dpr, dpr);

ctx.fillRect(20, 20, 50, 50);

const pixel = ctx.getImageData(20 * dpr, 20 * dpr, 1, 1).data;

That keeps the snapshot position aligned with the scaled drawing.

Spot the Bug answer: The pixel snapshot is reading from the wrong position because the canvas context has been scaled, but the getImageData coordinates are not adjusted for this scaling.

The fix:

const pixel = ctx.getImageData(20 * window.devicePixelRatio, 20 * window.devicePixelRatio, 1, 1).data;

Why:
When ctx.scale is applied, all subsequent drawing operations and coordinate systems are affected. The fillRect call correctly draws at the scaled position, but getImageData operates on the underlying canvas pixels. Therefore, to read the pixel at the intended logical position (20, 20) after scaling, the coordinates passed to getImageData must also be scaled by window.devicePixelRatio.


Got it: @Apexcodes, @emmawalter5

First: @Apexcodes :trophy:

Close but not quite:

@kirupa - They correctly identify that scaling changes the coordinates but don’t explain the crucial difference in how drawing operations versus getImageData interpret these scaled coordinates.

First-answer leaderboard

  1. @kirupa - 6 (firsts) :trophy:
  2. @Apexcodes - 4 (firsts)
  3. @adnanahmed - 2 (firsts)
  4. @emmawalter5 - 2 (firsts)